sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit ad249fe5b25a4f782e944019b165922363983295
parent 6d68bdd73aca544d0d4cc88f785b38a22d4b85d6
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:
MCargo.lock | 122++++++++++++++++---------------------------------------------------------------
Acontracts/releases/supply_chain.toml | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Adeny.toml | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 19++++++++++++++++++-
Atools/xtask/src/supply_chain_qualification.rs | 715+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 873 insertions(+), 99 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -117,9 +117,9 @@ dependencies = [ [[package]] name = "async-utility" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +checksum = "188f83b9a198af8c336e505611edb00d6d2ac5c694241c5a4f9a12316938cfe9" dependencies = [ "futures-util", "gloo-timers", @@ -219,15 +219,15 @@ dependencies = [ [[package]] name = "bitcoin-io" -version = "0.1.100" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", "hex-conservative", @@ -658,7 +658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1468,9 +1468,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.3" +version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" +checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "aes", "base64", @@ -1514,9 +1514,9 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.44.1" +version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" dependencies = [ "async-utility", "async-wsocket", @@ -1756,7 +1756,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -2404,7 +2404,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2908,7 +2908,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3689,7 +3689,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3763,16 +3763,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3790,31 +3781,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3824,96 +3798,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/contracts/releases/supply_chain.toml b/contracts/releases/supply_chain.toml @@ -0,0 +1,49 @@ +schema_version = 1 +spec_id = "radroots.crates.release.v1" +package_version = "0.1.0-alpha" + +[tools] +cargo_deny = "0.19.8" +cargo_cyclonedx = "0.5.9" + +[sbom] +format = "json" +spec_version = "1.5" +target = "all" +all_features = true +source_date_epoch = 0 + +[[advisory_exception]] +id = "RUSTSEC-2024-0384" +package = "instant" +affected_version = "0.1.13" +introduced_by = "nostr 0.44.7" +classification = "unmaintained" +mitigation = "No Radroots public API exposes instant; replace the dependency when upstream Nostr removes it." +remove_when = "nostr >=0.45.0 stable" + +[[advisory_exception]] +id = "RUSTSEC-2024-0421" +package = "idna" +affected_version = "0.5.0" +introduced_by = "nostr 0.44.7 via url-fork 3.0.1" +classification = "vulnerability" +mitigation = "radroots_transport_nostr canonicalizes every relay URL with url >=2.5.4 before upstream Nostr receives it." +remove_when = "nostr >=0.45.0 stable" + +[[advisory_exception]] +id = "CARGO-YANKED-SPIN-0.9.8" +package = "spin" +affected_version = "0.9.8" +introduced_by = "sqlx 0.9.0 via flume 0.12.0" +classification = "yanked" +mitigation = "The package has no RustSec vulnerability; the gate rejects every yanked name/version except this exact transitive dependency." +remove_when = "sqlx no longer resolves flume 0.12.0 with spin 0.9.8" + +[[package]] +name = "radroots_sdk" +manifest_path = "crates/sdk/Cargo.toml" + +[[package]] +name = "radroots" +manifest_path = "crates/radroots/Cargo.toml" diff --git a/deny.toml b/deny.toml @@ -0,0 +1,67 @@ +[graph] +targets = [ + "aarch64-apple-darwin", + "wasm32-unknown-unknown", + "x86_64-pc-windows-gnu", + "x86_64-unknown-linux-gnu", +] +all-features = true + +[advisories] +# `nostr` 0.44.7 depends on `url-fork` 3.0.1 and therefore `idna` 0.5.0. +# The only network-capable public adapter canonicalizes every relay URL with +# patched `url` >=2.5.4 before the value reaches upstream Nostr code. This +# exact exception is contract-bound and must be removed with the Nostr 0.45 +# stable upgrade. +ignore = [ + "RUSTSEC-2024-0384", # `instant` is an unmaintained, non-vulnerability Nostr 0.44 dependency. + "RUSTSEC-2024-0421", +] + +[licenses] +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +confidence-threshold = 0.93 + +[licenses.private] +ignore = false +registries = [] + +[bans] +multiple-versions = "allow" +wildcards = "deny" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" +allow = [] +allow-workspace = true +deny = [] +skip = [] +skip-tree = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] + +[sources.allow-org] +github = [] +gitlab = [] +bitbucket = [] diff --git a/tools/xtask/src/main.rs b/tools/xtask/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/xtask/src/supply_chain_qualification.rs b/tools/xtask/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") + ); + } +}