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:
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")
+ );
+ }
+}