catalog.rs (66586B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 path::{Component, Path}, 5 process::Command, 6 }; 7 8 use serde::Deserialize; 9 use sha2::{Digest, Sha256}; 10 11 use crate::contract::artifact_bundle::{ 12 GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction, 13 }; 14 15 const CATALOG_RELATIVE: &str = "contracts/crates/catalog.v2.toml"; 16 const RELEASE_RELATIVE: &str = "contracts/crates/release.v2.toml"; 17 const CONSOLIDATION_RELATIVE: &str = "contracts/consolidation/architecture.v1.toml"; 18 const GROUPS_RELATIVE: &str = "contracts/crates/generated/package_groups.v1.toml"; 19 const PLATFORMS_RELATIVE: &str = "contracts/crates/generated/platform_inventory.v1.toml"; 20 const RELEASE_INVENTORY_RELATIVE: &str = "contracts/crates/generated/release_inventory.v2.toml"; 21 const COVERAGE_RELATIVE: &str = "contracts/coverage.toml"; 22 const CATALOG_SCHEMA: &str = "radroots.workspace.catalog.v2"; 23 const RELEASE_ID: &str = "radroots.crates.release.v2"; 24 const CONSOLIDATION_ID: &str = "radroots.rust.consolidation.v1"; 25 const VERSION: &str = "0.1.0-alpha"; 26 const APPLICATION_RETIREMENT: &str = 27 "contracts/architecture/decisions/tera_application_ownership.v1.json"; 28 const RETIRED_APPLICATION_PACKAGES: [&str; 4] = [ 29 "radroots_mobile_bindgen", 30 "radroots_mobile_core", 31 "radroots_mobile_ffi", 32 "radroots_mobile_wasm", 33 ]; 34 35 #[derive(Debug, Deserialize)] 36 #[serde(deny_unknown_fields)] 37 struct ApplicationRetirement { 38 schema: String, 39 source_repository: String, 40 source_catalog_revision: String, 41 source_catalog_sha256: String, 42 transfer_donor_commit: String, 43 transfer_donor_tree: String, 44 target_repository: String, 45 target_revision: String, 46 transfer_evidence_sha256: String, 47 package: Vec<RetiredApplication>, 48 } 49 50 #[derive(Debug, Deserialize)] 51 #[serde(deny_unknown_fields)] 52 struct RetiredApplication { 53 former_catalog_entry: CatalogPackage, 54 target_name: String, 55 target_path: String, 56 projected_commit: String, 57 projected_tree: String, 58 } 59 60 #[derive(Debug, Deserialize)] 61 #[serde(deny_unknown_fields)] 62 struct Catalog { 63 schema_version: u16, 64 schema: String, 65 architecture: String, 66 consolidation: String, 67 version: String, 68 rust_version: String, 69 edition: String, 70 resolver: String, 71 public_package_count: usize, 72 package_count: usize, 73 digest_algorithm: String, 74 source_tree_digest_algorithm: String, 75 native_introduction_tree_digest_algorithm: String, 76 source_provenance_policy: String, 77 provenance_correction_contract: String, 78 retired_packages: Vec<String>, 79 package: Vec<CatalogPackage>, 80 } 81 82 #[derive(Debug, Deserialize)] 83 #[serde(deny_unknown_fields)] 84 struct CatalogPackage { 85 name: String, 86 path: String, 87 state: String, 88 tier: String, 89 visibility: String, 90 publish: bool, 91 version: String, 92 license: String, 93 platforms: Vec<String>, 94 groups: Vec<String>, 95 owners: Vec<String>, 96 permitted_dependency_tiers: Vec<String>, 97 provenance_kind: String, 98 source_repository: Option<String>, 99 source_revision: Option<String>, 100 source_path: Option<String>, 101 source_tree_sha256: Option<String>, 102 introduction_tree_sha256: Option<String>, 103 compatibility: Vec<String>, 104 removal_gate: Option<String>, 105 replaces: Vec<String>, 106 } 107 108 #[derive(Debug, Deserialize)] 109 #[serde(deny_unknown_fields)] 110 struct ReleaseV2 { 111 schema_version: u16, 112 spec_id: String, 113 status: String, 114 supersedes_without_mutation: String, 115 canonical_repository: String, 116 version: String, 117 public_package_count: usize, 118 publication_authorized: bool, 119 public_packages: Vec<String>, 120 v1_artifact: Vec<V1Artifact>, 121 v1_retired_human_artifact: Vec<RetiredV1HumanArtifact>, 122 } 123 124 #[derive(Debug, Deserialize)] 125 #[serde(deny_unknown_fields)] 126 struct V1Artifact { 127 path: String, 128 sha256: String, 129 } 130 131 #[derive(Debug, Deserialize)] 132 #[serde(deny_unknown_fields)] 133 struct RetiredV1HumanArtifact { 134 former_path: String, 135 sha256: String, 136 } 137 138 #[derive(Debug, Deserialize)] 139 #[serde(deny_unknown_fields)] 140 struct ConsolidationV1 { 141 schema_version: u16, 142 consolidation_id: String, 143 architecture: String, 144 canonical_rust_repository: String, 145 donor_repositories: Vec<String>, 146 consumer_repositories: Vec<String>, 147 source_retirement_requires: Vec<String>, 148 repository_archival_requires: Vec<String>, 149 } 150 151 #[derive(Debug, Deserialize)] 152 struct CoveragePolicy { 153 required: CoverageRequired, 154 } 155 156 #[derive(Debug, Deserialize)] 157 struct CoverageRequired { 158 crates: Vec<String>, 159 } 160 161 #[derive(Debug, Deserialize)] 162 struct CargoMetadata { 163 packages: Vec<CargoPackage>, 164 workspace_members: Vec<String>, 165 } 166 167 #[derive(Debug, Deserialize)] 168 struct CargoPackage { 169 id: String, 170 name: String, 171 version: String, 172 manifest_path: String, 173 license: Option<String>, 174 publish: Option<Vec<String>>, 175 dependencies: Vec<CargoDependency>, 176 } 177 178 #[derive(Debug, Deserialize)] 179 struct CargoDependency { 180 name: String, 181 source: Option<String>, 182 req: String, 183 path: Option<String>, 184 kind: Option<String>, 185 target: Option<String>, 186 } 187 188 pub fn run(args: &[String], workspace_root: &Path) -> Result<(), String> { 189 match args { 190 [command] if command == "check" => check(workspace_root), 191 [command] if command == "write" => write(workspace_root), 192 _ => Err("catalog accepts check or write".to_owned()), 193 } 194 } 195 196 pub(crate) fn check(workspace_root: &Path) -> Result<(), String> { 197 check_with_provenance(workspace_root, true) 198 } 199 200 pub(crate) fn check_source_export(workspace_root: &Path) -> Result<(), String> { 201 check_with_provenance(workspace_root, false) 202 } 203 204 pub(crate) fn active_group(workspace_root: &Path, group: &str) -> Result<Vec<String>, String> { 205 validate_identifier(group, "package group")?; 206 active_packages_matching(workspace_root, |package| { 207 package.groups.iter().any(|candidate| candidate == group) 208 }) 209 } 210 211 pub(crate) fn active_packages(workspace_root: &Path) -> Result<Vec<String>, String> { 212 active_packages_matching(workspace_root, |_| true) 213 } 214 215 fn active_packages_matching( 216 workspace_root: &Path, 217 predicate: impl Fn(&CatalogPackage) -> bool, 218 ) -> Result<Vec<String>, String> { 219 let catalog = parse_file::<Catalog>(workspace_root, CATALOG_RELATIVE)?; 220 validate_catalog(&catalog)?; 221 validate_application_retirement(&catalog, workspace_root)?; 222 let mut packages = catalog 223 .package 224 .iter() 225 .filter(|package| package.state == "active" && predicate(package)) 226 .map(|package| package.name.clone()) 227 .collect::<Vec<_>>(); 228 packages.sort_unstable(); 229 Ok(packages) 230 } 231 232 fn check_with_provenance( 233 workspace_root: &Path, 234 require_git_provenance: bool, 235 ) -> Result<(), String> { 236 let loaded = load_and_validate(workspace_root, require_git_provenance)?; 237 for artifact in render_projections(&loaded.catalog, &loaded.catalog_digest) { 238 let current = read_regular_file(workspace_root, artifact.relative)?; 239 if current != artifact.contents { 240 return Err(format!( 241 "generated catalog projection {} is stale; run catalog write", 242 artifact.relative 243 )); 244 } 245 } 246 Ok(()) 247 } 248 249 fn write(workspace_root: &Path) -> Result<(), String> { 250 let loaded = load_and_validate(workspace_root, true)?; 251 let artifacts = render_projections(&loaded.catalog, &loaded.catalog_digest); 252 with_artifact_bundle_transaction(workspace_root, |transaction| transaction.write(artifacts)) 253 } 254 255 struct LoadedCatalog { 256 catalog: Catalog, 257 catalog_digest: String, 258 } 259 260 fn load_and_validate( 261 workspace_root: &Path, 262 require_git_provenance: bool, 263 ) -> Result<LoadedCatalog, String> { 264 let catalog_bytes = read_regular_file(workspace_root, CATALOG_RELATIVE)?; 265 let catalog = parse_toml::<Catalog>(CATALOG_RELATIVE, &catalog_bytes)?; 266 let release = parse_file::<ReleaseV2>(workspace_root, RELEASE_RELATIVE)?; 267 let consolidation = parse_file::<ConsolidationV1>(workspace_root, CONSOLIDATION_RELATIVE)?; 268 let coverage = parse_file::<CoveragePolicy>(workspace_root, COVERAGE_RELATIVE)?; 269 validate_catalog(&catalog)?; 270 validate_application_retirement(&catalog, workspace_root)?; 271 validate_coverage_authority(&catalog, &coverage)?; 272 validate_release(&release, &catalog, workspace_root)?; 273 validate_consolidation(&consolidation)?; 274 validate_workspace_manifest(&catalog, workspace_root)?; 275 let metadata = cargo_metadata(workspace_root)?; 276 validate_metadata(&catalog, &metadata, workspace_root)?; 277 if require_git_provenance { 278 validate_active_source_provenance(&catalog, workspace_root)?; 279 } 280 Ok(LoadedCatalog { 281 catalog, 282 catalog_digest: sha256(&catalog_bytes), 283 }) 284 } 285 286 fn parse_file<T: for<'de> Deserialize<'de>>( 287 workspace_root: &Path, 288 relative: &str, 289 ) -> Result<T, String> { 290 let bytes = read_regular_file(workspace_root, relative)?; 291 parse_toml(relative, &bytes) 292 } 293 294 fn parse_toml<T: for<'de> Deserialize<'de>>(relative: &str, bytes: &[u8]) -> Result<T, String> { 295 let raw = 296 std::str::from_utf8(bytes).map_err(|error| format!("{relative} is not UTF-8: {error}"))?; 297 toml::from_str(raw).map_err(|error| format!("parse {relative}: {error}")) 298 } 299 300 fn validate_catalog(catalog: &Catalog) -> Result<(), String> { 301 if catalog.schema_version != 2 302 || catalog.schema != CATALOG_SCHEMA 303 || catalog.architecture != RELEASE_ID 304 || catalog.consolidation != CONSOLIDATION_ID 305 || catalog.version != VERSION 306 || catalog.rust_version != "1.97.1" 307 || catalog.edition != "2024" 308 || catalog.resolver != "3" 309 || catalog.public_package_count != 19 310 || catalog.package_count != catalog.package.len() 311 || catalog.digest_algorithm != "sha256-raw-bytes-v1" 312 || catalog.source_tree_digest_algorithm != "sha256-git-ls-tree-r-v1" 313 || catalog.native_introduction_tree_digest_algorithm != "sha256-git-tree-records-z-v1" 314 || catalog.source_provenance_policy != "imported_revision_tree_or_native_introduction_tree" 315 || catalog.provenance_correction_contract != "approved_correction_record_required" 316 { 317 return Err("catalog identity, toolchain, or cardinality drifted".to_owned()); 318 } 319 320 let expected_public = expected_public_packages(); 321 let allowed_states = BTreeSet::from(["active", "reserved_import", "reserved_refactor"]); 322 let allowed_visibilities = BTreeSet::from([ 323 "public_release", 324 "private_runtime", 325 "private_adapter", 326 "private_boundary", 327 "private_codegen", 328 "private_fixture", 329 "private_tool", 330 ]); 331 let allowed_licenses = BTreeSet::from([ 332 "MIT OR Apache-2.0", 333 "GPL-3.0-only", 334 "GPL-3.0-or-later", 335 "MPL-2.0", 336 ]); 337 let mut names = BTreeSet::new(); 338 let mut paths = BTreeSet::new(); 339 let mut public = BTreeSet::new(); 340 let mut package_by_name = BTreeMap::new(); 341 let mut groups = BTreeSet::new(); 342 for package in &catalog.package { 343 validate_identifier(&package.name, "package name")?; 344 validate_relative_path(&package.path)?; 345 validate_identifier(&package.tier, "package tier")?; 346 let expected_path = match package.name.as_str() { 347 "radroots" => "crates/radroots".to_owned(), 348 "xtask" => "tools/xtask".to_owned(), 349 name => format!( 350 "crates/{}", 351 name.strip_prefix("radroots_") 352 .ok_or_else(|| format!("package {name} lacks the radroots_ prefix"))? 353 ), 354 }; 355 if package.path != expected_path { 356 return Err(format!( 357 "package {} must use canonical path {expected_path}", 358 package.name 359 )); 360 } 361 if !names.insert(package.name.as_str()) || !paths.insert(package.path.as_str()) { 362 return Err(format!( 363 "duplicate package name or path for {}", 364 package.name 365 )); 366 } 367 if !allowed_states.contains(package.state.as_str()) 368 || !allowed_visibilities.contains(package.visibility.as_str()) 369 || !allowed_licenses.contains(package.license.as_str()) 370 || package.version != VERSION 371 || package.platforms.is_empty() 372 || package.groups.is_empty() 373 || package.owners.is_empty() 374 || package.permitted_dependency_tiers.is_empty() 375 || package.compatibility.is_empty() 376 { 377 return Err(format!("catalog package {} is incomplete", package.name)); 378 } 379 validate_unique_identifiers(&package.platforms, "platform")?; 380 validate_unique_identifiers(&package.groups, "group")?; 381 validate_unique_identifiers(&package.owners, "owner")?; 382 validate_unique_identifiers( 383 &package.permitted_dependency_tiers, 384 "permitted dependency tier", 385 )?; 386 validate_unique_identifiers(&package.compatibility, "compatibility authority")?; 387 for group in &package.groups { 388 groups.insert(group.as_str()); 389 } 390 if package.visibility == "public_release" { 391 public.insert(package.name.as_str()); 392 if !package.publish || package.license != "MIT OR Apache-2.0" { 393 return Err(format!( 394 "public package {} must be permissive and publication-eligible", 395 package.name 396 )); 397 } 398 } else if package.publish { 399 return Err(format!( 400 "private package {} must set publish=false", 401 package.name 402 )); 403 } 404 if package.license.starts_with("GPL-") 405 && !matches!( 406 package.visibility.as_str(), 407 "private_runtime" | "private_adapter" | "private_boundary" | "private_codegen" 408 ) 409 { 410 return Err(format!("GPL package {} has an invalid class", package.name)); 411 } 412 validate_package_provenance(package)?; 413 for replaced in &package.replaces { 414 validate_package_identity(replaced, "replaced package")?; 415 } 416 if package.state == "active" && package.removal_gate.is_some() { 417 return Err(format!( 418 "active package {} has a removal gate", 419 package.name 420 )); 421 } 422 if package.state != "active" 423 && package 424 .removal_gate 425 .as_deref() 426 .is_none_or(|gate| gate.trim().is_empty()) 427 { 428 return Err(format!( 429 "reserved package {} lacks a removal gate", 430 package.name 431 )); 432 } 433 package_by_name.insert(package.name.as_str(), package); 434 } 435 if public != expected_public { 436 return Err("catalog must contain exactly the 19 approved public packages".to_owned()); 437 } 438 let required_groups = BTreeSet::from([ 439 "boundaries", 440 "coverage_required", 441 "portable", 442 "preview", 443 "public_native", 444 "sdk", 445 "tools", 446 "wasm", 447 ]); 448 if !required_groups.is_subset(&groups) { 449 return Err("catalog is missing one or more required package groups".to_owned()); 450 } 451 let portable = catalog 452 .package 453 .iter() 454 .filter(|package| package.groups.iter().any(|group| group == "portable")) 455 .map(|package| package.name.as_str()) 456 .collect::<BTreeSet<_>>(); 457 if portable != expected_public { 458 return Err("portable group must contain exactly the 19 public packages".to_owned()); 459 } 460 for package in &catalog.package { 461 for tier in &package.permitted_dependency_tiers { 462 if !catalog 463 .package 464 .iter() 465 .any(|candidate| candidate.tier == *tier) 466 { 467 return Err(format!( 468 "package {} permits unknown tier {tier}", 469 package.name 470 )); 471 } 472 } 473 } 474 let retired = catalog 475 .retired_packages 476 .iter() 477 .map(String::as_str) 478 .collect::<BTreeSet<_>>(); 479 if retired.len() != catalog.retired_packages.len() 480 || retired 481 .iter() 482 .any(|name| package_by_name.contains_key(name)) 483 || !expected_retired_packages().is_subset(&retired) 484 { 485 return Err("retired package identities are incomplete or reused".to_owned()); 486 } 487 let xtasks = catalog 488 .package 489 .iter() 490 .filter(|package| package.name == "xtask") 491 .collect::<Vec<_>>(); 492 if xtasks.len() != 1 493 || xtasks[0].path != "tools/xtask" 494 || xtasks[0].visibility != "private_tool" 495 || xtasks[0].state != "active" 496 { 497 return Err("catalog must contain exactly one canonical xtask".to_owned()); 498 } 499 Ok(()) 500 } 501 502 fn validate_package_provenance(package: &CatalogPackage) -> Result<(), String> { 503 match package.provenance_kind.as_str() { 504 "imported" => { 505 let source_repository = package.source_repository.as_deref().ok_or_else(|| { 506 format!("imported package {} lacks source repository", package.name) 507 })?; 508 let source_revision = package.source_revision.as_deref().ok_or_else(|| { 509 format!("imported package {} lacks source revision", package.name) 510 })?; 511 let source_path = package 512 .source_path 513 .as_deref() 514 .ok_or_else(|| format!("imported package {} lacks source path", package.name))?; 515 let source_tree_sha256 = package.source_tree_sha256.as_deref().ok_or_else(|| { 516 format!("imported package {} lacks source tree digest", package.name) 517 })?; 518 if package.introduction_tree_sha256.is_some() { 519 return Err(format!( 520 "imported package {} declares native introduction provenance", 521 package.name 522 )); 523 } 524 if source_repository 525 != format!( 526 "https://github.com/radrootslabs/{}", 527 source_repository_name(source_repository)? 528 ) 529 { 530 return Err(format!( 531 "package {} source repository is noncanonical", 532 package.name 533 )); 534 } 535 validate_oid(source_revision, "source revision")?; 536 validate_relative_path(source_path)?; 537 validate_sha256(source_tree_sha256, "source tree digest") 538 } 539 "native" => { 540 if package.source_repository.is_some() 541 || package.source_revision.is_some() 542 || package.source_path.is_some() 543 || package.source_tree_sha256.is_some() 544 { 545 return Err(format!( 546 "native package {} must not declare imported provenance", 547 package.name 548 )); 549 } 550 let introduction_tree_sha256 = 551 package.introduction_tree_sha256.as_deref().ok_or_else(|| { 552 format!( 553 "native package {} lacks introduction tree digest", 554 package.name 555 ) 556 })?; 557 validate_sha256(introduction_tree_sha256, "introduction tree digest")?; 558 if package.state != "active" 559 || package.publish 560 || package.visibility == "public_release" 561 { 562 return Err(format!( 563 "native package {} must be active and unpublished", 564 package.name 565 )); 566 } 567 Ok(()) 568 } 569 _ => Err(format!( 570 "package {} has unknown provenance kind {}", 571 package.name, package.provenance_kind 572 )), 573 } 574 } 575 576 fn validate_coverage_authority(catalog: &Catalog, coverage: &CoveragePolicy) -> Result<(), String> { 577 let catalog_required = catalog 578 .package 579 .iter() 580 .filter(|package| { 581 package.state == "active" 582 && package 583 .groups 584 .iter() 585 .any(|group| group == "coverage_required") 586 }) 587 .map(|package| package.name.as_str()) 588 .collect::<BTreeSet<_>>(); 589 let policy_required = coverage 590 .required 591 .crates 592 .iter() 593 .map(String::as_str) 594 .collect::<BTreeSet<_>>(); 595 if policy_required.len() != coverage.required.crates.len() { 596 return Err("coverage policy contains duplicate required packages".to_owned()); 597 } 598 if catalog_required != policy_required { 599 return Err( 600 "coverage required packages must exactly match the catalog coverage_required group" 601 .to_owned(), 602 ); 603 } 604 Ok(()) 605 } 606 607 fn validate_release( 608 release: &ReleaseV2, 609 catalog: &Catalog, 610 workspace_root: &Path, 611 ) -> Result<(), String> { 612 if release.schema_version != 2 613 || release.spec_id != RELEASE_ID 614 || release.status != "approved_not_published" 615 || release.supersedes_without_mutation != "radroots.crates.release.v1" 616 || release.canonical_repository != "https://github.com/radrootslabs/lib" 617 || release.version != VERSION 618 || release.public_package_count != 19 619 || release.publication_authorized 620 || release 621 .public_packages 622 .iter() 623 .map(String::as_str) 624 .collect::<BTreeSet<_>>() 625 != expected_public_packages() 626 { 627 return Err("release v2 identity or public allocation drifted".to_owned()); 628 } 629 if release.public_packages.len() != release.public_package_count 630 || catalog.public_package_count != release.public_package_count 631 { 632 return Err("release v2 package cardinality drifted".to_owned()); 633 } 634 let mut paths = BTreeSet::new(); 635 for artifact in &release.v1_artifact { 636 validate_relative_path(&artifact.path)?; 637 validate_sha256(&artifact.sha256, "v1 artifact digest")?; 638 if !paths.insert(artifact.path.as_str()) { 639 return Err("release v2 repeats a v1 artifact".to_owned()); 640 } 641 let bytes = read_regular_file(workspace_root, &artifact.path)?; 642 if sha256(&bytes) != artifact.sha256 { 643 return Err(format!("historical v1 artifact {} drifted", artifact.path)); 644 } 645 } 646 let expected = BTreeSet::from([ 647 "contracts/crates/release_v1/radroots_crates_release_v1.dot", 648 "contracts/crates/release_v1/radroots_crates_release_v1.sha256", 649 "contracts/crates/release_v1/radroots_crates_release_v1.toml", 650 "contracts/crates/release_v1/radroots_crates_release_v1_inventory.csv", 651 ]); 652 if paths != expected { 653 return Err("release v2 must pin every historical v1 machine artifact".to_owned()); 654 } 655 if release.v1_retired_human_artifact.len() != 1 { 656 return Err("release v2 must record the retired v1 human artifact".to_owned()); 657 } 658 let retired = &release.v1_retired_human_artifact[0]; 659 validate_relative_path(&retired.former_path)?; 660 validate_sha256(&retired.sha256, "retired v1 human artifact digest")?; 661 if retired.former_path != "docs/specs/radroots_crates_release_v1.md" 662 || retired.sha256 != "ea2c1f0f5c53fae56a247ae7519b065c9a0d62dafb998b75a48075f4a875b5eb" 663 { 664 return Err("release v2 retired v1 human artifact drifted".to_owned()); 665 } 666 Ok(()) 667 } 668 669 fn validate_consolidation(contract: &ConsolidationV1) -> Result<(), String> { 670 if contract.schema_version != 1 671 || contract.consolidation_id != CONSOLIDATION_ID 672 || contract.architecture != RELEASE_ID 673 || contract.canonical_rust_repository != "https://github.com/radrootslabs/lib" 674 || contract 675 .donor_repositories 676 .iter() 677 .map(String::as_str) 678 .collect::<BTreeSet<_>>() 679 != BTreeSet::from([ 680 "https://github.com/radrootslabs/app_rt", 681 "https://github.com/radrootslabs/sdk", 682 "https://github.com/radrootslabs/studio_app", 683 ]) 684 || contract 685 .consumer_repositories 686 .iter() 687 .map(String::as_str) 688 .collect::<BTreeSet<_>>() 689 != BTreeSet::from([ 690 "https://github.com/radrootslabs/sdk", 691 "https://github.com/radrootslabs/studio_app", 692 ]) 693 || contract.source_retirement_requires.is_empty() 694 || contract.repository_archival_requires.is_empty() 695 { 696 return Err("consolidation v1 repository authority drifted".to_owned()); 697 } 698 validate_unique_identifiers( 699 &contract.source_retirement_requires, 700 "source retirement requirement", 701 )?; 702 validate_unique_identifiers( 703 &contract.repository_archival_requires, 704 "repository archival requirement", 705 ) 706 } 707 708 fn cargo_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { 709 let output = Command::new("cargo") 710 .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) 711 .current_dir(workspace_root) 712 .output() 713 .map_err(|error| format!("run cargo metadata: {error}"))?; 714 if !output.status.success() { 715 return Err(format!( 716 "cargo metadata failed: {}", 717 String::from_utf8_lossy(&output.stderr).trim() 718 )); 719 } 720 serde_json::from_slice(&output.stdout) 721 .map_err(|error| format!("parse cargo metadata output: {error}")) 722 } 723 724 fn validate_workspace_manifest(catalog: &Catalog, workspace_root: &Path) -> Result<(), String> { 725 let bytes = read_regular_file(workspace_root, "Cargo.toml")?; 726 let raw = 727 std::str::from_utf8(&bytes).map_err(|error| format!("Cargo.toml is not UTF-8: {error}"))?; 728 let manifest = 729 toml::from_str::<toml::Value>(raw).map_err(|error| format!("parse Cargo.toml: {error}"))?; 730 let workspace = manifest 731 .get("workspace") 732 .and_then(toml::Value::as_table) 733 .ok_or_else(|| "Cargo.toml lacks [workspace]".to_owned())?; 734 let members = workspace 735 .get("members") 736 .and_then(toml::Value::as_array) 737 .ok_or_else(|| "Cargo.toml lacks explicit workspace members".to_owned())? 738 .iter() 739 .map(|value| { 740 value 741 .as_str() 742 .ok_or_else(|| "workspace member must be a string".to_owned()) 743 }) 744 .collect::<Result<BTreeSet<_>, _>>()?; 745 let active_paths = catalog 746 .package 747 .iter() 748 .filter(|package| package.state == "active") 749 .map(|package| package.path.as_str()) 750 .collect::<BTreeSet<_>>(); 751 if members != active_paths 752 || members 753 .iter() 754 .any(|member| member.contains('*') || member.contains('?') || member.contains('[')) 755 { 756 return Err( 757 "Cargo workspace members must exactly match explicit active catalog paths".to_owned(), 758 ); 759 } 760 let default_members = workspace 761 .get("default-members") 762 .and_then(toml::Value::as_array) 763 .ok_or_else(|| "Cargo.toml lacks explicit default members".to_owned())? 764 .iter() 765 .map(|value| { 766 value 767 .as_str() 768 .ok_or_else(|| "default member must be a string".to_owned()) 769 }) 770 .collect::<Result<BTreeSet<_>, _>>()?; 771 let expected_defaults = catalog 772 .package 773 .iter() 774 .filter(|package| { 775 package.state == "active" 776 && (package.groups.iter().any(|group| group == "portable") 777 || package.name == "xtask") 778 }) 779 .map(|package| package.path.as_str()) 780 .collect::<BTreeSet<_>>(); 781 if default_members != expected_defaults { 782 return Err( 783 "Cargo default members must match active portable packages plus xtask".to_owned(), 784 ); 785 } 786 if workspace.get("resolver").and_then(toml::Value::as_str) != Some("3") { 787 return Err("Cargo workspace resolver must remain 3".to_owned()); 788 } 789 let dependencies = manifest 790 .get("workspace") 791 .and_then(|workspace| workspace.get("dependencies")) 792 .and_then(toml::Value::as_table) 793 .ok_or_else(|| "Cargo.toml lacks [workspace.dependencies]".to_owned())?; 794 for (name, value) in dependencies { 795 let name = value 796 .get("package") 797 .and_then(toml::Value::as_str) 798 .unwrap_or(name); 799 validate_foundation_dependency(name, value.get("git").and_then(toml::Value::as_str))?; 800 } 801 for package in catalog 802 .package 803 .iter() 804 .filter(|package| package.state == "active" && package.name != "xtask") 805 { 806 let dependency = dependencies 807 .get(&package.name) 808 .and_then(toml::Value::as_table) 809 .ok_or_else(|| format!("workspace dependency {} is missing", package.name))?; 810 if dependency.get("path").and_then(toml::Value::as_str) != Some(package.path.as_str()) 811 || dependency.get("version").and_then(toml::Value::as_str) 812 != Some(format!("={VERSION}").as_str()) 813 || ["git", "rev", "branch", "tag"] 814 .iter() 815 .any(|key| dependency.contains_key(*key)) 816 { 817 return Err(format!( 818 "workspace dependency {} must use its catalog path and exact version only", 819 package.name 820 )); 821 } 822 } 823 Ok(()) 824 } 825 826 fn validate_foundation_dependency(name: &str, source: Option<&str>) -> Result<(), String> { 827 if expected_retired_packages().contains(name) 828 || name == "tera" 829 || name.starts_with("tera_") 830 || source.is_some_and(|source| source.contains("radrootslabs/tera")) 831 { 832 return Err(format!( 833 "shared foundation cannot depend on retired or application package {name}" 834 )); 835 } 836 Ok(()) 837 } 838 839 fn validate_application_retirement(catalog: &Catalog, root: &Path) -> Result<(), String> { 840 let bytes = read_regular_file(root, APPLICATION_RETIREMENT)?; 841 let retirement: ApplicationRetirement = serde_json::from_slice(&bytes) 842 .map_err(|error| format!("parse application ownership decision: {error}"))?; 843 if retirement.schema != "radroots.tera.application-ownership.v1" 844 || retirement.source_repository != "https://github.com/radrootslabs/lib" 845 || retirement.target_repository != "https://github.com/radrootslabs/tera" 846 || retirement.package.len() != RETIRED_APPLICATION_PACKAGES.len() 847 { 848 return Err("application ownership decision identity drifted".to_owned()); 849 } 850 validate_oid( 851 &retirement.source_catalog_revision, 852 "former catalog revision", 853 )?; 854 validate_oid( 855 &retirement.transfer_donor_commit, 856 "application donor commit", 857 )?; 858 validate_oid(&retirement.transfer_donor_tree, "application donor tree")?; 859 validate_oid(&retirement.target_revision, "application target revision")?; 860 validate_sha256(&retirement.source_catalog_sha256, "former catalog digest")?; 861 validate_sha256( 862 &retirement.transfer_evidence_sha256, 863 "transfer evidence digest", 864 )?; 865 let mut names = BTreeSet::new(); 866 for transferred in &retirement.package { 867 let former = &transferred.former_catalog_entry; 868 if !RETIRED_APPLICATION_PACKAGES.contains(&former.name.as_str()) 869 || !names.insert(former.name.as_str()) 870 || !catalog.retired_packages.contains(&former.name) 871 || catalog 872 .package 873 .iter() 874 .any(|package| package.name == former.name) 875 || former.state != "active" 876 || former.publish 877 || former.license != "GPL-3.0-or-later" 878 || former.path != format!("crates/{}", former.name.trim_start_matches("radroots_")) 879 || transferred.target_name != former.name.replacen("radroots_mobile_", "tera_", 1) 880 || transferred.target_path != format!("core/crates/{}", transferred.target_name) 881 { 882 return Err( 883 "application package retirement or retained attribution drifted".to_owned(), 884 ); 885 } 886 validate_package_provenance(former)?; 887 validate_oid( 888 &transferred.projected_commit, 889 "application projected commit", 890 )?; 891 validate_oid(&transferred.projected_tree, "application projected tree")?; 892 match fs::symlink_metadata(root.join(&former.path)) { 893 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 894 Err(error) => return Err(format!("inspect retired application path: {error}")), 895 Ok(_) => { 896 return Err(format!( 897 "retired application path {} remains present", 898 former.path 899 )); 900 } 901 } 902 } 903 Ok(()) 904 } 905 906 fn validate_metadata( 907 catalog: &Catalog, 908 metadata: &CargoMetadata, 909 workspace_root: &Path, 910 ) -> Result<(), String> { 911 let workspace_ids = metadata 912 .workspace_members 913 .iter() 914 .map(String::as_str) 915 .collect::<BTreeSet<_>>(); 916 let workspace_packages = metadata 917 .packages 918 .iter() 919 .filter(|package| workspace_ids.contains(package.id.as_str())) 920 .map(|package| (package.name.as_str(), package)) 921 .collect::<BTreeMap<_, _>>(); 922 let catalog_by_name = catalog 923 .package 924 .iter() 925 .map(|package| (package.name.as_str(), package)) 926 .collect::<BTreeMap<_, _>>(); 927 let active = catalog 928 .package 929 .iter() 930 .filter(|package| package.state == "active") 931 .map(|package| package.name.as_str()) 932 .collect::<BTreeSet<_>>(); 933 if workspace_packages.keys().copied().collect::<BTreeSet<_>>() != active { 934 return Err("active catalog packages and Cargo workspace members differ".to_owned()); 935 } 936 for package in catalog 937 .package 938 .iter() 939 .filter(|package| package.state != "active") 940 { 941 if workspace_root.join(&package.path).exists() { 942 return Err(format!( 943 "reserved package path {} exists before its catalog activation", 944 package.path 945 )); 946 } 947 } 948 let canonical_root = fs::canonicalize(workspace_root) 949 .map_err(|error| format!("canonicalize {}: {error}", workspace_root.display()))?; 950 for (name, cargo) in &workspace_packages { 951 let package = catalog_by_name[name]; 952 let manifest = Path::new(&cargo.manifest_path); 953 let relative_manifest = manifest 954 .strip_prefix(&canonical_root) 955 .map_err(|_| format!("manifest {} escapes workspace", manifest.display()))?; 956 let actual_path = relative_manifest 957 .parent() 958 .ok_or_else(|| format!("manifest {} has no package root", manifest.display()))? 959 .to_string_lossy() 960 .replace('\\', "/"); 961 let cargo_publish = cargo 962 .publish 963 .as_ref() 964 .is_some_and(|registries| !registries.is_empty()); 965 if package.path != actual_path 966 || package.version != cargo.version 967 || package.license != cargo.license.as_deref().unwrap_or("") 968 || package.publish != cargo_publish 969 { 970 return Err(format!("catalog and Cargo metadata drifted for {name}")); 971 } 972 for dependency in &cargo.dependencies { 973 validate_foundation_dependency(&dependency.name, dependency.source.as_deref())?; 974 let Some(target) = catalog_by_name.get(dependency.name.as_str()) else { 975 continue; 976 }; 977 if dependency.path.is_none() || dependency.req != format!("={VERSION}") { 978 return Err(format!( 979 "first-party dependency {name} -> {} must use path plus ={VERSION}", 980 dependency.name 981 )); 982 } 983 if !package 984 .permitted_dependency_tiers 985 .iter() 986 .any(|tier| tier == &target.tier) 987 { 988 return Err(format!( 989 "catalog tier policy forbids {name} -> {} ({:?}, {:?})", 990 dependency.name, dependency.kind, dependency.target 991 )); 992 } 993 if package.visibility == "public_release" && target.visibility != "public_release" { 994 return Err(format!( 995 "public package {name} depends on private package {}", 996 dependency.name 997 )); 998 } 999 if package.license == "MIT OR Apache-2.0" && target.license.starts_with("GPL-") { 1000 return Err(format!( 1001 "permissive package {name} depends on GPL package {}", 1002 dependency.name 1003 )); 1004 } 1005 } 1006 } 1007 Ok(()) 1008 } 1009 1010 fn validate_active_source_provenance( 1011 catalog: &Catalog, 1012 workspace_root: &Path, 1013 ) -> Result<(), String> { 1014 for package in catalog 1015 .package 1016 .iter() 1017 .filter(|package| package.state == "active") 1018 { 1019 match package.provenance_kind.as_str() { 1020 "imported" 1021 if package.source_repository.as_deref() 1022 == Some("https://github.com/radrootslabs/lib") => 1023 { 1024 validate_imported_source_provenance(package, workspace_root)?; 1025 } 1026 "native" => validate_native_source_provenance(package, workspace_root)?, 1027 _ => {} 1028 } 1029 } 1030 Ok(()) 1031 } 1032 1033 fn validate_imported_source_provenance( 1034 package: &CatalogPackage, 1035 workspace_root: &Path, 1036 ) -> Result<(), String> { 1037 let source_revision = package 1038 .source_revision 1039 .as_deref() 1040 .ok_or_else(|| format!("imported package {} lacks source revision", package.name))?; 1041 let source_path = package 1042 .source_path 1043 .as_deref() 1044 .ok_or_else(|| format!("imported package {} lacks source path", package.name))?; 1045 let expected = package 1046 .source_tree_sha256 1047 .as_deref() 1048 .ok_or_else(|| format!("imported package {} lacks source tree digest", package.name))?; 1049 let output = Command::new("git") 1050 .args(["ls-tree", "-r", source_revision, "--", source_path]) 1051 .current_dir(workspace_root) 1052 .output() 1053 .map_err(|error| format!("run git ls-tree for {}: {error}", package.name))?; 1054 if !output.status.success() { 1055 return Err(format!( 1056 "source revision for {} is unavailable: {}", 1057 package.name, 1058 String::from_utf8_lossy(&output.stderr).trim() 1059 )); 1060 } 1061 if sha256(&output.stdout) != expected { 1062 return Err(format!( 1063 "source tree provenance drifted for {}", 1064 package.name 1065 )); 1066 } 1067 Ok(()) 1068 } 1069 1070 fn validate_native_source_provenance( 1071 package: &CatalogPackage, 1072 workspace_root: &Path, 1073 ) -> Result<(), String> { 1074 let expected = package 1075 .introduction_tree_sha256 1076 .as_deref() 1077 .ok_or_else(|| format!("native package {} lacks introduction digest", package.name))?; 1078 let introducing_commit = native_introducing_commit(workspace_root, &package.path)?; 1079 let actual = if let Some(commit) = introducing_commit { 1080 committed_tree_digest(workspace_root, &commit, &package.path)? 1081 } else { 1082 staged_tree_digest(workspace_root, &package.path)? 1083 }; 1084 if actual != expected { 1085 return Err(format!( 1086 "native introduction tree provenance drifted for {}", 1087 package.name 1088 )); 1089 } 1090 Ok(()) 1091 } 1092 1093 fn native_introducing_commit( 1094 workspace_root: &Path, 1095 package_path: &str, 1096 ) -> Result<Option<String>, String> { 1097 let output = Command::new("git") 1098 .args([ 1099 "log", 1100 "--format=%H", 1101 "--diff-filter=A", 1102 "--reverse", 1103 "--no-renames", 1104 "HEAD", 1105 "--", 1106 package_path, 1107 ]) 1108 .current_dir(workspace_root) 1109 .output() 1110 .map_err(|error| format!("derive native introduction commit: {error}"))?; 1111 if !output.status.success() { 1112 return Err(format!( 1113 "derive native introduction commit for {package_path}: {}", 1114 String::from_utf8_lossy(&output.stderr).trim() 1115 )); 1116 } 1117 let stdout = std::str::from_utf8(&output.stdout) 1118 .map_err(|error| format!("native introduction history is not UTF-8: {error}"))?; 1119 let Some(commit) = stdout.lines().next() else { 1120 return Ok(None); 1121 }; 1122 validate_oid(commit, "native introducing commit")?; 1123 Ok(Some(commit.to_owned())) 1124 } 1125 1126 fn committed_tree_digest( 1127 workspace_root: &Path, 1128 commit: &str, 1129 package_path: &str, 1130 ) -> Result<String, String> { 1131 let output = Command::new("git") 1132 .args(["ls-tree", "-r", "-z", commit, "--", package_path]) 1133 .current_dir(workspace_root) 1134 .output() 1135 .map_err(|error| format!("read native introduction tree: {error}"))?; 1136 if !output.status.success() { 1137 return Err(format!( 1138 "read native introduction tree for {package_path}: {}", 1139 String::from_utf8_lossy(&output.stderr).trim() 1140 )); 1141 } 1142 if output.stdout.is_empty() { 1143 return Err(format!( 1144 "native introducing commit {commit} has no tree at {package_path}" 1145 )); 1146 } 1147 Ok(sha256(&output.stdout)) 1148 } 1149 1150 fn staged_tree_digest(workspace_root: &Path, package_path: &str) -> Result<String, String> { 1151 let output = Command::new("git") 1152 .args(["ls-files", "--stage", "-z", "--", package_path]) 1153 .current_dir(workspace_root) 1154 .output() 1155 .map_err(|error| format!("read staged native tree: {error}"))?; 1156 if !output.status.success() { 1157 return Err(format!( 1158 "read staged native tree for {package_path}: {}", 1159 String::from_utf8_lossy(&output.stderr).trim() 1160 )); 1161 } 1162 let records = canonical_staged_tree_records(&output.stdout)?; 1163 if records.is_empty() { 1164 return Err(format!( 1165 "native package {package_path} has no committed introduction or staged tree" 1166 )); 1167 } 1168 Ok(sha256(&records)) 1169 } 1170 1171 fn canonical_staged_tree_records(input: &[u8]) -> Result<Vec<u8>, String> { 1172 let mut output = Vec::new(); 1173 for record in input 1174 .split(|byte| *byte == 0) 1175 .filter(|record| !record.is_empty()) 1176 { 1177 let tab = record 1178 .iter() 1179 .position(|byte| *byte == b'\t') 1180 .ok_or_else(|| "staged tree record lacks a path separator".to_owned())?; 1181 let header = std::str::from_utf8(&record[..tab]) 1182 .map_err(|error| format!("staged tree record header is not UTF-8: {error}"))?; 1183 let fields = header.split(' ').collect::<Vec<_>>(); 1184 if fields.len() != 3 || fields[2] != "0" { 1185 return Err("staged native tree must contain only stage-zero records".to_owned()); 1186 } 1187 let mode = fields[0]; 1188 let oid = fields[1]; 1189 if mode.len() != 6 || !mode.bytes().all(|byte| matches!(byte, b'0'..=b'7')) { 1190 return Err("staged native tree contains an invalid mode".to_owned()); 1191 } 1192 validate_oid(oid, "staged native object")?; 1193 if oid.bytes().all(|byte| byte == b'0') { 1194 return Err("staged native tree contains an intent-to-add object".to_owned()); 1195 } 1196 if record[tab + 1..].is_empty() { 1197 return Err("staged native tree contains an empty path".to_owned()); 1198 } 1199 let object_type = if mode == "160000" { "commit" } else { "blob" }; 1200 output.extend_from_slice(mode.as_bytes()); 1201 output.push(b' '); 1202 output.extend_from_slice(object_type.as_bytes()); 1203 output.push(b' '); 1204 output.extend_from_slice(oid.as_bytes()); 1205 output.push(b'\t'); 1206 output.extend_from_slice(&record[tab + 1..]); 1207 output.push(0); 1208 } 1209 Ok(output) 1210 } 1211 1212 fn render_projections(catalog: &Catalog, digest: &str) -> Vec<GeneratedArtifact> { 1213 let mut groups = BTreeMap::<&str, Vec<&str>>::new(); 1214 let mut active_groups = BTreeMap::<&str, Vec<&str>>::new(); 1215 let mut reserved_groups = BTreeMap::<&str, Vec<&str>>::new(); 1216 let mut platforms = BTreeMap::<&str, Vec<&str>>::new(); 1217 let mut public = Vec::new(); 1218 let mut private = Vec::new(); 1219 let mut reserved = Vec::new(); 1220 for package in &catalog.package { 1221 for group in &package.groups { 1222 groups.entry(group).or_default().push(&package.name); 1223 if package.state == "active" { 1224 active_groups.entry(group).or_default().push(&package.name); 1225 } else { 1226 reserved_groups 1227 .entry(group) 1228 .or_default() 1229 .push(&package.name); 1230 } 1231 } 1232 for platform in &package.platforms { 1233 platforms.entry(platform).or_default().push(&package.name); 1234 } 1235 if package.visibility == "public_release" { 1236 public.push(package.name.as_str()); 1237 } else { 1238 private.push(package.name.as_str()); 1239 } 1240 if package.state != "active" { 1241 reserved.push(package.name.as_str()); 1242 } 1243 } 1244 sort_map_values(&mut groups); 1245 sort_map_values(&mut active_groups); 1246 sort_map_values(&mut reserved_groups); 1247 sort_map_values(&mut platforms); 1248 public.sort_unstable(); 1249 private.sort_unstable(); 1250 reserved.sort_unstable(); 1251 vec![ 1252 GeneratedArtifact { 1253 relative: GROUPS_RELATIVE, 1254 contents: render_group_projection( 1255 digest, 1256 &groups, 1257 &active_groups, 1258 &reserved_groups, 1259 ) 1260 .into_bytes(), 1261 }, 1262 GeneratedArtifact { 1263 relative: PLATFORMS_RELATIVE, 1264 contents: render_map_projection( 1265 "radroots.workspace.platform-inventory.v1", 1266 digest, 1267 "platform", 1268 &platforms, 1269 ) 1270 .into_bytes(), 1271 }, 1272 GeneratedArtifact { 1273 relative: RELEASE_INVENTORY_RELATIVE, 1274 contents: format!( 1275 "schema = \"radroots.workspace.release-inventory.v2\"\ncatalog_sha256 = \"{digest}\"\narchitecture = \"{RELEASE_ID}\"\nversion = \"{VERSION}\"\npublic_packages = {}\nprivate_packages = {}\nreserved_packages = {}\n", 1276 toml_array(&public), 1277 toml_array(&private), 1278 toml_array(&reserved) 1279 ) 1280 .into_bytes(), 1281 }, 1282 ] 1283 } 1284 1285 fn render_group_projection( 1286 digest: &str, 1287 groups: &BTreeMap<&str, Vec<&str>>, 1288 active: &BTreeMap<&str, Vec<&str>>, 1289 reserved: &BTreeMap<&str, Vec<&str>>, 1290 ) -> String { 1291 let mut output = format!( 1292 "schema = \"radroots.workspace.package-groups.v1\"\ncatalog_sha256 = \"{digest}\"\n" 1293 ); 1294 for (id, packages) in groups { 1295 output.push_str(&format!( 1296 "\n[[group]]\nid = \"{id}\"\npackages = {}\nactive_packages = {}\nreserved_packages = {}\n", 1297 toml_array(packages), 1298 toml_array(active.get(id).map(Vec::as_slice).unwrap_or_default()), 1299 toml_array(reserved.get(id).map(Vec::as_slice).unwrap_or_default()), 1300 )); 1301 } 1302 output 1303 } 1304 1305 fn render_map_projection( 1306 schema: &str, 1307 digest: &str, 1308 table: &str, 1309 values: &BTreeMap<&str, Vec<&str>>, 1310 ) -> String { 1311 let mut output = format!("schema = \"{schema}\"\ncatalog_sha256 = \"{digest}\"\n"); 1312 for (id, packages) in values { 1313 output.push_str(&format!( 1314 "\n[[{table}]]\nid = \"{id}\"\npackages = {}\n", 1315 toml_array(packages) 1316 )); 1317 } 1318 output 1319 } 1320 1321 fn toml_array(values: &[&str]) -> String { 1322 format!( 1323 "[{}]", 1324 values 1325 .iter() 1326 .map(|value| format!("\"{value}\"")) 1327 .collect::<Vec<_>>() 1328 .join(", ") 1329 ) 1330 } 1331 1332 fn sort_map_values(values: &mut BTreeMap<&str, Vec<&str>>) { 1333 for packages in values.values_mut() { 1334 packages.sort_unstable(); 1335 packages.dedup(); 1336 } 1337 } 1338 1339 fn expected_public_packages() -> BTreeSet<&'static str> { 1340 BTreeSet::from([ 1341 "radroots", 1342 "radroots_blossom", 1343 "radroots_core", 1344 "radroots_event", 1345 "radroots_event_codec", 1346 "radroots_geonames", 1347 "radroots_identity", 1348 "radroots_nostr", 1349 "radroots_nostr_connect", 1350 "radroots_protocol", 1351 "radroots_sdk", 1352 "radroots_secrets", 1353 "radroots_signing", 1354 "radroots_storage", 1355 "radroots_storage_sqlite", 1356 "radroots_sync", 1357 "radroots_trade", 1358 "radroots_transport", 1359 "radroots_transport_nostr", 1360 ]) 1361 } 1362 1363 fn expected_retired_packages() -> BTreeSet<&'static str> { 1364 BTreeSet::from([ 1365 "radroots-studio-application", 1366 "radroots-studio-domain", 1367 "radroots-studio-ffi", 1368 "radroots-studio-nostr", 1369 "radroots-studio-storage", 1370 "radroots-studio-uniffi-bindgen", 1371 "radroots_studio_application", 1372 "radroots_studio_domain", 1373 "radroots_studio_ffi", 1374 "radroots_studio_nostr", 1375 "radroots_studio_preferences", 1376 "radroots_studio_runtime", 1377 "radroots_studio_storage", 1378 "radroots_studio_uniffi_bindgen", 1379 "radroots_app_bindgen", 1380 "radroots_app_core", 1381 "radroots_app_ffi", 1382 "radroots_app_wasm", 1383 "radroots_mobile_bindgen", 1384 "radroots_mobile_core", 1385 "radroots_mobile_ffi", 1386 "radroots_mobile_wasm", 1387 "radroots_sdk_xtask", 1388 ]) 1389 } 1390 1391 fn validate_unique_identifiers(values: &[String], context: &str) -> Result<(), String> { 1392 let mut unique = BTreeSet::new(); 1393 for value in values { 1394 validate_identifier(value, context)?; 1395 if !unique.insert(value.as_str()) { 1396 return Err(format!("duplicate {context} {value}")); 1397 } 1398 } 1399 Ok(()) 1400 } 1401 1402 fn validate_identifier(value: &str, context: &str) -> Result<(), String> { 1403 if value.is_empty() 1404 || !value 1405 .bytes() 1406 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') 1407 { 1408 return Err(format!("{context} must use lowercase snake case")); 1409 } 1410 Ok(()) 1411 } 1412 1413 fn validate_package_identity(value: &str, context: &str) -> Result<(), String> { 1414 if value.is_empty() 1415 || !value.bytes().all(|byte| { 1416 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') 1417 }) 1418 { 1419 return Err(format!("{context} is invalid")); 1420 } 1421 Ok(()) 1422 } 1423 1424 fn validate_relative_path(value: &str) -> Result<(), String> { 1425 let path = Path::new(value); 1426 if value.is_empty() 1427 || path.is_absolute() 1428 || value.contains('\\') 1429 || value 1430 .split('/') 1431 .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) 1432 || path 1433 .components() 1434 .any(|component| !matches!(component, Component::Normal(_))) 1435 { 1436 return Err(format!("catalog path {value:?} is unsafe")); 1437 } 1438 Ok(()) 1439 } 1440 1441 fn validate_oid(value: &str, context: &str) -> Result<(), String> { 1442 if value.len() != 40 1443 || !value 1444 .bytes() 1445 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 1446 { 1447 return Err(format!("{context} must be lowercase full 40-hex")); 1448 } 1449 Ok(()) 1450 } 1451 1452 fn validate_sha256(value: &str, context: &str) -> Result<(), String> { 1453 if value.len() != 64 1454 || !value 1455 .bytes() 1456 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 1457 { 1458 return Err(format!("{context} must be lowercase 64-hex")); 1459 } 1460 Ok(()) 1461 } 1462 1463 fn source_repository_name(url: &str) -> Result<&str, String> { 1464 url.strip_prefix("https://github.com/radrootslabs/") 1465 .filter(|name| { 1466 !name.is_empty() 1467 && name 1468 .bytes() 1469 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') 1470 }) 1471 .ok_or_else(|| format!("invalid canonical source repository {url}")) 1472 } 1473 1474 fn sha256(bytes: &[u8]) -> String { 1475 format!("{:x}", Sha256::digest(bytes)) 1476 } 1477 1478 #[cfg(test)] 1479 mod tests { 1480 use super::*; 1481 use tempfile::TempDir; 1482 1483 fn checked_in_catalog() -> Catalog { 1484 parse_file(&crate::workspace_root(), CATALOG_RELATIVE).expect("checked-in catalog") 1485 } 1486 1487 fn git(root: &Path, args: &[&str]) { 1488 let output = Command::new("git") 1489 .args(args) 1490 .current_dir(root) 1491 .output() 1492 .expect("run git fixture command"); 1493 assert!( 1494 output.status.success(), 1495 "git {} failed: {}", 1496 args.join(" "), 1497 String::from_utf8_lossy(&output.stderr) 1498 ); 1499 } 1500 1501 fn git_fixture() -> TempDir { 1502 let root = tempfile::tempdir().expect("temporary git fixture"); 1503 git(root.path(), &["init", "--quiet"]); 1504 git(root.path(), &["config", "user.name", "Catalog Test"]); 1505 git( 1506 root.path(), 1507 &["config", "user.email", "catalog-test@example.invalid"], 1508 ); 1509 fs::write(root.path().join("README.md"), "# Fixture\n").expect("baseline file"); 1510 git(root.path(), &["add", "README.md"]); 1511 git(root.path(), &["commit", "--quiet", "-m", "baseline"]); 1512 root 1513 } 1514 1515 fn native_package<'a>( 1516 catalog: &'a mut Catalog, 1517 path: &str, 1518 digest: &str, 1519 ) -> &'a mut CatalogPackage { 1520 let package = catalog 1521 .package 1522 .iter_mut() 1523 .find(|package| package.name == "radroots_nostrdb") 1524 .expect("private package fixture"); 1525 package.path = path.to_owned(); 1526 package.provenance_kind = "native".to_owned(); 1527 package.source_repository = None; 1528 package.source_revision = None; 1529 package.source_path = None; 1530 package.source_tree_sha256 = None; 1531 package.introduction_tree_sha256 = Some(digest.to_owned()); 1532 package 1533 } 1534 1535 #[test] 1536 fn public_inventory_is_exact() { 1537 assert_eq!(expected_public_packages().len(), 19); 1538 assert!(expected_public_packages().contains("radroots")); 1539 assert!(expected_public_packages().contains("radroots_sdk")); 1540 } 1541 1542 #[test] 1543 fn paths_and_digests_fail_closed() { 1544 assert!(validate_relative_path("crates/sdk").is_ok()); 1545 assert!(validate_relative_path("../sdk").is_err()); 1546 assert!(validate_relative_path("crates/./sdk").is_err()); 1547 assert!(validate_sha256(&"a".repeat(64), "digest").is_ok()); 1548 assert!(validate_sha256(&"A".repeat(64), "digest").is_err()); 1549 } 1550 1551 #[test] 1552 fn provenance_kinds_are_disjoint_and_native_is_unpublished() { 1553 let mut catalog = checked_in_catalog(); 1554 let imported = catalog 1555 .package 1556 .iter_mut() 1557 .find(|package| package.name == "radroots_core") 1558 .expect("imported fixture"); 1559 imported.source_revision = None; 1560 assert!(validate_package_provenance(imported).is_err()); 1561 1562 let mut catalog = checked_in_catalog(); 1563 let native = native_package(&mut catalog, "crates/nostrdb", &"a".repeat(64)); 1564 assert!(validate_package_provenance(native).is_ok()); 1565 native.source_revision = Some("a".repeat(40)); 1566 assert!(validate_package_provenance(native).is_err()); 1567 1568 let mut catalog = checked_in_catalog(); 1569 let public = catalog 1570 .package 1571 .iter_mut() 1572 .find(|package| package.name == "radroots_core") 1573 .expect("public fixture"); 1574 public.provenance_kind = "native".to_owned(); 1575 public.source_repository = None; 1576 public.source_revision = None; 1577 public.source_path = None; 1578 public.source_tree_sha256 = None; 1579 public.introduction_tree_sha256 = Some("a".repeat(64)); 1580 assert!(validate_package_provenance(public).is_err()); 1581 } 1582 1583 #[test] 1584 fn native_provenance_matches_staged_then_derived_introduction_tree() { 1585 let root = git_fixture(); 1586 let package_root = root.path().join("crates/native_fixture"); 1587 fs::create_dir_all(&package_root).expect("native package root"); 1588 fs::write( 1589 package_root.join("Cargo.toml"), 1590 "[package]\nname='fixture'\n", 1591 ) 1592 .expect("native manifest"); 1593 fs::write(package_root.join("lib.rs"), "pub fn initial() {}\n").expect("native source"); 1594 git(root.path(), &["add", "crates/native_fixture"]); 1595 1596 let staged = 1597 staged_tree_digest(root.path(), "crates/native_fixture").expect("staged tree digest"); 1598 assert_eq!( 1599 native_introducing_commit(root.path(), "crates/native_fixture") 1600 .expect("precommit history"), 1601 None 1602 ); 1603 let mut catalog = checked_in_catalog(); 1604 let package = native_package(&mut catalog, "crates/native_fixture", &staged); 1605 validate_native_source_provenance(package, root.path()).expect("staged provenance"); 1606 1607 fs::write(package_root.join("lib.rs"), "unstaged change\n").expect("unstaged change"); 1608 validate_native_source_provenance(package, root.path()) 1609 .expect("only the staged introduction is authoritative before commit"); 1610 git( 1611 root.path(), 1612 &["commit", "--quiet", "-m", "add native package"], 1613 ); 1614 1615 let introducing = native_introducing_commit(root.path(), "crates/native_fixture") 1616 .expect("committed history") 1617 .expect("introducing commit"); 1618 assert_eq!( 1619 committed_tree_digest(root.path(), &introducing, "crates/native_fixture") 1620 .expect("committed introduction digest"), 1621 staged 1622 ); 1623 validate_native_source_provenance(package, root.path()) 1624 .expect("derived committed provenance"); 1625 1626 git(root.path(), &["add", "crates/native_fixture/lib.rs"]); 1627 git( 1628 root.path(), 1629 &["commit", "--quiet", "-m", "change native package"], 1630 ); 1631 validate_native_source_provenance(package, root.path()) 1632 .expect("later changes do not rewrite introduction provenance"); 1633 package.introduction_tree_sha256 = Some("b".repeat(64)); 1634 assert!(validate_native_source_provenance(package, root.path()).is_err()); 1635 } 1636 1637 #[test] 1638 fn native_precommit_provenance_requires_stage_zero_index_records() { 1639 let root = git_fixture(); 1640 fs::create_dir_all(root.path().join("crates/native_fixture")).expect("native package root"); 1641 fs::write( 1642 root.path().join("crates/native_fixture/lib.rs"), 1643 "pub fn unstaged() {}\n", 1644 ) 1645 .expect("unstaged native source"); 1646 assert!(staged_tree_digest(root.path(), "crates/native_fixture").is_err()); 1647 1648 let oid = "a".repeat(40); 1649 let conflicted = format!("100644 {oid} 1\tcrates/native_fixture/lib.rs\0"); 1650 assert!(canonical_staged_tree_records(conflicted.as_bytes()).is_err()); 1651 1652 let intent_to_add = format!( 1653 "100644 {} 0\tcrates/native_fixture/lib.rs\0", 1654 "0".repeat(40) 1655 ); 1656 assert!(canonical_staged_tree_records(intent_to_add.as_bytes()).is_err()); 1657 } 1658 1659 #[test] 1660 fn generated_maps_are_sorted_and_digest_bound() { 1661 let mut values = BTreeMap::from([("sdk", vec!["z", "a", "a"])]); 1662 sort_map_values(&mut values); 1663 let rendered = render_map_projection("fixture.v1", &"a".repeat(64), "group", &values); 1664 assert!(rendered.contains("catalog_sha256")); 1665 assert!(rendered.contains("packages = [\"a\", \"z\"]")); 1666 } 1667 1668 #[test] 1669 fn checked_in_catalog_structure_is_valid() { 1670 validate_catalog(&checked_in_catalog()).expect("catalog structure"); 1671 } 1672 1673 #[test] 1674 fn retired_application_packages_cannot_return_as_dependencies_or_sources() { 1675 for name in 1676 RETIRED_APPLICATION_PACKAGES 1677 .into_iter() 1678 .chain(["tera", "tera_core", "tera_ffi"]) 1679 { 1680 assert!(validate_foundation_dependency(name, None).is_err()); 1681 } 1682 for source in [ 1683 "git+https://github.com/radrootslabs/tera?rev=immutable", 1684 "https://github.com/radrootslabs/tera.git", 1685 "ssh://git@github.com/radrootslabs/tera.git", 1686 ] { 1687 assert!(validate_foundation_dependency("renamed_application", Some(source)).is_err()); 1688 } 1689 validate_foundation_dependency("radroots_sdk", None).expect("shared SDK remains owned"); 1690 validate_foundation_dependency("radroots_storage_sqlite", None) 1691 .expect("shared storage remains owned"); 1692 let catalog = checked_in_catalog(); 1693 validate_application_retirement(&catalog, &crate::workspace_root()) 1694 .expect("retirement is coherent"); 1695 let fixture = tempfile::TempDir::new().expect("retirement fixture"); 1696 let decision = fixture.path().join(APPLICATION_RETIREMENT); 1697 fs::create_dir_all(decision.parent().expect("decision parent")) 1698 .expect("decision directory"); 1699 fs::copy( 1700 crate::workspace_root().join(APPLICATION_RETIREMENT), 1701 &decision, 1702 ) 1703 .expect("decision"); 1704 validate_application_retirement(&catalog, fixture.path()).expect("no application source"); 1705 fs::create_dir_all(fixture.path().join("crates/mobile_core")).expect("reintroduced source"); 1706 assert!(validate_application_retirement(&catalog, fixture.path()).is_err()); 1707 } 1708 1709 #[test] 1710 fn retirement_rejects_missing_duplicate_or_modified_package_evidence() { 1711 let fixture = tempfile::TempDir::new().expect("retirement fixture"); 1712 let path = fixture.path().join(APPLICATION_RETIREMENT); 1713 fs::create_dir_all(path.parent().expect("parent")).expect("decision directory"); 1714 let original: serde_json::Value = serde_json::from_slice( 1715 &fs::read(crate::workspace_root().join(APPLICATION_RETIREMENT)).expect("decision"), 1716 ) 1717 .expect("decision JSON"); 1718 let catalog = checked_in_catalog(); 1719 for case in 0..5 { 1720 let mut changed = original.clone(); 1721 match case { 1722 0 => { 1723 changed["package"].as_array_mut().expect("packages").pop(); 1724 } 1725 1 => { 1726 changed["package"][1] = changed["package"][0].clone(); 1727 } 1728 2 => { 1729 changed["package"][0]["former_catalog_entry"]["license"] = "MIT".into(); 1730 } 1731 3 => { 1732 changed["package"][0]["target_path"] = "../outside".into(); 1733 } 1734 _ => { 1735 changed["unknown"] = true.into(); 1736 } 1737 } 1738 fs::write(&path, serde_json::to_vec(&changed).expect("encode")) 1739 .expect("altered decision"); 1740 assert!(validate_application_retirement(&catalog, fixture.path()).is_err()); 1741 } 1742 let mut reactivated = checked_in_catalog(); 1743 reactivated 1744 .retired_packages 1745 .retain(|name| name != "radroots_mobile_core"); 1746 assert!(validate_catalog(&reactivated).is_err()); 1747 } 1748 1749 #[test] 1750 fn catalog_rejects_version_visibility_license_and_retirement_drift() { 1751 let mut catalog = checked_in_catalog(); 1752 catalog 1753 .package 1754 .iter_mut() 1755 .find(|package| package.name == "xtask") 1756 .expect("xtask") 1757 .publish = true; 1758 assert!(validate_catalog(&catalog).is_err()); 1759 1760 let mut catalog = checked_in_catalog(); 1761 catalog 1762 .package 1763 .iter_mut() 1764 .find(|package| package.name == "radroots_core") 1765 .expect("core") 1766 .license = "GPL-3.0-only".to_owned(); 1767 assert!(validate_catalog(&catalog).is_err()); 1768 1769 let mut catalog = checked_in_catalog(); 1770 catalog 1771 .package 1772 .iter_mut() 1773 .find(|package| package.name == "radroots_sdk_ffi") 1774 .expect("sdk ffi") 1775 .version = "0.1.0-alpha.1".to_owned(); 1776 assert!(validate_catalog(&catalog).is_err()); 1777 1778 let mut catalog = checked_in_catalog(); 1779 catalog.retired_packages.push("radroots_core".to_owned()); 1780 assert!(validate_catalog(&catalog).is_err()); 1781 } 1782 1783 #[test] 1784 fn catalog_rejects_naming_path_and_package_count_drift() { 1785 let mut catalog = checked_in_catalog(); 1786 catalog.package_count -= 1; 1787 assert!(validate_catalog(&catalog).is_err()); 1788 1789 let mut catalog = checked_in_catalog(); 1790 catalog.package[0].name = "radroots-bad-name".to_owned(); 1791 assert!(validate_catalog(&catalog).is_err()); 1792 1793 let mut catalog = checked_in_catalog(); 1794 catalog.package[0].path = "../escape".to_owned(); 1795 assert!(validate_catalog(&catalog).is_err()); 1796 } 1797 1798 #[test] 1799 fn metadata_rejects_floating_sources_and_tier_edges() { 1800 let workspace_root = crate::workspace_root(); 1801 let catalog = checked_in_catalog(); 1802 let mut metadata = cargo_metadata(&workspace_root).expect("cargo metadata"); 1803 let dependency = metadata 1804 .packages 1805 .iter_mut() 1806 .find(|package| package.name == "radroots_event") 1807 .and_then(|package| { 1808 package 1809 .dependencies 1810 .iter_mut() 1811 .find(|dependency| dependency.name == "radroots_core") 1812 }) 1813 .expect("event to core dependency"); 1814 dependency.path = None; 1815 dependency.req = "*".to_owned(); 1816 assert!(validate_metadata(&catalog, &metadata, &workspace_root).is_err()); 1817 1818 let mut catalog = checked_in_catalog(); 1819 catalog 1820 .package 1821 .iter_mut() 1822 .find(|package| package.name == "radroots_event") 1823 .expect("event") 1824 .permitted_dependency_tiers 1825 .retain(|tier| tier != "foundation"); 1826 let metadata = cargo_metadata(&workspace_root).expect("cargo metadata"); 1827 assert!(validate_metadata(&catalog, &metadata, &workspace_root).is_err()); 1828 } 1829 }