architecture.rs (60199B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 path::{Component, Path}, 5 }; 6 7 use serde::Deserialize; 8 9 mod api_leakage; 10 mod core_contract; 11 mod dependency_boundary; 12 mod retired_compatibility; 13 14 const DEVIATIONS_RELATIVE: &str = "contracts/architecture/deviations.toml"; 15 const ARCHITECTURE_RELATIVE: &str = "contracts/crates/release_v1/radroots_crates_release_v1.toml"; 16 const ARCHITECTURE_ID: &str = "radroots.crates.release.v1"; 17 const PUBLIC_HOMEPAGE: &str = "https://radroots.org"; 18 const PUBLIC_README: &str = "README.md"; 19 const PUBLIC_AUTHORS: &[&str] = &["Tyson Lupul <tyson@radroots.org>"]; 20 21 #[derive(Debug, Deserialize)] 22 #[serde(deny_unknown_fields)] 23 struct DeviationLedger { 24 schema_version: u16, 25 architecture_id: String, 26 deviation: Vec<DeviationRecord>, 27 } 28 29 #[derive(Debug, Deserialize)] 30 #[serde(deny_unknown_fields)] 31 struct DeviationRecord { 32 id: String, 33 date: String, 34 status: String, 35 approval: String, 36 affected_steps: Vec<String>, 37 spec_anchors: Vec<String>, 38 source_evidence: Vec<String>, 39 replacement_action: String, 40 verification: Vec<String>, 41 unresolved_risk: String, 42 normative_architecture_change: bool, 43 adr_required: bool, 44 #[serde(default)] 45 closure_evidence: Vec<String>, 46 } 47 48 #[derive(Debug, Deserialize)] 49 struct ArchitectureIdentity { 50 spec_id: String, 51 edition: String, 52 resolver: String, 53 rust_version: String, 54 license: String, 55 canonical_repositories: Vec<String>, 56 repositories: BTreeMap<String, ArchitectureRepository>, 57 package: Vec<ArchitecturePackage>, 58 } 59 60 #[derive(Debug, Deserialize)] 61 struct ArchitectureRepository { 62 url: String, 63 version: String, 64 packages: Vec<String>, 65 } 66 67 #[derive(Debug, Deserialize)] 68 struct ArchitecturePackage { 69 name: String, 70 } 71 72 #[derive(Debug, Deserialize)] 73 struct ConsolidationOwnership { 74 canonical_rust_repository: String, 75 } 76 77 #[derive(Debug, Deserialize)] 78 struct WorkspaceManifest { 79 workspace: WorkspaceMembers, 80 } 81 82 #[derive(Debug, Deserialize)] 83 struct WorkspaceMembershipManifest { 84 workspace: WorkspaceMembership, 85 } 86 87 #[derive(Debug, Deserialize)] 88 struct WorkspaceMembership { 89 members: Vec<String>, 90 } 91 92 #[derive(Debug, Deserialize)] 93 struct WorkspaceMembers { 94 members: Vec<String>, 95 resolver: String, 96 package: WorkspacePackage, 97 metadata: WorkspaceMetadata, 98 lints: WorkspaceLints, 99 } 100 101 #[derive(Debug, Deserialize)] 102 struct WorkspaceLints { 103 rust: WorkspaceRustLints, 104 rustdoc: WorkspaceRustdocLints, 105 clippy: WorkspaceClippyLints, 106 } 107 108 #[derive(Debug, Deserialize)] 109 struct WorkspaceRustLints { 110 unsafe_code: String, 111 } 112 113 #[derive(Debug, Deserialize)] 114 struct WorkspaceRustdocLints { 115 broken_intra_doc_links: String, 116 } 117 118 #[derive(Debug, Deserialize)] 119 struct WorkspaceClippyLints { 120 dbg_macro: String, 121 todo: String, 122 unimplemented: String, 123 } 124 125 #[derive(Debug, Deserialize)] 126 struct WorkspaceMetadata { 127 radroots: RadrootsWorkspaceMetadata, 128 } 129 130 #[derive(Debug, Deserialize)] 131 struct RadrootsWorkspaceMetadata { 132 #[serde(rename = "public-package")] 133 public_package: PublicPackageMetadata, 134 } 135 136 #[derive(Debug, Deserialize)] 137 struct PublicPackageMetadata { 138 version: String, 139 authors: Vec<String>, 140 readme: String, 141 } 142 143 #[derive(Debug, Deserialize)] 144 struct WorkspacePackage { 145 version: String, 146 edition: String, 147 #[serde(rename = "rust-version")] 148 rust_version: String, 149 license: String, 150 repository: String, 151 homepage: String, 152 readme: String, 153 authors: Vec<String>, 154 } 155 156 pub fn validate(workspace_root: &Path) -> Result<(), String> { 157 validate_workspace_members(workspace_root)?; 158 let architecture_path = workspace_root.join(ARCHITECTURE_RELATIVE); 159 let architecture_raw = fs::read_to_string(&architecture_path) 160 .map_err(|error| format!("read {}: {error}", architecture_path.display()))?; 161 let architecture = toml::from_str::<ArchitectureIdentity>(&architecture_raw) 162 .map_err(|error| format!("parse {}: {error}", architecture_path.display()))?; 163 164 let architecture_packages = architecture 165 .package 166 .iter() 167 .map(|package| package.name.clone()) 168 .collect::<BTreeSet<_>>(); 169 core_contract::validate(workspace_root)?; 170 api_leakage::validate_policy_catalog(workspace_root, &architecture_packages)?; 171 dependency_boundary::validate_policy_catalog(workspace_root, &architecture_packages)?; 172 retired_compatibility::validate(workspace_root)?; 173 validate_workspace_toolchain(workspace_root, &architecture)?; 174 validate_public_package_metadata(workspace_root, &architecture)?; 175 validate_no_production_sibling_paths(workspace_root)?; 176 validate_public_dependency_versions(workspace_root, &architecture)?; 177 178 let ledger_path = workspace_root.join(DEVIATIONS_RELATIVE); 179 let ledger_raw = fs::read_to_string(&ledger_path) 180 .map_err(|error| format!("read {}: {error}", ledger_path.display()))?; 181 validate_ledger(workspace_root, &architecture.spec_id, &ledger_raw) 182 } 183 184 pub fn validate_dependency_boundaries(workspace_root: &Path) -> Result<(), String> { 185 validate(workspace_root)?; 186 dependency_boundary::validate_resolved_boundaries(workspace_root) 187 } 188 189 pub fn validate_api_boundaries(workspace_root: &Path) -> Result<(), String> { 190 validate(workspace_root)?; 191 api_leakage::validate_public_api(workspace_root) 192 } 193 194 pub fn validate_ci(workspace_root: &Path) -> Result<(), String> { 195 validate(workspace_root)?; 196 dependency_boundary::validate_resolved_boundaries(workspace_root)?; 197 api_leakage::validate_public_api(workspace_root) 198 } 199 200 fn validate_workspace_toolchain( 201 workspace_root: &Path, 202 architecture: &ArchitectureIdentity, 203 ) -> Result<(), String> { 204 let manifest_path = workspace_root.join("Cargo.toml"); 205 let manifest_raw = fs::read_to_string(&manifest_path) 206 .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; 207 let manifest = toml::from_str::<WorkspaceManifest>(&manifest_raw) 208 .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; 209 if manifest.workspace.resolver != architecture.resolver || architecture.resolver != "3" { 210 return Err(format!( 211 "workspace resolver {} must match architecture resolver {}", 212 manifest.workspace.resolver, architecture.resolver 213 )); 214 } 215 if manifest.workspace.package.rust_version != architecture.rust_version 216 || architecture.rust_version != "1.97.1" 217 { 218 return Err(format!( 219 "workspace rust-version {} must match architecture rust_version {}", 220 manifest.workspace.package.rust_version, architecture.rust_version 221 )); 222 } 223 let workspace_package = &manifest.workspace.package; 224 let public_metadata = &manifest.workspace.metadata.radroots.public_package; 225 let repository = architecture 226 .repositories 227 .values() 228 .find(|repository| repository.url == workspace_package.repository) 229 .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; 230 if public_metadata.version != repository.version { 231 return Err(format!( 232 "public package version source {} must match repository version {}", 233 public_metadata.version, repository.version 234 )); 235 } 236 if public_metadata.authors != PUBLIC_AUTHORS { 237 return Err("public package authors source must match the workspace convention".to_owned()); 238 } 239 if public_metadata.readme != PUBLIC_README { 240 return Err(format!( 241 "public package readme source must be {PUBLIC_README}" 242 )); 243 } 244 let lints = &manifest.workspace.lints; 245 if lints.rust.unsafe_code != "deny" 246 || lints.rustdoc.broken_intra_doc_links != "deny" 247 || lints.clippy.dbg_macro != "deny" 248 || lints.clippy.todo != "deny" 249 || lints.clippy.unimplemented != "deny" 250 { 251 return Err( 252 "workspace lints must deny unsafe code and deny the approved rustdoc/Clippy baseline" 253 .to_owned(), 254 ); 255 } 256 if workspace_package.edition != architecture.edition || architecture.edition != "2024" { 257 return Err(format!( 258 "workspace edition {} must match architecture edition {}", 259 workspace_package.edition, architecture.edition 260 )); 261 } 262 if workspace_package.license != architecture.license { 263 return Err(format!( 264 "workspace license {} must match architecture license {}", 265 workspace_package.license, architecture.license 266 )); 267 } 268 if !architecture 269 .canonical_repositories 270 .contains(&workspace_package.repository) 271 { 272 return Err(format!( 273 "workspace repository {} is not canonical", 274 workspace_package.repository 275 )); 276 } 277 if workspace_package.homepage != PUBLIC_HOMEPAGE { 278 return Err(format!( 279 "workspace homepage {} must be {PUBLIC_HOMEPAGE}", 280 workspace_package.homepage 281 )); 282 } 283 if workspace_package.authors != PUBLIC_AUTHORS { 284 return Err("workspace authors must match the public package convention".to_owned()); 285 } 286 if !workspace_root.join(&workspace_package.readme).is_file() { 287 return Err(format!( 288 "workspace readme {} does not exist", 289 workspace_package.readme 290 )); 291 } 292 for license in ["LICENSE-MIT", "LICENSE-APACHE"] { 293 if !workspace_root.join(license).is_file() { 294 return Err(format!("workspace is missing {license}")); 295 } 296 } 297 let toolchain_path = workspace_root.join("rust-toolchain.toml"); 298 let toolchain_raw = fs::read_to_string(&toolchain_path) 299 .map_err(|error| format!("read {}: {error}", toolchain_path.display()))?; 300 let toolchain = toolchain_raw 301 .parse::<toml::Value>() 302 .map_err(|error| format!("parse {}: {error}", toolchain_path.display()))?; 303 let channel = toolchain 304 .get("toolchain") 305 .and_then(|value| value.get("channel")) 306 .and_then(toml::Value::as_str); 307 if channel != Some(architecture.rust_version.as_str()) { 308 return Err(format!( 309 "rust-toolchain.toml channel must match architecture rust_version {}", 310 architecture.rust_version 311 )); 312 } 313 Ok(()) 314 } 315 316 fn validate_public_package_metadata( 317 workspace_root: &Path, 318 architecture: &ArchitectureIdentity, 319 ) -> Result<(), String> { 320 let workspace_raw = fs::read_to_string(workspace_root.join("Cargo.toml")) 321 .map_err(|error| format!("read workspace Cargo.toml: {error}"))?; 322 let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) 323 .map_err(|error| format!("parse workspace Cargo.toml: {error}"))?; 324 let repository = architecture 325 .repositories 326 .values() 327 .find(|repository| repository.url == workspace.workspace.package.repository) 328 .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; 329 let all_packages = architecture 330 .package 331 .iter() 332 .map(|package| package.name.as_str()) 333 .collect::<BTreeSet<_>>(); 334 let consolidation_path = workspace_root.join("contracts/consolidation/architecture.v1.toml"); 335 let consolidated_owner = if consolidation_path.is_file() { 336 let consolidation = fs::read_to_string(&consolidation_path) 337 .map_err(|error| format!("read {}: {error}", consolidation_path.display()))?; 338 Some( 339 toml::from_str::<ConsolidationOwnership>(&consolidation) 340 .map_err(|error| format!("parse {}: {error}", consolidation_path.display()))? 341 .canonical_rust_repository, 342 ) 343 } else { 344 None 345 }; 346 let local_packages = 347 if consolidated_owner.as_deref() == Some(workspace.workspace.package.repository.as_str()) { 348 all_packages.iter().map(|name| (*name).to_owned()).collect() 349 } else { 350 repository.packages.iter().cloned().collect::<BTreeSet<_>>() 351 }; 352 let mut found_local_packages = BTreeSet::new(); 353 354 for member in &workspace.workspace.members { 355 let manifest_path = workspace_root.join(member).join("Cargo.toml"); 356 let raw = fs::read_to_string(&manifest_path) 357 .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; 358 let manifest = raw 359 .parse::<toml::Value>() 360 .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; 361 let package = manifest 362 .get("package") 363 .and_then(toml::Value::as_table) 364 .ok_or_else(|| format!("{} is missing [package]", manifest_path.display()))?; 365 let name = package 366 .get("name") 367 .and_then(toml::Value::as_str) 368 .ok_or_else(|| format!("{} is missing package.name", manifest_path.display()))?; 369 if !all_packages.contains(name) { 370 continue; 371 } 372 if !local_packages.contains(name) { 373 return Err(format!( 374 "public package {name} belongs to a different canonical repository" 375 )); 376 } 377 found_local_packages.insert(name.to_owned()); 378 validate_public_manifest_field( 379 package, 380 "version", 381 &workspace.workspace.package.version, 382 &repository.version, 383 name, 384 )?; 385 validate_public_manifest_field( 386 package, 387 "edition", 388 &workspace.workspace.package.edition, 389 &architecture.edition, 390 name, 391 )?; 392 validate_public_manifest_field( 393 package, 394 "rust-version", 395 &workspace.workspace.package.rust_version, 396 &architecture.rust_version, 397 name, 398 )?; 399 validate_public_manifest_field( 400 package, 401 "license", 402 &workspace.workspace.package.license, 403 &architecture.license, 404 name, 405 )?; 406 validate_public_manifest_field( 407 package, 408 "repository", 409 &workspace.workspace.package.repository, 410 &repository.url, 411 name, 412 )?; 413 validate_public_manifest_field( 414 package, 415 "homepage", 416 &workspace.workspace.package.homepage, 417 PUBLIC_HOMEPAGE, 418 name, 419 )?; 420 let authors = resolve_public_authors(package, &workspace.workspace.package.authors) 421 .ok_or_else(|| format!("public package {name} must declare authors"))?; 422 if authors != PUBLIC_AUTHORS { 423 return Err(format!( 424 "public package {name} authors must match the workspace convention" 425 )); 426 } 427 let readme = package.get("readme").and_then(toml::Value::as_str); 428 if readme != Some(PUBLIC_README) 429 || !workspace_root.join(member).join(PUBLIC_README).is_file() 430 { 431 return Err(format!( 432 "public package {name} must use an existing crate-local {PUBLIC_README}" 433 )); 434 } 435 let publish_registries = package 436 .get("publish") 437 .and_then(toml::Value::as_array) 438 .ok_or_else(|| format!("public package {name} must set publish = [\"crates-io\"]"))?; 439 if publish_registries.len() != 1 || publish_registries[0].as_str() != Some("crates-io") { 440 return Err(format!( 441 "public package {name} must set publish = [\"crates-io\"]" 442 )); 443 } 444 let inherits_lints = manifest 445 .get("lints") 446 .and_then(toml::Value::as_table) 447 .is_some_and(|lints| { 448 lints.len() == 1 449 && lints.get("workspace").and_then(toml::Value::as_bool) == Some(true) 450 }); 451 if !inherits_lints { 452 return Err(format!( 453 "public package {name} must inherit the workspace lint policy" 454 )); 455 } 456 } 457 if found_local_packages != local_packages { 458 let missing = local_packages 459 .difference(&found_local_packages) 460 .cloned() 461 .collect::<Vec<_>>() 462 .join(", "); 463 let extra = found_local_packages 464 .difference(&local_packages) 465 .cloned() 466 .collect::<Vec<_>>() 467 .join(", "); 468 return Err(format!( 469 "workspace public package inventory is missing: {missing}; workspace public package inventory has unallocated packages: {extra}" 470 )); 471 } 472 Ok(()) 473 } 474 475 fn validate_public_dependency_versions( 476 workspace_root: &Path, 477 architecture: &ArchitectureIdentity, 478 ) -> Result<(), String> { 479 let workspace_path = workspace_root.join("Cargo.toml"); 480 let workspace_raw = fs::read_to_string(&workspace_path) 481 .map_err(|error| format!("read {}: {error}", workspace_path.display()))?; 482 let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) 483 .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; 484 let workspace_value = workspace_raw 485 .parse::<toml::Value>() 486 .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; 487 let workspace_dependencies = workspace_value 488 .get("workspace") 489 .and_then(toml::Value::as_table) 490 .and_then(|workspace| workspace.get("dependencies")) 491 .and_then(toml::Value::as_table); 492 let repository = architecture 493 .repositories 494 .values() 495 .find(|repository| repository.url == workspace.workspace.package.repository) 496 .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; 497 let local_packages = repository 498 .packages 499 .iter() 500 .map(String::as_str) 501 .collect::<BTreeSet<_>>(); 502 let public_packages = architecture 503 .package 504 .iter() 505 .map(|package| package.name.as_str()) 506 .collect::<BTreeSet<_>>(); 507 let package_versions = architecture 508 .repositories 509 .values() 510 .flat_map(|repository| { 511 repository 512 .packages 513 .iter() 514 .map(|package| (package.as_str(), repository.version.as_str())) 515 }) 516 .collect::<BTreeMap<_, _>>(); 517 518 for member in &workspace.workspace.members { 519 let manifest_path = workspace_root.join(member).join("Cargo.toml"); 520 let raw = fs::read_to_string(&manifest_path) 521 .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; 522 let manifest = raw 523 .parse::<toml::Value>() 524 .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; 525 let package_name = manifest 526 .get("package") 527 .and_then(toml::Value::as_table) 528 .and_then(|package| package.get("name")) 529 .and_then(toml::Value::as_str) 530 .ok_or_else(|| format!("{} is missing package.name", manifest_path.display()))?; 531 if !local_packages.contains(package_name) { 532 continue; 533 } 534 let policy = PublicDependencyPolicy { 535 workspace_root, 536 workspace_dependencies, 537 public_packages: &public_packages, 538 local_packages: &local_packages, 539 package_versions: &package_versions, 540 }; 541 validate_public_dependency_sections(member, package_name, &manifest, &policy)?; 542 } 543 Ok(()) 544 } 545 546 struct PublicDependencyPolicy<'a> { 547 workspace_root: &'a Path, 548 workspace_dependencies: Option<&'a toml::value::Table>, 549 public_packages: &'a BTreeSet<&'a str>, 550 local_packages: &'a BTreeSet<&'a str>, 551 package_versions: &'a BTreeMap<&'a str, &'a str>, 552 } 553 554 fn validate_public_dependency_sections( 555 member: &str, 556 owner: &str, 557 manifest: &toml::Value, 558 policy: &PublicDependencyPolicy<'_>, 559 ) -> Result<(), String> { 560 let manifest_table = manifest 561 .as_table() 562 .ok_or_else(|| format!("{member}/Cargo.toml must be a TOML table"))?; 563 for section in ["dependencies", "dev-dependencies", "build-dependencies"] { 564 if let Some(dependencies) = manifest_table.get(section).and_then(toml::Value::as_table) { 565 validate_public_dependency_table(member, owner, section, dependencies, policy)?; 566 } 567 } 568 if let Some(targets) = manifest_table.get("target").and_then(toml::Value::as_table) { 569 for (target, target_value) in targets { 570 let Some(target_table) = target_value.as_table() else { 571 continue; 572 }; 573 for section in ["dependencies", "dev-dependencies", "build-dependencies"] { 574 if let Some(dependencies) = 575 target_table.get(section).and_then(toml::Value::as_table) 576 { 577 validate_public_dependency_table( 578 member, 579 owner, 580 &format!("target.{target}.{section}"), 581 dependencies, 582 policy, 583 )?; 584 } 585 } 586 } 587 } 588 Ok(()) 589 } 590 591 fn validate_public_dependency_table( 592 member: &str, 593 owner: &str, 594 section: &str, 595 dependencies: &toml::value::Table, 596 policy: &PublicDependencyPolicy<'_>, 597 ) -> Result<(), String> { 598 for (dependency_key, declaration) in dependencies { 599 let inherits_workspace = declaration 600 .as_table() 601 .and_then(|table| table.get("workspace")) 602 .and_then(toml::Value::as_bool) 603 == Some(true); 604 let resolved = if inherits_workspace { 605 policy 606 .workspace_dependencies 607 .and_then(|dependencies| dependencies.get(dependency_key)) 608 .ok_or_else(|| { 609 format!( 610 "public package {owner} {section}.{dependency_key} inherits a missing workspace dependency" 611 ) 612 })? 613 } else { 614 declaration 615 }; 616 let resolved_table = resolved.as_table(); 617 let dependency_path = resolved_table 618 .and_then(|table| table.get("path")) 619 .and_then(toml::Value::as_str); 620 let declared_package = resolved_table 621 .and_then(|table| table.get("package")) 622 .and_then(toml::Value::as_str); 623 let path_base = if inherits_workspace { 624 policy.workspace_root.to_path_buf() 625 } else { 626 policy.workspace_root.join(member) 627 }; 628 let path_package = if declared_package.is_none() { 629 dependency_path 630 .map(|path| dependency_package_name(&path_base.join(path))) 631 .transpose()? 632 } else { 633 None 634 }; 635 let dependency_name = declared_package 636 .or(path_package.as_deref()) 637 .unwrap_or(dependency_key.as_str()); 638 let first_party = 639 dependency_key.starts_with("radroots_") || dependency_name.starts_with("radroots_"); 640 if first_party && !policy.public_packages.contains(dependency_name) { 641 return Err(format!( 642 "public package {owner} {section}.{dependency_key} must not depend on private first-party package {dependency_name}" 643 )); 644 } 645 if !policy.public_packages.contains(dependency_name) { 646 continue; 647 } 648 let version = match resolved { 649 toml::Value::String(version) => Some(version.as_str()), 650 toml::Value::Table(table) => table.get("version").and_then(toml::Value::as_str), 651 _ => None, 652 }; 653 let governed_version = policy 654 .package_versions 655 .get(dependency_name) 656 .ok_or_else(|| { 657 format!("public dependency {dependency_name} has no repository version authority") 658 })?; 659 let exact_version = format!("={governed_version}"); 660 if policy.local_packages.contains(dependency_name) 661 && (dependency_path.is_none() || version != Some(exact_version.as_str())) 662 { 663 return Err(format!( 664 "public package {owner} {section}.{dependency_key} dependency on {dependency_name} must declare path and exact version {exact_version}" 665 )); 666 } 667 if !policy.local_packages.contains(dependency_name) 668 && (dependency_path.is_some() || version != Some(exact_version.as_str())) 669 { 670 return Err(format!( 671 "public package {owner} {section}.{dependency_key} cross-repository dependency on {dependency_name} must declare exact registry version {exact_version} without a path" 672 )); 673 } 674 } 675 Ok(()) 676 } 677 678 fn validate_no_production_sibling_paths(workspace_root: &Path) -> Result<(), String> { 679 let canonical_root = fs::canonicalize(workspace_root) 680 .map_err(|error| format!("canonicalize {}: {error}", workspace_root.display()))?; 681 let workspace_path = workspace_root.join("Cargo.toml"); 682 let workspace_raw = fs::read_to_string(&workspace_path) 683 .map_err(|error| format!("read {}: {error}", workspace_path.display()))?; 684 let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) 685 .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; 686 let workspace_value = workspace_raw 687 .parse::<toml::Value>() 688 .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; 689 if let Some(dependencies) = workspace_value 690 .get("workspace") 691 .and_then(toml::Value::as_table) 692 .and_then(|workspace| workspace.get("dependencies")) 693 .and_then(toml::Value::as_table) 694 { 695 validate_dependency_path_table( 696 &canonical_root, 697 &canonical_root, 698 "workspace", 699 "workspace.dependencies", 700 dependencies, 701 )?; 702 } 703 for member in &workspace.workspace.members { 704 let manifest_path = workspace_root.join(member).join("Cargo.toml"); 705 let raw = fs::read_to_string(&manifest_path) 706 .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; 707 let manifest = raw 708 .parse::<toml::Value>() 709 .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; 710 validate_manifest_dependency_paths( 711 &canonical_root, 712 &canonical_root.join(member), 713 member, 714 &manifest, 715 )?; 716 } 717 Ok(()) 718 } 719 720 fn validate_manifest_dependency_paths( 721 workspace_root: &Path, 722 package_root: &Path, 723 owner: &str, 724 manifest: &toml::Value, 725 ) -> Result<(), String> { 726 let Some(manifest_table) = manifest.as_table() else { 727 return Err(format!("{owner}/Cargo.toml must be a TOML table")); 728 }; 729 for section in ["dependencies", "dev-dependencies", "build-dependencies"] { 730 if let Some(dependencies) = manifest_table.get(section).and_then(toml::Value::as_table) { 731 validate_dependency_path_table( 732 workspace_root, 733 package_root, 734 owner, 735 section, 736 dependencies, 737 )?; 738 } 739 } 740 if let Some(targets) = manifest_table.get("target").and_then(toml::Value::as_table) { 741 for (target, target_value) in targets { 742 let Some(target_table) = target_value.as_table() else { 743 continue; 744 }; 745 for section in ["dependencies", "dev-dependencies", "build-dependencies"] { 746 if let Some(dependencies) = 747 target_table.get(section).and_then(toml::Value::as_table) 748 { 749 validate_dependency_path_table( 750 workspace_root, 751 package_root, 752 owner, 753 &format!("target.{target}.{section}"), 754 dependencies, 755 )?; 756 } 757 } 758 } 759 } 760 Ok(()) 761 } 762 763 fn validate_dependency_path_table( 764 workspace_root: &Path, 765 path_base: &Path, 766 owner: &str, 767 section: &str, 768 dependencies: &toml::value::Table, 769 ) -> Result<(), String> { 770 for (dependency, declaration) in dependencies { 771 let Some(path) = declaration 772 .as_table() 773 .and_then(|table| table.get("path")) 774 .and_then(toml::Value::as_str) 775 else { 776 continue; 777 }; 778 let resolved = fs::canonicalize(path_base.join(path)).map_err(|error| { 779 format!( 780 "resolve production dependency {owner} {section}.{dependency} path {path}: {error}" 781 ) 782 })?; 783 if !resolved.starts_with(workspace_root) { 784 return Err(format!( 785 "production dependency {owner} {section}.{dependency} path {path} escapes the repository; use a registry version or an external local-development override" 786 )); 787 } 788 } 789 Ok(()) 790 } 791 792 fn dependency_package_name(package_root: &Path) -> Result<String, String> { 793 let manifest_path = package_root.join("Cargo.toml"); 794 let raw = fs::read_to_string(&manifest_path).map_err(|error| { 795 format!( 796 "read dependency manifest {}: {error}", 797 manifest_path.display() 798 ) 799 })?; 800 let manifest = raw.parse::<toml::Value>().map_err(|error| { 801 format!( 802 "parse dependency manifest {}: {error}", 803 manifest_path.display() 804 ) 805 })?; 806 manifest 807 .get("package") 808 .and_then(toml::Value::as_table) 809 .and_then(|package| package.get("name")) 810 .and_then(toml::Value::as_str) 811 .map(str::to_owned) 812 .ok_or_else(|| format!("{} is missing package.name", manifest_path.display())) 813 } 814 815 fn validate_public_manifest_field( 816 package: &toml::value::Table, 817 key: &str, 818 workspace_value: &str, 819 expected: &str, 820 package_name: &str, 821 ) -> Result<(), String> { 822 let value = resolve_public_string(package, key, workspace_value) 823 .ok_or_else(|| format!("public package {package_name} must declare {key}"))?; 824 if value != expected { 825 return Err(format!( 826 "public package {package_name} {key} {value} must be {expected}" 827 )); 828 } 829 Ok(()) 830 } 831 832 fn resolve_public_string<'a>( 833 package: &'a toml::value::Table, 834 key: &str, 835 workspace_value: &'a str, 836 ) -> Option<&'a str> { 837 match package.get(key)? { 838 toml::Value::String(value) => Some(value), 839 toml::Value::Table(value) 840 if value.get("workspace").and_then(toml::Value::as_bool) == Some(true) => 841 { 842 Some(workspace_value) 843 } 844 _ => None, 845 } 846 } 847 848 fn resolve_public_authors<'a>( 849 package: &'a toml::value::Table, 850 workspace_authors: &'a [String], 851 ) -> Option<Vec<&'a str>> { 852 match package.get("authors")? { 853 toml::Value::Array(values) => values.iter().map(toml::Value::as_str).collect(), 854 toml::Value::Table(value) 855 if value.get("workspace").and_then(toml::Value::as_bool) == Some(true) => 856 { 857 Some(workspace_authors.iter().map(String::as_str).collect()) 858 } 859 _ => None, 860 } 861 } 862 863 fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> { 864 let manifest_path = workspace_root.join("Cargo.toml"); 865 let manifest_raw = fs::read_to_string(&manifest_path) 866 .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; 867 let manifest = toml::from_str::<WorkspaceMembershipManifest>(&manifest_raw) 868 .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; 869 let manifest_value = manifest_raw 870 .parse::<toml::Value>() 871 .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; 872 validate_manifest_dependency_keys(&manifest_value, "Cargo.toml")?; 873 let declared = manifest 874 .workspace 875 .members 876 .into_iter() 877 .collect::<BTreeSet<_>>(); 878 let mut discovered = BTreeSet::new(); 879 for root in ["crates", "tools"] { 880 let directory = workspace_root.join(root); 881 for entry in fs::read_dir(&directory) 882 .map_err(|error| format!("read {}: {error}", directory.display()))? 883 { 884 let entry = 885 entry.map_err(|error| format!("read {} entry: {error}", directory.display()))?; 886 if entry 887 .file_type() 888 .map_err(|error| format!("read {} type: {error}", entry.path().display()))? 889 .is_dir() 890 && entry.path().join("Cargo.toml").is_file() 891 { 892 let directory_name = entry.file_name().to_string_lossy().into_owned(); 893 require_lower_snake_case( 894 &directory_name, 895 &format!("{root}/{directory_name} crate directory"), 896 )?; 897 let package_manifest_path = entry.path().join("Cargo.toml"); 898 let package_manifest_raw = 899 fs::read_to_string(&package_manifest_path).map_err(|error| { 900 format!("read {}: {error}", package_manifest_path.display()) 901 })?; 902 let package_manifest = 903 package_manifest_raw 904 .parse::<toml::Value>() 905 .map_err(|error| { 906 format!("parse {}: {error}", package_manifest_path.display()) 907 })?; 908 let package_name = package_manifest 909 .get("package") 910 .and_then(toml::Value::as_table) 911 .and_then(|package| package.get("name")) 912 .and_then(toml::Value::as_str) 913 .ok_or_else(|| { 914 format!( 915 "{} is missing package.name", 916 package_manifest_path.display() 917 ) 918 })?; 919 require_lower_snake_case( 920 package_name, 921 &format!("{} package.name", package_manifest_path.display()), 922 )?; 923 validate_manifest_dependency_keys( 924 &package_manifest, 925 &package_manifest_path.display().to_string(), 926 )?; 927 discovered.insert(format!("{root}/{directory_name}")); 928 } 929 } 930 } 931 if declared != discovered { 932 let missing = discovered 933 .difference(&declared) 934 .cloned() 935 .collect::<Vec<_>>() 936 .join(", "); 937 let unknown = declared 938 .difference(&discovered) 939 .cloned() 940 .collect::<Vec<_>>() 941 .join(", "); 942 return Err(format!( 943 "workspace membership is missing local package roots: {missing}; workspace membership has unknown package roots: {unknown}" 944 )); 945 } 946 Ok(()) 947 } 948 949 fn validate_manifest_dependency_keys(manifest: &toml::Value, label: &str) -> Result<(), String> { 950 let Some(manifest) = manifest.as_table() else { 951 return Err(format!("{label} must be a TOML table")); 952 }; 953 for section in ["dependencies", "dev-dependencies", "build-dependencies"] { 954 if let Some(dependencies) = manifest.get(section).and_then(toml::Value::as_table) { 955 validate_dependency_keys(dependencies, label, section)?; 956 } 957 } 958 if let Some(workspace_dependencies) = manifest 959 .get("workspace") 960 .and_then(toml::Value::as_table) 961 .and_then(|workspace| workspace.get("dependencies")) 962 .and_then(toml::Value::as_table) 963 { 964 validate_dependency_keys(workspace_dependencies, label, "workspace.dependencies")?; 965 } 966 if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { 967 for (target, target_value) in targets { 968 let Some(target_table) = target_value.as_table() else { 969 continue; 970 }; 971 for section in ["dependencies", "dev-dependencies", "build-dependencies"] { 972 if let Some(dependencies) = 973 target_table.get(section).and_then(toml::Value::as_table) 974 { 975 validate_dependency_keys( 976 dependencies, 977 label, 978 &format!("target.{target}.{section}"), 979 )?; 980 } 981 } 982 } 983 } 984 Ok(()) 985 } 986 987 fn validate_dependency_keys( 988 dependencies: &toml::value::Table, 989 label: &str, 990 section: &str, 991 ) -> Result<(), String> { 992 for key in dependencies 993 .keys() 994 .filter(|key| key.starts_with("radroots")) 995 { 996 require_lower_snake_case(key, &format!("{label} {section} dependency key"))?; 997 } 998 Ok(()) 999 } 1000 1001 fn require_lower_snake_case(value: &str, label: &str) -> Result<(), String> { 1002 if is_lower_snake_case(value) { 1003 Ok(()) 1004 } else { 1005 Err(format!("{label} `{value}` must use lowercase snake case")) 1006 } 1007 } 1008 1009 fn is_lower_snake_case(value: &str) -> bool { 1010 !value.is_empty() 1011 && value.split('_').all(|segment| { 1012 segment 1013 .as_bytes() 1014 .first() 1015 .is_some_and(u8::is_ascii_lowercase) 1016 && segment 1017 .bytes() 1018 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) 1019 }) 1020 } 1021 1022 fn validate_ledger( 1023 workspace_root: &Path, 1024 expected_architecture_id: &str, 1025 raw: &str, 1026 ) -> Result<(), String> { 1027 let ledger = toml::from_str::<DeviationLedger>(raw) 1028 .map_err(|error| format!("parse {DEVIATIONS_RELATIVE}: {error}"))?; 1029 if ledger.schema_version != 1 { 1030 return Err("deviation ledger schema_version must be 1".to_owned()); 1031 } 1032 if ledger.architecture_id != expected_architecture_id 1033 || ledger.architecture_id != ARCHITECTURE_ID 1034 { 1035 return Err(format!( 1036 "deviation ledger architecture_id {} must match {}", 1037 ledger.architecture_id, expected_architecture_id 1038 )); 1039 } 1040 1041 let mut ids = BTreeSet::new(); 1042 for record in &ledger.deviation { 1043 validate_record(workspace_root, record)?; 1044 if !ids.insert(record.id.as_str()) { 1045 return Err(format!("duplicate deviation id {}", record.id)); 1046 } 1047 } 1048 Ok(()) 1049 } 1050 1051 fn validate_record(workspace_root: &Path, record: &DeviationRecord) -> Result<(), String> { 1052 if !is_deviation_id(&record.id) { 1053 return Err(format!("deviation id {} must use RCRV1-DEV-NNN", record.id)); 1054 } 1055 if !is_iso_date(&record.date) { 1056 return Err(format!("deviation {} date must use YYYY-MM-DD", record.id)); 1057 } 1058 if !matches!(record.status.as_str(), "active" | "closed" | "superseded") { 1059 return Err(format!( 1060 "deviation {} status must be active, closed, or superseded", 1061 record.id 1062 )); 1063 } 1064 require_text(&record.id, "approval", &record.approval)?; 1065 require_text(&record.id, "replacement_action", &record.replacement_action)?; 1066 require_text(&record.id, "unresolved_risk", &record.unresolved_risk)?; 1067 require_nonempty_list(&record.id, "affected_steps", &record.affected_steps)?; 1068 require_nonempty_list(&record.id, "spec_anchors", &record.spec_anchors)?; 1069 require_nonempty_list(&record.id, "source_evidence", &record.source_evidence)?; 1070 require_nonempty_list(&record.id, "verification", &record.verification)?; 1071 1072 for step in &record.affected_steps { 1073 let valid = step.len() == 3 1074 && step.bytes().all(|byte| byte.is_ascii_digit()) 1075 && step 1076 .parse::<u16>() 1077 .is_ok_and(|value| (1..=315).contains(&value)); 1078 if !valid { 1079 return Err(format!( 1080 "deviation {} affected step {} must be in 001..315", 1081 record.id, step 1082 )); 1083 } 1084 } 1085 for anchor in &record.spec_anchors { 1086 validate_spec_anchor(workspace_root, &record.id, anchor)?; 1087 } 1088 if record.status == "active" && !record.closure_evidence.is_empty() { 1089 return Err(format!( 1090 "active deviation {} must not carry closure_evidence", 1091 record.id 1092 )); 1093 } 1094 if record.status != "active" { 1095 require_nonempty_list(&record.id, "closure_evidence", &record.closure_evidence)?; 1096 } 1097 1098 let _ = (record.normative_architecture_change, record.adr_required); 1099 Ok(()) 1100 } 1101 1102 fn validate_spec_anchor( 1103 workspace_root: &Path, 1104 deviation_id: &str, 1105 anchor: &str, 1106 ) -> Result<(), String> { 1107 let (relative, fragment) = anchor 1108 .split_once('#') 1109 .map_or((anchor, None), |(path, fragment)| (path, Some(fragment))); 1110 if relative.trim().is_empty() || fragment.is_none_or(|value| value.trim().is_empty()) { 1111 return Err(format!( 1112 "deviation {deviation_id} has invalid spec anchor {anchor}" 1113 )); 1114 } 1115 let path = Path::new(relative); 1116 if path.components().any(|component| { 1117 matches!( 1118 component, 1119 Component::ParentDir | Component::RootDir | Component::Prefix(_) 1120 ) 1121 }) { 1122 return Err(format!( 1123 "deviation {deviation_id} spec anchor must be repository-relative: {anchor}" 1124 )); 1125 } 1126 if relative != ARCHITECTURE_RELATIVE { 1127 return Err(format!( 1128 "deviation {deviation_id} spec anchor must target {ARCHITECTURE_RELATIVE}: {anchor}" 1129 )); 1130 } 1131 let machine_path = workspace_root.join(path); 1132 let raw = fs::read_to_string(&machine_path) 1133 .map_err(|error| format!("read {}: {error}", machine_path.display()))?; 1134 let machine = toml::from_str::<toml::Value>(&raw) 1135 .map_err(|error| format!("parse {}: {error}", machine_path.display()))?; 1136 let selector = fragment.expect("fragment is required above"); 1137 if !machine_selector_exists(&machine, selector) { 1138 return Err(format!( 1139 "deviation {deviation_id} spec anchor has unknown machine selector {selector}" 1140 )); 1141 } 1142 Ok(()) 1143 } 1144 1145 fn machine_selector_exists(machine: &toml::Value, selector: &str) -> bool { 1146 let segments = selector.split('.').collect::<Vec<_>>(); 1147 if segments.iter().any(|segment| segment.is_empty()) { 1148 return false; 1149 } 1150 let root = match machine.as_table() { 1151 Some(root) => root, 1152 None => return false, 1153 }; 1154 match segments.as_slice() { 1155 ["repositories", repository] => root 1156 .get("repositories") 1157 .and_then(toml::Value::as_table) 1158 .and_then(|repositories| repositories.get(*repository)) 1159 .is_some_and(toml::Value::is_table), 1160 ["repository_policy"] | ["release_policy"] => { 1161 root.get(segments[0]).is_some_and(toml::Value::is_table) 1162 } 1163 ["quality_policy", "coverage"] => root 1164 .get("quality_policy") 1165 .and_then(toml::Value::as_table) 1166 .and_then(|quality| quality.get("coverage")) 1167 .is_some_and(toml::Value::is_table), 1168 ["package", package] => root 1169 .get("package") 1170 .and_then(toml::Value::as_array) 1171 .is_some_and(|packages| { 1172 packages.iter().any(|candidate| { 1173 candidate 1174 .as_table() 1175 .and_then(|table| table.get("name")) 1176 .and_then(toml::Value::as_str) 1177 == Some(*package) 1178 }) 1179 }), 1180 _ => false, 1181 } 1182 } 1183 1184 fn require_text(deviation_id: &str, field: &str, value: &str) -> Result<(), String> { 1185 if value.trim().is_empty() { 1186 return Err(format!( 1187 "deviation {deviation_id} field {field} must not be empty" 1188 )); 1189 } 1190 Ok(()) 1191 } 1192 1193 fn require_nonempty_list(deviation_id: &str, field: &str, values: &[String]) -> Result<(), String> { 1194 if values.is_empty() || values.iter().any(|value| value.trim().is_empty()) { 1195 return Err(format!( 1196 "deviation {deviation_id} field {field} must contain non-empty values" 1197 )); 1198 } 1199 Ok(()) 1200 } 1201 1202 fn is_deviation_id(value: &str) -> bool { 1203 value 1204 .strip_prefix("RCRV1-DEV-") 1205 .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) 1206 } 1207 1208 fn is_iso_date(value: &str) -> bool { 1209 value.len() == 10 1210 && value.as_bytes()[4] == b'-' 1211 && value.as_bytes()[7] == b'-' 1212 && value 1213 .bytes() 1214 .enumerate() 1215 .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) 1216 } 1217 1218 #[cfg(test)] 1219 mod tests { 1220 use std::{ 1221 collections::BTreeMap, 1222 fs, 1223 path::PathBuf, 1224 time::{SystemTime, UNIX_EPOCH}, 1225 }; 1226 1227 use super::{ 1228 ArchitectureIdentity, ArchitecturePackage, ArchitectureRepository, validate_ledger, 1229 validate_no_production_sibling_paths, validate_public_dependency_versions, 1230 validate_public_package_metadata, validate_workspace_members, validate_workspace_toolchain, 1231 }; 1232 1233 fn test_root(label: &str) -> PathBuf { 1234 let nonce = SystemTime::now() 1235 .duration_since(UNIX_EPOCH) 1236 .expect("clock") 1237 .as_nanos(); 1238 let root = std::env::temp_dir().join(format!("radroots_architecture_{label}_{nonce}")); 1239 fs::create_dir_all(root.join("contracts/crates/release_v1")).expect("create spec root"); 1240 fs::write( 1241 root.join("contracts/crates/release_v1/radroots_crates_release_v1.toml"), 1242 "spec_id = \"radroots.crates.release.v1\"\n\n[repositories.sdk]\nurl = \"https://github.com/radrootslabs/sdk\"\n", 1243 ) 1244 .expect("write spec"); 1245 root 1246 } 1247 1248 fn complete_ledger() -> &'static str { 1249 r#"schema_version = 1 1250 architecture_id = "radroots.crates.release.v1" 1251 1252 [[deviation]] 1253 id = "RCRV1-DEV-001" 1254 date = "2026-07-27" 1255 status = "active" 1256 approval = "Explicit user correction dated 2026-07-27." 1257 affected_steps = ["015", "016"] 1258 spec_anchors = ["contracts/crates/release_v1/radroots_crates_release_v1.toml#repositories.sdk"] 1259 source_evidence = ["The approved architecture assigns packages to the existing lib and sdk repositories."] 1260 replacement_action = "Keep both standalone repositories and verify them independently." 1261 verification = ["Repository-local architecture validation passes."] 1262 unresolved_risk = "Remote publication remains separately authorized." 1263 normative_architecture_change = false 1264 adr_required = false 1265 "# 1266 } 1267 1268 fn architecture() -> ArchitectureIdentity { 1269 ArchitectureIdentity { 1270 spec_id: "radroots.crates.release.v1".to_string(), 1271 edition: "2024".to_string(), 1272 resolver: "3".to_string(), 1273 rust_version: "1.97.1".to_string(), 1274 license: "MIT OR Apache-2.0".to_string(), 1275 canonical_repositories: vec!["https://github.com/radrootslabs/sdk".to_string()], 1276 repositories: BTreeMap::from([( 1277 "sdk".to_string(), 1278 ArchitectureRepository { 1279 url: "https://github.com/radrootslabs/sdk".to_string(), 1280 version: "0.1.0".to_string(), 1281 packages: vec!["radroots".to_string()], 1282 }, 1283 )]), 1284 package: vec![ArchitecturePackage { 1285 name: "radroots".to_string(), 1286 }], 1287 } 1288 } 1289 1290 fn complete_workspace_manifest(members: &str) -> String { 1291 format!( 1292 "[workspace]\nmembers = [{members}]\nresolver = \"3\"\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.97.1\"\nlicense = \"MIT OR Apache-2.0\"\nrepository = \"https://github.com/radrootslabs/sdk\"\nhomepage = \"https://radroots.org\"\nreadme = \"README\"\nauthors = [\"Tyson Lupul <tyson@radroots.org>\"]\n\n[workspace.metadata.radroots.public-package]\nversion = \"0.1.0\"\nauthors = [\"Tyson Lupul <tyson@radroots.org>\"]\nreadme = \"README.md\"\n\n[workspace.lints.rust]\nunsafe_code = \"deny\"\n\n[workspace.lints.rustdoc]\nbroken_intra_doc_links = \"deny\"\n\n[workspace.lints.clippy]\ndbg_macro = \"deny\"\ntodo = \"deny\"\nunimplemented = \"deny\"\n" 1293 ) 1294 } 1295 1296 #[test] 1297 fn accepts_complete_active_deviation() { 1298 let root = test_root("complete"); 1299 validate_ledger(&root, "radroots.crates.release.v1", complete_ledger()) 1300 .expect("complete deviation"); 1301 let _ = fs::remove_dir_all(root); 1302 } 1303 1304 #[test] 1305 fn rejects_incomplete_active_deviation() { 1306 let root = test_root("incomplete"); 1307 let incomplete = complete_ledger().replace( 1308 "spec_anchors = [\"contracts/crates/release_v1/radroots_crates_release_v1.toml#repositories.sdk\"]", 1309 "spec_anchors = []", 1310 ); 1311 let error = validate_ledger(&root, "radroots.crates.release.v1", &incomplete) 1312 .expect_err("missing anchor must fail"); 1313 assert!(error.contains("field spec_anchors must contain non-empty values")); 1314 let _ = fs::remove_dir_all(root); 1315 } 1316 1317 #[test] 1318 fn deviation_anchors_require_resolving_machine_selectors() { 1319 let root = test_root("machine_selector"); 1320 let invalid = complete_ledger().replace("#repositories.sdk", "#garbage"); 1321 let error = validate_ledger(&root, "radroots.crates.release.v1", &invalid) 1322 .expect_err("arbitrary fragment must fail"); 1323 assert!(error.contains("unknown machine selector garbage")); 1324 1325 let markdown_slug = 1326 complete_ledger().replace("#repositories.sdk", "#20-current-to-target-migration-map"); 1327 let error = validate_ledger(&root, "radroots.crates.release.v1", &markdown_slug) 1328 .expect_err("Markdown heading slug must fail"); 1329 assert!(error.contains("unknown machine selector")); 1330 let _ = fs::remove_dir_all(root); 1331 } 1332 1333 #[test] 1334 fn workspace_membership_requires_every_local_package_root() { 1335 let root = test_root("workspace_members"); 1336 for path in ["crates/a", "tools/xtask"] { 1337 fs::create_dir_all(root.join(path)).expect("create package root"); 1338 fs::write( 1339 root.join(path).join("Cargo.toml"), 1340 "[package]\nname = \"fixture\"\n", 1341 ) 1342 .expect("write package manifest"); 1343 } 1344 fs::write( 1345 root.join("Cargo.toml"), 1346 "[workspace]\nmembers = [\"crates/a\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.package]\nrust-version = \"1.97.1\"\n", 1347 ) 1348 .expect("write complete workspace"); 1349 validate_workspace_members(&root).expect("complete membership"); 1350 1351 fs::write( 1352 root.join("Cargo.toml"), 1353 "[workspace]\nmembers = [\"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.package]\nrust-version = \"1.97.1\"\n", 1354 ) 1355 .expect("write incomplete workspace"); 1356 let error = validate_workspace_members(&root).expect_err("missing member must fail"); 1357 assert!(error.contains("missing local package roots: crates/a")); 1358 let _ = fs::remove_dir_all(root); 1359 } 1360 1361 #[test] 1362 fn workspace_identity_rejects_kebab_case_crate_names_and_dependency_keys() { 1363 let root = test_root("workspace_snake_case_identity"); 1364 fs::create_dir_all(root.join("crates/radroots_probe")).expect("create package root"); 1365 fs::create_dir_all(root.join("tools/xtask")).expect("create xtask root"); 1366 fs::write( 1367 root.join("Cargo.toml"), 1368 "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n", 1369 ) 1370 .expect("write workspace manifest"); 1371 fs::write( 1372 root.join("crates/radroots_probe/Cargo.toml"), 1373 "[package]\nname = \"radroots-probe\"\n", 1374 ) 1375 .expect("write kebab package manifest"); 1376 fs::write( 1377 root.join("tools/xtask/Cargo.toml"), 1378 "[package]\nname = \"xtask\"\n", 1379 ) 1380 .expect("write xtask manifest"); 1381 1382 let package_error = validate_workspace_members(&root) 1383 .expect_err("kebab-case Cargo package identity must fail"); 1384 assert!(package_error.contains("package.name")); 1385 assert!(package_error.contains("lowercase snake case")); 1386 1387 fs::write( 1388 root.join("crates/radroots_probe/Cargo.toml"), 1389 "[package]\nname = \"radroots_probe\"\n", 1390 ) 1391 .expect("write snake package manifest"); 1392 fs::write( 1393 root.join("Cargo.toml"), 1394 "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.dependencies]\nradroots-probe = \"0.1.0\"\n", 1395 ) 1396 .expect("write kebab dependency key"); 1397 1398 let dependency_error = validate_workspace_members(&root) 1399 .expect_err("kebab-case Radroots dependency key must fail"); 1400 assert!(dependency_error.contains("workspace.dependencies dependency key")); 1401 assert!(dependency_error.contains("lowercase snake case")); 1402 let _ = fs::remove_dir_all(root); 1403 } 1404 1405 #[test] 1406 fn workspace_toolchain_requires_exact_resolver_and_rust_version() { 1407 let root = test_root("workspace_toolchain"); 1408 fs::write(root.join("Cargo.toml"), complete_workspace_manifest("")) 1409 .expect("write workspace manifest"); 1410 for path in ["README", "LICENSE-MIT", "LICENSE-APACHE"] { 1411 fs::write(root.join(path), "fixture\n").expect("write workspace metadata file"); 1412 } 1413 fs::write( 1414 root.join("rust-toolchain.toml"), 1415 "[toolchain]\nchannel = \"1.97.1\"\n", 1416 ) 1417 .expect("write toolchain"); 1418 let architecture = architecture(); 1419 validate_workspace_toolchain(&root, &architecture).expect("exact toolchain policy"); 1420 1421 fs::write( 1422 root.join("rust-toolchain.toml"), 1423 "[toolchain]\nchannel = \"1.97.0\"\n", 1424 ) 1425 .expect("write mismatched toolchain"); 1426 let error = validate_workspace_toolchain(&root, &architecture) 1427 .expect_err("mismatched toolchain must fail"); 1428 assert!(error.contains("channel must match")); 1429 let _ = fs::remove_dir_all(root); 1430 } 1431 1432 #[test] 1433 fn public_package_metadata_requires_canonical_inheritance() { 1434 let root = test_root("public_package_metadata"); 1435 fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); 1436 fs::write( 1437 root.join("Cargo.toml"), 1438 complete_workspace_manifest("\"crates/radroots\""), 1439 ) 1440 .expect("write workspace manifest"); 1441 let manifest = "[package]\nname = \"radroots\"\nversion.workspace = true\nedition.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nrepository.workspace = true\nhomepage.workspace = true\nauthors.workspace = true\nreadme = \"README.md\"\npublish = [\"crates-io\"]\n\n[lints]\nworkspace = true\n"; 1442 fs::write(root.join("crates/radroots/Cargo.toml"), manifest) 1443 .expect("write public manifest"); 1444 fs::write(root.join("crates/radroots/README.md"), "fixture\n") 1445 .expect("write public readme"); 1446 validate_public_package_metadata(&root, &architecture()).expect("canonical metadata"); 1447 1448 fs::write( 1449 root.join("crates/radroots/Cargo.toml"), 1450 manifest.replace("[lints]\nworkspace = true\n", ""), 1451 ) 1452 .expect("write incomplete public manifest"); 1453 let error = validate_public_package_metadata(&root, &architecture()) 1454 .expect_err("missing lint inheritance must fail"); 1455 assert!(error.contains("must inherit the workspace lint policy")); 1456 let _ = fs::remove_dir_all(root); 1457 } 1458 1459 #[test] 1460 fn public_package_inventory_requires_every_local_package() { 1461 let root = test_root("public_package_inventory"); 1462 fs::write(root.join("Cargo.toml"), complete_workspace_manifest("")) 1463 .expect("write workspace manifest"); 1464 let error = validate_public_package_metadata(&root, &architecture()) 1465 .expect_err("missing local public package must fail"); 1466 assert!(error.contains("workspace public package inventory is missing: radroots")); 1467 let _ = fs::remove_dir_all(root); 1468 } 1469 1470 #[test] 1471 fn public_dependencies_require_path_and_exact_version() { 1472 let root = test_root("public_dependency_version"); 1473 fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); 1474 fs::create_dir_all(root.join("crates/dependency")).expect("create dependency package"); 1475 fs::write( 1476 root.join("Cargo.toml"), 1477 format!( 1478 "{}\n[workspace.dependencies]\nradroots_core = {{ package = \"radroots_core\", path = \"crates/dependency\", version = \"=0.1.0\" }}\n", 1479 complete_workspace_manifest("\"crates/radroots\"") 1480 ), 1481 ) 1482 .expect("write workspace manifest"); 1483 fs::write( 1484 root.join("crates/radroots/Cargo.toml"), 1485 "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\n\n[dependencies]\nradroots_core = { workspace = true }\n", 1486 ) 1487 .expect("write public manifest"); 1488 fs::write( 1489 root.join("crates/dependency/Cargo.toml"), 1490 "[package]\nname = \"radroots_core\"\nversion = \"0.1.0\"\n", 1491 ) 1492 .expect("write dependency manifest"); 1493 let mut architecture = architecture(); 1494 architecture.package.push(ArchitecturePackage { 1495 name: "radroots_core".to_owned(), 1496 }); 1497 architecture 1498 .repositories 1499 .get_mut("sdk") 1500 .expect("sdk repository") 1501 .packages 1502 .push("radroots_core".to_owned()); 1503 validate_public_dependency_versions(&root, &architecture) 1504 .expect("path plus exact version public dependency"); 1505 1506 let workspace_path = root.join("Cargo.toml"); 1507 let workspace = fs::read_to_string(&workspace_path).expect("read workspace manifest"); 1508 fs::write( 1509 &workspace_path, 1510 workspace.replace(", version = \"=0.1.0\"", ""), 1511 ) 1512 .expect("write path-only dependency"); 1513 let error = validate_public_dependency_versions(&root, &architecture) 1514 .expect_err("path-only public dependency must fail"); 1515 assert!(error.contains("must declare path and exact version =0.1.0")); 1516 let _ = fs::remove_dir_all(root); 1517 } 1518 1519 #[test] 1520 fn production_dependencies_reject_sibling_paths() { 1521 let root = test_root("production_sibling_path"); 1522 let sibling = root.with_extension("sibling"); 1523 fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); 1524 fs::create_dir_all(root.join("crates/dependency")).expect("create local dependency"); 1525 fs::create_dir_all(&sibling).expect("create sibling dependency"); 1526 fs::write( 1527 root.join("Cargo.toml"), 1528 complete_workspace_manifest("\"crates/radroots\""), 1529 ) 1530 .expect("write workspace manifest"); 1531 let sibling_path = sibling.to_string_lossy(); 1532 fs::write( 1533 root.join("crates/radroots/Cargo.toml"), 1534 format!( 1535 "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\n\n[dependencies]\nprobe = {{ path = \"{sibling_path}\" }}\n" 1536 ), 1537 ) 1538 .expect("write sibling dependency"); 1539 let error = validate_no_production_sibling_paths(&root) 1540 .expect_err("sibling production path must fail"); 1541 assert!(error.contains("escapes the repository")); 1542 1543 fs::write( 1544 root.join("crates/radroots/Cargo.toml"), 1545 "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\n\n[dependencies]\nprobe = { path = \"../dependency\" }\n", 1546 ) 1547 .expect("write local dependency"); 1548 fs::create_dir_all(root.join(".cargo")).expect("create cargo config directory"); 1549 fs::write( 1550 root.join(".cargo/config.toml"), 1551 format!("[patch.crates-io]\nprobe = {{ path = \"{sibling_path}\" }}\n"), 1552 ) 1553 .expect("write development override"); 1554 validate_no_production_sibling_paths(&root).expect("in-repository path"); 1555 let _ = fs::remove_dir_all(root); 1556 let _ = fs::remove_dir_all(sibling); 1557 } 1558 }