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