api_leakage.rs (51732B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 path::{Component, Path, PathBuf}, 5 process::Command, 6 }; 7 8 use serde::Deserialize; 9 use syn::{ 10 Expr, ExprLit, Fields, ForeignItem, ImplItem, Item, Lit, Meta, Path as SynPath, TraitItem, 11 Type, UseTree, Visibility, visit::Visit, 12 }; 13 14 const API_BOUNDARIES_RELATIVE: &str = "contracts/releases/api_boundaries.toml"; 15 const API_DECISION_RELATIVE: &str = 16 "contracts/architecture/decisions/public_api_leakage_migration_baseline.v1.toml"; 17 const API_DECISION_ID: &str = "radroots.public_api_leakage_migration_baseline.v1"; 18 const SPEC_ID: &str = "radroots.crates.release.v1"; 19 const POLICY_SCHEMA_VERSION: u16 = 1; 20 const DECISION_SCHEMA_VERSION: u16 = 1; 21 const CURRENT_STEP: u16 = 313; 22 const HISTORICAL_EXCEPTION_IDS: [&str; 8] = [ 23 "RCRV1-API-001", 24 "RCRV1-API-002", 25 "RCRV1-API-003", 26 "RCRV1-API-004", 27 "RCRV1-API-005", 28 "RCRV1-API-006", 29 "RCRV1-API-007", 30 "RCRV1-API-008", 31 ]; 32 const FORBIDDEN_EXPANSION: [&str; 8] = [ 33 "broader_aliases", 34 "keyring", 35 "new_items", 36 "new_upstream_paths", 37 "platform_specific_types", 38 "reqwest", 39 "sqlx", 40 "tokio", 41 ]; 42 const REMOVAL_MILESTONES: [(&str, u16); 3] = [ 43 ("radroots_identity", 42), 44 ("radroots_nostr", 124), 45 ("radroots_nostr_connect", 140), 46 ]; 47 48 #[derive(Debug, Deserialize)] 49 #[serde(deny_unknown_fields)] 50 struct ApiBoundaryPolicy { 51 schema_version: u16, 52 spec_id: String, 53 forbidden_public_paths: Vec<String>, 54 package: Vec<ApiPackagePolicy>, 55 #[serde(default)] 56 exception: Vec<ApiException>, 57 } 58 59 #[derive(Debug, Deserialize)] 60 #[serde(deny_unknown_fields)] 61 struct ApiPackagePolicy { 62 name: String, 63 allowed_public_paths: Vec<String>, 64 } 65 66 #[derive(Debug, Deserialize)] 67 #[serde(deny_unknown_fields)] 68 struct ApiException { 69 id: String, 70 package: String, 71 source: String, 72 forbidden_path: String, 73 items: Vec<String>, 74 observed_paths: Vec<String>, 75 decision: String, 76 removal_step: u16, 77 rationale: String, 78 } 79 80 #[derive(Debug, Deserialize)] 81 #[serde(deny_unknown_fields)] 82 struct ApiLeakageDecision { 83 schema_version: u16, 84 decision_id: String, 85 status: String, 86 accepted_date: String, 87 completed_step: u16, 88 publication_authorized: bool, 89 exception_ids: Vec<String>, 90 active_exception_ids: Vec<String>, 91 forbidden_expansion: Vec<String>, 92 removal_milestone: Vec<ApiRemovalMilestone>, 93 } 94 95 #[derive(Debug, Deserialize)] 96 #[serde(deny_unknown_fields)] 97 struct ApiRemovalMilestone { 98 package: String, 99 step: u16, 100 } 101 102 #[derive(Debug, Deserialize)] 103 struct CargoMetadata { 104 packages: Vec<CargoPackage>, 105 workspace_members: Vec<String>, 106 } 107 108 #[derive(Debug, Deserialize)] 109 struct CargoPackage { 110 id: String, 111 name: String, 112 manifest_path: String, 113 targets: Vec<CargoTarget>, 114 } 115 116 #[derive(Debug, Deserialize)] 117 struct CargoTarget { 118 kind: Vec<String>, 119 src_path: String, 120 } 121 122 struct ModuleUnit { 123 module: Vec<String>, 124 parent: Option<Vec<String>>, 125 declared_public: bool, 126 initially_effective: bool, 127 source_relative: String, 128 items: Vec<Item>, 129 } 130 131 #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] 132 struct ApiFinding { 133 package: String, 134 source: String, 135 item: String, 136 forbidden_path: String, 137 observed_path: String, 138 } 139 140 #[derive(Debug)] 141 enum InternalExport { 142 Module(Vec<String>), 143 Item(Vec<String>, String), 144 External, 145 } 146 147 pub(super) fn validate_policy_catalog( 148 workspace_root: &Path, 149 expected_packages: &BTreeSet<String>, 150 ) -> Result<(), String> { 151 let policy = load_policy(workspace_root)?; 152 validate_policy(workspace_root, &policy, expected_packages) 153 } 154 155 pub(super) fn validate_public_api(workspace_root: &Path) -> Result<(), String> { 156 let policy = load_policy(workspace_root)?; 157 let expected_packages = policy 158 .package 159 .iter() 160 .map(|package| package.name.clone()) 161 .collect::<BTreeSet<_>>(); 162 validate_policy(workspace_root, &policy, &expected_packages)?; 163 let metadata = load_metadata(workspace_root)?; 164 let findings = scan_workspace(workspace_root, &policy, &metadata)?; 165 let unapproved = findings 166 .into_iter() 167 .filter(|finding| !exception_matches(&policy.exception, finding)) 168 .collect::<Vec<_>>(); 169 if unapproved.is_empty() { 170 Ok(()) 171 } else { 172 Err(format!( 173 "forbidden public implementation type leakage:\n{}", 174 unapproved 175 .iter() 176 .map(format_finding) 177 .collect::<Vec<_>>() 178 .join("\n") 179 )) 180 } 181 } 182 183 fn load_policy(workspace_root: &Path) -> Result<ApiBoundaryPolicy, String> { 184 let path = workspace_root.join(API_BOUNDARIES_RELATIVE); 185 let raw = 186 fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; 187 toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) 188 } 189 190 fn validate_policy( 191 workspace_root: &Path, 192 policy: &ApiBoundaryPolicy, 193 expected_packages: &BTreeSet<String>, 194 ) -> Result<(), String> { 195 if policy.schema_version != POLICY_SCHEMA_VERSION { 196 return Err(format!( 197 "{API_BOUNDARIES_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}" 198 )); 199 } 200 if policy.spec_id != SPEC_ID { 201 return Err(format!( 202 "{API_BOUNDARIES_RELATIVE} spec_id must be {SPEC_ID}" 203 )); 204 } 205 let decision = load_and_validate_decision(workspace_root)?; 206 207 let forbidden = sorted_unique( 208 "forbidden_public_paths", 209 &policy.forbidden_public_paths, 210 false, 211 )?; 212 if forbidden.len() != policy.forbidden_public_paths.len() { 213 return Err(format!( 214 "{API_BOUNDARIES_RELATIVE} forbidden_public_paths must be unique" 215 )); 216 } 217 for required in [ 218 "keyring", 219 "nostr_sdk", 220 "reqwest", 221 "sqlx", 222 "std::os", 223 "tokio", 224 ] { 225 if !forbidden.contains(required) { 226 return Err(format!( 227 "{API_BOUNDARIES_RELATIVE} must forbid public {required} paths" 228 )); 229 } 230 } 231 232 let mut package_names = BTreeSet::new(); 233 for package in &policy.package { 234 if !package_names.insert(package.name.as_str()) { 235 return Err(format!( 236 "{API_BOUNDARIES_RELATIVE} package {} is declared more than once", 237 package.name 238 )); 239 } 240 let allowed = sorted_unique( 241 &format!("package {} allowed_public_paths", package.name), 242 &package.allowed_public_paths, 243 true, 244 )?; 245 for path in allowed { 246 if !forbidden.contains(path) { 247 return Err(format!( 248 "{API_BOUNDARIES_RELATIVE} package {} allows {path}, which is not forbidden globally", 249 package.name 250 )); 251 } 252 } 253 } 254 let actual_packages = package_names 255 .into_iter() 256 .map(str::to_owned) 257 .collect::<BTreeSet<_>>(); 258 if &actual_packages != expected_packages { 259 return Err(package_set_mismatch(expected_packages, &actual_packages)); 260 } 261 262 let package_policy = policy 263 .package 264 .iter() 265 .map(|package| (package.name.as_str(), package)) 266 .collect::<BTreeMap<_, _>>(); 267 let mut exception_ids = BTreeSet::new(); 268 let mut exception_keys = BTreeSet::new(); 269 for exception in &policy.exception { 270 if !exception_ids.insert(exception.id.as_str()) { 271 return Err(format!( 272 "{API_BOUNDARIES_RELATIVE} exception id {} is duplicated", 273 exception.id 274 )); 275 } 276 if !valid_exception_id(&exception.id) { 277 return Err(format!( 278 "{API_BOUNDARIES_RELATIVE} exception id {} must match RCRV1-API-NNN", 279 exception.id 280 )); 281 } 282 let package = package_policy 283 .get(exception.package.as_str()) 284 .ok_or_else(|| { 285 format!( 286 "{API_BOUNDARIES_RELATIVE} exception {} names unknown package {}", 287 exception.id, exception.package 288 ) 289 })?; 290 if !forbidden.contains(exception.forbidden_path.as_str()) { 291 return Err(format!( 292 "{API_BOUNDARIES_RELATIVE} exception {} names unknown forbidden path {}", 293 exception.id, exception.forbidden_path 294 )); 295 } 296 if package 297 .allowed_public_paths 298 .iter() 299 .any(|allowed| allowed == &exception.forbidden_path) 300 { 301 return Err(format!( 302 "{API_BOUNDARIES_RELATIVE} exception {} is redundant with package allowance {}", 303 exception.id, exception.forbidden_path 304 )); 305 } 306 validate_relative_source(&exception.id, &exception.source)?; 307 let items = sorted_unique( 308 &format!("exception {} items", exception.id), 309 &exception.items, 310 false, 311 )?; 312 if items.len() != exception.items.len() { 313 return Err(format!( 314 "{API_BOUNDARIES_RELATIVE} exception {} items must be unique", 315 exception.id 316 )); 317 } 318 let observed_paths = sorted_unique( 319 &format!("exception {} observed_paths", exception.id), 320 &exception.observed_paths, 321 false, 322 )?; 323 if observed_paths.len() != exception.observed_paths.len() 324 || observed_paths.iter().any(|path| { 325 !(path == &exception.forbidden_path 326 || path 327 .strip_prefix(&exception.forbidden_path) 328 .is_some_and(|suffix| suffix.starts_with("::"))) 329 }) 330 { 331 return Err(format!( 332 "{API_BOUNDARIES_RELATIVE} exception {} observed_paths must be sorted, unique, and rooted at {}", 333 exception.id, exception.forbidden_path 334 )); 335 } 336 if exception.removal_step <= CURRENT_STEP { 337 return Err(format!( 338 "{API_BOUNDARIES_RELATIVE} exception {} expired at Step {}", 339 exception.id, exception.removal_step 340 )); 341 } 342 if exception.rationale.trim().is_empty() { 343 return Err(format!( 344 "{API_BOUNDARIES_RELATIVE} exception {} must include a rationale", 345 exception.id 346 )); 347 } 348 validate_decision_reference(exception, &decision)?; 349 for item in &exception.items { 350 let key = ( 351 exception.package.as_str(), 352 exception.source.as_str(), 353 item.as_str(), 354 exception.forbidden_path.as_str(), 355 ); 356 if !exception_keys.insert(key) { 357 return Err(format!( 358 "{API_BOUNDARIES_RELATIVE} exception {} duplicates an item-scoped allowance", 359 exception.id 360 )); 361 } 362 } 363 } 364 let active_exception_ids = decision 365 .active_exception_ids 366 .iter() 367 .map(String::as_str) 368 .collect::<BTreeSet<_>>(); 369 if exception_ids != active_exception_ids { 370 return Err(format!( 371 "{API_BOUNDARIES_RELATIVE} active exception ids must exactly match {API_DECISION_RELATIVE}" 372 )); 373 } 374 Ok(()) 375 } 376 377 fn sorted_unique<'a>( 378 label: &str, 379 values: &'a [String], 380 allow_empty: bool, 381 ) -> Result<BTreeSet<&'a str>, String> { 382 if !allow_empty && values.is_empty() { 383 return Err(format!( 384 "{API_BOUNDARIES_RELATIVE} {label} must not be empty" 385 )); 386 } 387 let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); 388 if unique.iter().copied().ne(values.iter().map(String::as_str)) { 389 return Err(format!( 390 "{API_BOUNDARIES_RELATIVE} {label} must be sorted and unique" 391 )); 392 } 393 Ok(unique) 394 } 395 396 fn valid_exception_id(id: &str) -> bool { 397 id.strip_prefix("RCRV1-API-") 398 .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) 399 } 400 401 fn validate_relative_source(id: &str, source: &str) -> Result<(), String> { 402 let path = Path::new(source); 403 if source.is_empty() 404 || path.is_absolute() 405 || path 406 .components() 407 .any(|component| !matches!(component, Component::Normal(_))) 408 || path.extension().and_then(|extension| extension.to_str()) != Some("rs") 409 { 410 return Err(format!( 411 "{API_BOUNDARIES_RELATIVE} exception {id} source must be a normalized relative Rust path" 412 )); 413 } 414 Ok(()) 415 } 416 417 fn load_and_validate_decision(workspace_root: &Path) -> Result<ApiLeakageDecision, String> { 418 let path = workspace_root.join(API_DECISION_RELATIVE); 419 let raw = 420 fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; 421 let decision = toml::from_str::<ApiLeakageDecision>(&raw) 422 .map_err(|error| format!("parse {}: {error}", path.display()))?; 423 424 if decision.schema_version != DECISION_SCHEMA_VERSION 425 || decision.decision_id != API_DECISION_ID 426 || decision.status != "accepted_historical" 427 || decision.accepted_date != "2026-07-27" 428 || decision.completed_step != CURRENT_STEP 429 || decision.publication_authorized 430 { 431 return Err(format!( 432 "{API_DECISION_RELATIVE} identity, lifecycle, or publication policy drifted" 433 )); 434 } 435 if decision 436 .exception_ids 437 .iter() 438 .map(String::as_str) 439 .ne(HISTORICAL_EXCEPTION_IDS) 440 { 441 return Err(format!( 442 "{API_DECISION_RELATIVE} must preserve the exact historical exception ids" 443 )); 444 } 445 if !decision.active_exception_ids.is_empty() { 446 return Err(format!( 447 "{API_DECISION_RELATIVE} must not authorize active exceptions after Step {CURRENT_STEP}" 448 )); 449 } 450 if decision 451 .forbidden_expansion 452 .iter() 453 .map(String::as_str) 454 .ne(FORBIDDEN_EXPANSION) 455 { 456 return Err(format!( 457 "{API_DECISION_RELATIVE} forbidden expansion set drifted" 458 )); 459 } 460 if decision 461 .removal_milestone 462 .iter() 463 .map(|milestone| (milestone.package.as_str(), milestone.step)) 464 .ne(REMOVAL_MILESTONES) 465 { 466 return Err(format!( 467 "{API_DECISION_RELATIVE} removal milestones drifted" 468 )); 469 } 470 Ok(decision) 471 } 472 473 fn validate_decision_reference( 474 exception: &ApiException, 475 decision: &ApiLeakageDecision, 476 ) -> Result<(), String> { 477 let path = Path::new(&exception.decision); 478 if exception.decision != API_DECISION_RELATIVE 479 || path.is_absolute() 480 || path 481 .components() 482 .any(|component| !matches!(component, Component::Normal(_))) 483 || path.extension().and_then(|extension| extension.to_str()) != Some("toml") 484 { 485 return Err(format!( 486 "{API_BOUNDARIES_RELATIVE} exception {} decision must be {API_DECISION_RELATIVE}", 487 exception.id 488 )); 489 } 490 if !decision 491 .active_exception_ids 492 .iter() 493 .any(|id| id == &exception.id) 494 { 495 return Err(format!( 496 "{API_BOUNDARIES_RELATIVE} exception {} is not active in {API_DECISION_RELATIVE}", 497 exception.id 498 )); 499 } 500 Ok(()) 501 } 502 503 fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { 504 let missing = expected 505 .difference(actual) 506 .cloned() 507 .collect::<Vec<_>>() 508 .join(", "); 509 let extra = actual 510 .difference(expected) 511 .cloned() 512 .collect::<Vec<_>>() 513 .join(", "); 514 format!( 515 "{API_BOUNDARIES_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}" 516 ) 517 } 518 519 fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { 520 let output = Command::new("cargo") 521 .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) 522 .current_dir(workspace_root) 523 .output() 524 .map_err(|error| format!("run cargo metadata: {error}"))?; 525 if !output.status.success() { 526 return Err(format!( 527 "cargo metadata failed while checking public API boundaries: {}", 528 String::from_utf8_lossy(&output.stderr).trim() 529 )); 530 } 531 serde_json::from_slice(&output.stdout) 532 .map_err(|error| format!("parse cargo metadata for public API boundaries: {error}")) 533 } 534 535 fn scan_workspace( 536 _workspace_root: &Path, 537 policy: &ApiBoundaryPolicy, 538 metadata: &CargoMetadata, 539 ) -> Result<Vec<ApiFinding>, String> { 540 let workspace_members = metadata 541 .workspace_members 542 .iter() 543 .map(String::as_str) 544 .collect::<BTreeSet<_>>(); 545 let package_policy = policy 546 .package 547 .iter() 548 .map(|package| (package.name.as_str(), package)) 549 .collect::<BTreeMap<_, _>>(); 550 let forbidden = policy 551 .forbidden_public_paths 552 .iter() 553 .map(String::as_str) 554 .collect::<BTreeSet<_>>(); 555 let mut findings = BTreeSet::new(); 556 557 for package in &metadata.packages { 558 if !workspace_members.contains(package.id.as_str()) { 559 continue; 560 } 561 let Some(package_policy) = package_policy.get(package.name.as_str()).copied() else { 562 continue; 563 }; 564 let manifest_path = Path::new(&package.manifest_path); 565 let package_root = manifest_path.parent().ok_or_else(|| { 566 format!( 567 "package {} manifest has no parent: {}", 568 package.name, package.manifest_path 569 ) 570 })?; 571 let allowed = package_policy 572 .allowed_public_paths 573 .iter() 574 .map(String::as_str) 575 .collect::<BTreeSet<_>>(); 576 let library_targets = package 577 .targets 578 .iter() 579 .filter(|target| { 580 target 581 .kind 582 .iter() 583 .any(|kind| kind == "lib" || kind == "proc-macro") 584 }) 585 .collect::<Vec<_>>(); 586 if library_targets.len() != 1 { 587 return Err(format!( 588 "public package {} must expose exactly one library target for API leakage analysis", 589 package.name 590 )); 591 } 592 let source_path = Path::new(&library_targets[0].src_path); 593 let source_module_directory = module_directory(source_path)?; 594 let mut units = BTreeMap::new(); 595 collect_module( 596 package_root, 597 Vec::new(), 598 None, 599 true, 600 true, 601 source_path, 602 &source_module_directory, 603 None, 604 &mut units, 605 )?; 606 findings.extend(scan_package_units( 607 &package.name, 608 &units, 609 &forbidden, 610 &allowed, 611 )?); 612 } 613 614 Ok(findings.into_iter().collect()) 615 } 616 617 #[allow(clippy::too_many_arguments)] 618 fn collect_module( 619 package_root: &Path, 620 module: Vec<String>, 621 parent: Option<Vec<String>>, 622 declared_public: bool, 623 initially_effective: bool, 624 source_path: &Path, 625 module_directory: &Path, 626 inline_items: Option<Vec<Item>>, 627 units: &mut BTreeMap<Vec<String>, ModuleUnit>, 628 ) -> Result<(), String> { 629 if units.contains_key(&module) { 630 return Ok(()); 631 } 632 let (items, source_relative) = if let Some(items) = inline_items { 633 let relative = source_path 634 .strip_prefix(package_root) 635 .map_err(|_| { 636 format!( 637 "module source {} escapes package {}", 638 source_path.display(), 639 package_root.display() 640 ) 641 })? 642 .to_string_lossy() 643 .replace('\\', "/"); 644 (items, relative) 645 } else { 646 let raw = fs::read_to_string(source_path) 647 .map_err(|error| format!("read {}: {error}", source_path.display()))?; 648 let file = syn::parse_file(&raw) 649 .map_err(|error| format!("parse {}: {error}", source_path.display()))?; 650 let relative = source_path 651 .strip_prefix(package_root) 652 .map_err(|_| { 653 format!( 654 "module source {} escapes package {}", 655 source_path.display(), 656 package_root.display() 657 ) 658 })? 659 .to_string_lossy() 660 .replace('\\', "/"); 661 (file.items, relative) 662 }; 663 664 let children = items 665 .iter() 666 .filter_map(|item| match item { 667 Item::Mod(item_mod) => Some(item_mod.clone()), 668 _ => None, 669 }) 670 .collect::<Vec<_>>(); 671 units.insert( 672 module.clone(), 673 ModuleUnit { 674 module: module.clone(), 675 parent, 676 declared_public, 677 initially_effective, 678 source_relative, 679 items, 680 }, 681 ); 682 683 for child in children { 684 let mut child_module = module.clone(); 685 child_module.push(child.ident.to_string()); 686 let child_public = is_public(&child.vis); 687 let child_effective = initially_effective && child_public; 688 if let Some((_, inline)) = child.content { 689 collect_module( 690 package_root, 691 child_module, 692 Some(module.clone()), 693 child_public, 694 child_effective, 695 source_path, 696 &module_directory.join(child.ident.to_string()), 697 Some(inline), 698 units, 699 )?; 700 } else { 701 let child_path = module_source_path(source_path, module_directory, &child)?; 702 collect_module( 703 package_root, 704 child_module, 705 Some(module.clone()), 706 child_public, 707 child_effective, 708 &child_path, 709 &module_directory.join(child.ident.to_string()), 710 None, 711 units, 712 )?; 713 } 714 } 715 Ok(()) 716 } 717 718 fn module_directory(source_path: &Path) -> Result<PathBuf, String> { 719 let parent = source_path 720 .parent() 721 .ok_or_else(|| format!("module source has no parent: {}", source_path.display()))?; 722 let file_name = source_path.file_name().and_then(|name| name.to_str()); 723 if matches!(file_name, Some("lib.rs" | "main.rs" | "mod.rs")) { 724 Ok(parent.to_path_buf()) 725 } else { 726 Ok(parent.join( 727 source_path 728 .file_stem() 729 .ok_or_else(|| format!("module source has no stem: {}", source_path.display()))?, 730 )) 731 } 732 } 733 734 fn module_source_path( 735 source_path: &Path, 736 module_directory: &Path, 737 item_mod: &syn::ItemMod, 738 ) -> Result<PathBuf, String> { 739 if let Some(path) = path_attribute(item_mod) { 740 return Ok(source_path 741 .parent() 742 .unwrap_or_else(|| Path::new(".")) 743 .join(path)); 744 } 745 let name = item_mod.ident.to_string(); 746 let flat = module_directory.join(format!("{name}.rs")); 747 let nested = module_directory.join(&name).join("mod.rs"); 748 match (flat.is_file(), nested.is_file()) { 749 (true, false) => Ok(flat), 750 (false, true) => Ok(nested), 751 (true, true) => Err(format!( 752 "module {} is ambiguous between {} and {}", 753 item_mod.ident, 754 flat.display(), 755 nested.display() 756 )), 757 (false, false) => Err(format!( 758 "module {} source is missing under {}", 759 item_mod.ident, 760 module_directory.display() 761 )), 762 } 763 } 764 765 fn path_attribute(item_mod: &syn::ItemMod) -> Option<String> { 766 item_mod.attrs.iter().find_map(|attribute| { 767 if !attribute.path().is_ident("path") { 768 return None; 769 } 770 let Meta::NameValue(value) = &attribute.meta else { 771 return None; 772 }; 773 let Expr::Lit(ExprLit { 774 lit: Lit::Str(path), 775 .. 776 }) = &value.value 777 else { 778 return None; 779 }; 780 Some(path.value()) 781 }) 782 } 783 784 fn scan_package_units( 785 package: &str, 786 units: &BTreeMap<Vec<String>, ModuleUnit>, 787 forbidden: &BTreeSet<&str>, 788 allowed: &BTreeSet<&str>, 789 ) -> Result<Vec<ApiFinding>, String> { 790 let mut effective = units 791 .values() 792 .filter(|unit| unit.initially_effective) 793 .map(|unit| unit.module.clone()) 794 .collect::<BTreeSet<_>>(); 795 let mut exported_items = BTreeSet::new(); 796 let mut changed = true; 797 while changed { 798 changed = propagate_public_modules(units, &mut effective); 799 let scopes = effective.iter().cloned().collect::<Vec<_>>(); 800 for scope in scopes { 801 let unit = units 802 .get(&scope) 803 .ok_or_else(|| format!("missing module unit {}", module_label(&scope)))?; 804 for item_use in unit.items.iter().filter_map(|item| match item { 805 Item::Use(item_use) if is_public(&item_use.vis) => Some(item_use), 806 _ => None, 807 }) { 808 for (segments, glob) in flatten_use_tree(&item_use.tree) { 809 match resolve_internal_export(&scope, &segments, glob, units) { 810 InternalExport::Module(module) => { 811 changed |= effective.insert(module); 812 } 813 InternalExport::Item(module, item) => { 814 changed |= exported_items.insert((module, item)); 815 } 816 InternalExport::External => {} 817 } 818 } 819 } 820 } 821 } 822 propagate_public_modules(units, &mut effective); 823 824 let mut findings = BTreeSet::new(); 825 for unit in units.values() { 826 let imports = import_map(&unit.items); 827 let unit_effective = effective.contains(&unit.module); 828 let explicitly_exported = exported_items 829 .iter() 830 .filter(|(module, _)| module == &unit.module) 831 .map(|(_, item)| item.as_str()) 832 .collect::<BTreeSet<_>>(); 833 834 for item in &unit.items { 835 if let Item::Use(item_use) = item { 836 if unit_effective && is_public(&item_use.vis) { 837 scan_public_use( 838 package, 839 unit, 840 item_use, 841 &imports, 842 forbidden, 843 allowed, 844 &mut findings, 845 ); 846 } 847 continue; 848 } 849 if let Item::Impl(item_impl) = item { 850 let Some(self_name) = impl_self_name(&item_impl.self_ty) else { 851 continue; 852 }; 853 if unit_effective || explicitly_exported.contains(self_name.as_str()) { 854 scan_impl( 855 package, 856 unit, 857 item_impl, 858 &self_name, 859 &imports, 860 forbidden, 861 allowed, 862 &mut findings, 863 ); 864 } 865 continue; 866 } 867 let Some(name) = item_name(item) else { 868 continue; 869 }; 870 if (unit_effective && item_is_public(item)) 871 || explicitly_exported.contains(name.as_str()) 872 { 873 scan_item( 874 package, 875 unit, 876 item, 877 &imports, 878 forbidden, 879 allowed, 880 &mut findings, 881 ); 882 } 883 } 884 } 885 Ok(findings.into_iter().collect()) 886 } 887 888 fn propagate_public_modules( 889 units: &BTreeMap<Vec<String>, ModuleUnit>, 890 effective: &mut BTreeSet<Vec<String>>, 891 ) -> bool { 892 let mut changed = false; 893 loop { 894 let before = effective.len(); 895 for unit in units.values() { 896 if unit.declared_public 897 && unit 898 .parent 899 .as_ref() 900 .is_some_and(|parent| effective.contains(parent)) 901 { 902 effective.insert(unit.module.clone()); 903 } 904 } 905 if effective.len() == before { 906 break; 907 } 908 changed = true; 909 } 910 changed 911 } 912 913 fn flatten_use_tree(tree: &UseTree) -> Vec<(Vec<String>, bool)> { 914 fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut Vec<(Vec<String>, bool)>) { 915 match tree { 916 UseTree::Path(path) => { 917 prefix.push(path.ident.to_string()); 918 visit(&path.tree, prefix, output); 919 prefix.pop(); 920 } 921 UseTree::Name(name) => { 922 if name.ident == "self" { 923 output.push((prefix.clone(), false)); 924 } else { 925 let mut path = prefix.clone(); 926 path.push(name.ident.to_string()); 927 output.push((path, false)); 928 } 929 } 930 UseTree::Rename(rename) => { 931 let mut path = prefix.clone(); 932 path.push(rename.ident.to_string()); 933 output.push((path, false)); 934 } 935 UseTree::Glob(_) => output.push((prefix.clone(), true)), 936 UseTree::Group(group) => { 937 for item in &group.items { 938 visit(item, prefix, output); 939 } 940 } 941 } 942 } 943 944 let mut output = Vec::new(); 945 visit(tree, &mut Vec::new(), &mut output); 946 output 947 } 948 949 fn resolve_internal_export( 950 current: &[String], 951 segments: &[String], 952 glob: bool, 953 units: &BTreeMap<Vec<String>, ModuleUnit>, 954 ) -> InternalExport { 955 if segments.is_empty() { 956 return InternalExport::External; 957 } 958 let candidates = internal_candidates(current, segments); 959 for candidate in candidates { 960 if glob && units.contains_key(&candidate) { 961 return InternalExport::Module(candidate); 962 } 963 if units.contains_key(&candidate) { 964 return InternalExport::Module(candidate); 965 } 966 if let Some((item, module)) = candidate.split_last() { 967 let module = module.to_vec(); 968 if units.contains_key(&module) { 969 return InternalExport::Item(module, item.clone()); 970 } 971 } 972 } 973 InternalExport::External 974 } 975 976 fn internal_candidates(current: &[String], segments: &[String]) -> Vec<Vec<String>> { 977 let mut candidates = Vec::new(); 978 match segments.first().map(String::as_str) { 979 Some("crate") => candidates.push(segments[1..].to_vec()), 980 Some("self") => { 981 let mut path = current.to_vec(); 982 path.extend_from_slice(&segments[1..]); 983 candidates.push(path); 984 } 985 Some("super") => { 986 let mut base = current.to_vec(); 987 let mut index = 0; 988 while segments.get(index).map(String::as_str) == Some("super") { 989 base.pop(); 990 index += 1; 991 } 992 base.extend_from_slice(&segments[index..]); 993 candidates.push(base); 994 } 995 _ => { 996 candidates.push(segments.to_vec()); 997 let mut local = current.to_vec(); 998 local.extend_from_slice(segments); 999 if !candidates.contains(&local) { 1000 candidates.push(local); 1001 } 1002 } 1003 } 1004 candidates 1005 } 1006 1007 fn import_map(items: &[Item]) -> BTreeMap<String, Vec<String>> { 1008 fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut BTreeMap<String, Vec<String>>) { 1009 match tree { 1010 UseTree::Path(path) => { 1011 prefix.push(path.ident.to_string()); 1012 visit(&path.tree, prefix, output); 1013 prefix.pop(); 1014 } 1015 UseTree::Name(name) => { 1016 if name.ident == "self" { 1017 if let Some(local) = prefix.last() { 1018 output.insert(local.clone(), prefix.clone()); 1019 } 1020 } else { 1021 let mut path = prefix.clone(); 1022 path.push(name.ident.to_string()); 1023 output.insert(name.ident.to_string(), path); 1024 } 1025 } 1026 UseTree::Rename(rename) => { 1027 let mut path = prefix.clone(); 1028 path.push(rename.ident.to_string()); 1029 output.insert(rename.rename.to_string(), path); 1030 } 1031 UseTree::Glob(_) => {} 1032 UseTree::Group(group) => { 1033 for item in &group.items { 1034 visit(item, prefix, output); 1035 } 1036 } 1037 } 1038 } 1039 1040 let mut output = BTreeMap::new(); 1041 for item_use in items.iter().filter_map(|item| match item { 1042 Item::Use(item_use) => Some(item_use), 1043 _ => None, 1044 }) { 1045 visit(&item_use.tree, &mut Vec::new(), &mut output); 1046 } 1047 output 1048 } 1049 1050 #[allow(clippy::too_many_arguments)] 1051 fn scan_item( 1052 package: &str, 1053 unit: &ModuleUnit, 1054 item: &Item, 1055 imports: &BTreeMap<String, Vec<String>>, 1056 forbidden: &BTreeSet<&str>, 1057 allowed: &BTreeSet<&str>, 1058 findings: &mut BTreeSet<ApiFinding>, 1059 ) { 1060 let name = item_name(item).unwrap_or_else(|| "<item>".to_owned()); 1061 let label = qualified_item(&unit.module, &name); 1062 let mut visitor = 1063 LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); 1064 match item { 1065 Item::Const(item) => visitor.visit_type(&item.ty), 1066 Item::Enum(item) => { 1067 visitor.visit_generics(&item.generics); 1068 for variant in &item.variants { 1069 visit_fields(&mut visitor, &variant.fields, true); 1070 } 1071 } 1072 Item::Fn(item) => visitor.visit_signature(&item.sig), 1073 Item::ForeignMod(item) => { 1074 for foreign in &item.items { 1075 if let ForeignItem::Fn(function) = foreign { 1076 visitor.visit_signature(&function.sig); 1077 } 1078 } 1079 } 1080 Item::Static(item) => visitor.visit_type(&item.ty), 1081 Item::Struct(item) => { 1082 visitor.visit_generics(&item.generics); 1083 visit_fields(&mut visitor, &item.fields, false); 1084 } 1085 Item::Trait(item) => { 1086 visitor.visit_generics(&item.generics); 1087 for bound in &item.supertraits { 1088 visitor.visit_type_param_bound(bound); 1089 } 1090 for trait_item in &item.items { 1091 match trait_item { 1092 TraitItem::Const(item) => visitor.visit_type(&item.ty), 1093 TraitItem::Fn(item) => visitor.visit_signature(&item.sig), 1094 TraitItem::Type(item) => { 1095 visitor.visit_generics(&item.generics); 1096 for bound in &item.bounds { 1097 visitor.visit_type_param_bound(bound); 1098 } 1099 if let Some((_, ty)) = &item.default { 1100 visitor.visit_type(ty); 1101 } 1102 } 1103 _ => {} 1104 } 1105 } 1106 } 1107 Item::TraitAlias(item) => { 1108 visitor.visit_generics(&item.generics); 1109 for bound in &item.bounds { 1110 visitor.visit_type_param_bound(bound); 1111 } 1112 } 1113 Item::Type(item) => { 1114 visitor.visit_generics(&item.generics); 1115 visitor.visit_type(&item.ty); 1116 } 1117 Item::Union(item) => { 1118 visitor.visit_generics(&item.generics); 1119 for field in &item.fields.named { 1120 if is_public(&field.vis) { 1121 visitor.visit_type(&field.ty); 1122 } 1123 } 1124 } 1125 _ => {} 1126 } 1127 } 1128 1129 fn visit_fields(visitor: &mut LeakageVisitor<'_>, fields: &Fields, all_visible: bool) { 1130 for field in fields { 1131 if all_visible || is_public(&field.vis) { 1132 visitor.visit_type(&field.ty); 1133 } 1134 } 1135 } 1136 1137 #[allow(clippy::too_many_arguments)] 1138 fn scan_impl( 1139 package: &str, 1140 unit: &ModuleUnit, 1141 item_impl: &syn::ItemImpl, 1142 self_name: &str, 1143 imports: &BTreeMap<String, Vec<String>>, 1144 forbidden: &BTreeSet<&str>, 1145 allowed: &BTreeSet<&str>, 1146 findings: &mut BTreeSet<ApiFinding>, 1147 ) { 1148 let trait_impl = item_impl.trait_.is_some(); 1149 for item in &item_impl.items { 1150 let (name, is_exposed) = match item { 1151 ImplItem::Const(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), 1152 ImplItem::Fn(item) => ( 1153 item.sig.ident.to_string(), 1154 trait_impl || is_public(&item.vis), 1155 ), 1156 ImplItem::Type(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), 1157 _ => continue, 1158 }; 1159 if !is_exposed { 1160 continue; 1161 } 1162 let label = format!("{}::{name}", qualified_item(&unit.module, self_name)); 1163 let mut visitor = 1164 LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); 1165 if let Some((_, trait_path, _)) = &item_impl.trait_ { 1166 visitor.visit_path(trait_path); 1167 } 1168 match item { 1169 ImplItem::Const(item) => visitor.visit_type(&item.ty), 1170 ImplItem::Fn(item) => visitor.visit_signature(&item.sig), 1171 ImplItem::Type(item) => { 1172 visitor.visit_generics(&item.generics); 1173 visitor.visit_type(&item.ty); 1174 } 1175 _ => {} 1176 } 1177 } 1178 } 1179 1180 #[allow(clippy::too_many_arguments)] 1181 fn scan_public_use( 1182 package: &str, 1183 unit: &ModuleUnit, 1184 item_use: &syn::ItemUse, 1185 imports: &BTreeMap<String, Vec<String>>, 1186 forbidden: &BTreeSet<&str>, 1187 allowed: &BTreeSet<&str>, 1188 findings: &mut BTreeSet<ApiFinding>, 1189 ) { 1190 for (segments, _) in flatten_use_tree(&item_use.tree) { 1191 if segments.is_empty() { 1192 continue; 1193 } 1194 let label = format!("{}::pub use", module_label(&unit.module)); 1195 record_path( 1196 package, unit, &label, &segments, imports, forbidden, allowed, findings, 1197 ); 1198 } 1199 } 1200 1201 struct LeakageVisitor<'a> { 1202 package: &'a str, 1203 unit: &'a ModuleUnit, 1204 item: String, 1205 imports: &'a BTreeMap<String, Vec<String>>, 1206 forbidden: &'a BTreeSet<&'a str>, 1207 allowed: &'a BTreeSet<&'a str>, 1208 findings: &'a mut BTreeSet<ApiFinding>, 1209 } 1210 1211 impl<'a> LeakageVisitor<'a> { 1212 #[allow(clippy::too_many_arguments)] 1213 fn new( 1214 package: &'a str, 1215 unit: &'a ModuleUnit, 1216 item: String, 1217 imports: &'a BTreeMap<String, Vec<String>>, 1218 forbidden: &'a BTreeSet<&'a str>, 1219 allowed: &'a BTreeSet<&'a str>, 1220 findings: &'a mut BTreeSet<ApiFinding>, 1221 ) -> Self { 1222 Self { 1223 package, 1224 unit, 1225 item, 1226 imports, 1227 forbidden, 1228 allowed, 1229 findings, 1230 } 1231 } 1232 } 1233 1234 impl<'ast> Visit<'ast> for LeakageVisitor<'_> { 1235 fn visit_path(&mut self, path: &'ast SynPath) { 1236 let segments = path 1237 .segments 1238 .iter() 1239 .map(|segment| segment.ident.to_string()) 1240 .collect::<Vec<_>>(); 1241 record_path( 1242 self.package, 1243 self.unit, 1244 &self.item, 1245 &segments, 1246 self.imports, 1247 self.forbidden, 1248 self.allowed, 1249 self.findings, 1250 ); 1251 syn::visit::visit_path(self, path); 1252 } 1253 } 1254 1255 #[allow(clippy::too_many_arguments)] 1256 fn record_path( 1257 package: &str, 1258 unit: &ModuleUnit, 1259 item: &str, 1260 segments: &[String], 1261 imports: &BTreeMap<String, Vec<String>>, 1262 forbidden: &BTreeSet<&str>, 1263 allowed: &BTreeSet<&str>, 1264 findings: &mut BTreeSet<ApiFinding>, 1265 ) { 1266 if segments.is_empty() { 1267 return; 1268 } 1269 let resolved = if let Some(imported) = imports.get(&segments[0]) { 1270 let mut resolved = imported.clone(); 1271 resolved.extend_from_slice(&segments[1..]); 1272 resolved 1273 } else { 1274 segments.to_vec() 1275 }; 1276 let observed = resolved.join("::"); 1277 for forbidden_path in forbidden { 1278 if allowed.contains(forbidden_path) 1279 || !(observed == *forbidden_path 1280 || observed 1281 .strip_prefix(forbidden_path) 1282 .is_some_and(|suffix| suffix.starts_with("::"))) 1283 { 1284 continue; 1285 } 1286 findings.insert(ApiFinding { 1287 package: package.to_owned(), 1288 source: unit.source_relative.clone(), 1289 item: item.to_owned(), 1290 forbidden_path: (*forbidden_path).to_owned(), 1291 observed_path: observed.clone(), 1292 }); 1293 } 1294 } 1295 1296 fn exception_matches(exceptions: &[ApiException], finding: &ApiFinding) -> bool { 1297 exceptions.iter().any(|exception| { 1298 exception.package == finding.package 1299 && exception.source == finding.source 1300 && exception.forbidden_path == finding.forbidden_path 1301 && exception.items.binary_search(&finding.item).is_ok() 1302 && exception 1303 .observed_paths 1304 .binary_search(&finding.observed_path) 1305 .is_ok() 1306 }) 1307 } 1308 1309 fn format_finding(finding: &ApiFinding) -> String { 1310 format!( 1311 "package={} source={} item={} forbidden_path={} observed_path={}", 1312 finding.package, 1313 finding.source, 1314 finding.item, 1315 finding.forbidden_path, 1316 finding.observed_path 1317 ) 1318 } 1319 1320 fn item_is_public(item: &Item) -> bool { 1321 match item { 1322 Item::Const(item) => is_public(&item.vis), 1323 Item::Enum(item) => is_public(&item.vis), 1324 Item::Fn(item) => is_public(&item.vis), 1325 Item::ForeignMod(_) => true, 1326 Item::Static(item) => is_public(&item.vis), 1327 Item::Struct(item) => is_public(&item.vis), 1328 Item::Trait(item) => is_public(&item.vis), 1329 Item::TraitAlias(item) => is_public(&item.vis), 1330 Item::Type(item) => is_public(&item.vis), 1331 Item::Union(item) => is_public(&item.vis), 1332 _ => false, 1333 } 1334 } 1335 1336 fn item_name(item: &Item) -> Option<String> { 1337 match item { 1338 Item::Const(item) => Some(item.ident.to_string()), 1339 Item::Enum(item) => Some(item.ident.to_string()), 1340 Item::Fn(item) => Some(item.sig.ident.to_string()), 1341 Item::Static(item) => Some(item.ident.to_string()), 1342 Item::Struct(item) => Some(item.ident.to_string()), 1343 Item::Trait(item) => Some(item.ident.to_string()), 1344 Item::TraitAlias(item) => Some(item.ident.to_string()), 1345 Item::Type(item) => Some(item.ident.to_string()), 1346 Item::Union(item) => Some(item.ident.to_string()), 1347 _ => None, 1348 } 1349 } 1350 1351 fn impl_self_name(self_ty: &Type) -> Option<String> { 1352 let Type::Path(path) = self_ty else { 1353 return None; 1354 }; 1355 path.path 1356 .segments 1357 .last() 1358 .map(|segment| segment.ident.to_string()) 1359 } 1360 1361 fn is_public(visibility: &Visibility) -> bool { 1362 matches!(visibility, Visibility::Public(_)) 1363 } 1364 1365 fn qualified_item(module: &[String], item: &str) -> String { 1366 if module.is_empty() { 1367 item.to_owned() 1368 } else { 1369 format!("{}::{item}", module.join("::")) 1370 } 1371 } 1372 1373 fn module_label(module: &[String]) -> String { 1374 if module.is_empty() { 1375 "crate".to_owned() 1376 } else { 1377 module.join("::") 1378 } 1379 } 1380 1381 #[cfg(test)] 1382 mod tests { 1383 use super::{ 1384 API_DECISION_RELATIVE, ApiBoundaryPolicy, ApiException, CargoMetadata, scan_workspace, 1385 validate_policy, 1386 }; 1387 use serde::Deserialize; 1388 use std::{collections::BTreeSet, fs}; 1389 1390 const POLICY: &str = include_str!("../../../../contracts/releases/api_boundaries.toml"); 1391 const DECISION: &str = include_str!( 1392 "../../../../contracts/architecture/decisions/public_api_leakage_migration_baseline.v1.toml" 1393 ); 1394 const ARCHITECTURE: &str = 1395 include_str!("../../../../contracts/crates/release_v1/radroots_crates_release_v1.toml"); 1396 const GENERIC_SQLX: &str = include_str!("../../tests/fixtures/api-leakage/generic-sqlx.rs"); 1397 const GENERIC_RENAMED_TOKIO: &str = 1398 include_str!("../../tests/fixtures/api-leakage/generic-renamed-tokio.rs"); 1399 const ALLOWED_ADAPTER: &str = 1400 include_str!("../../tests/fixtures/api-leakage/allowed-concrete-adapter.rs"); 1401 const PRIVATE_IMPLEMENTATION: &str = 1402 include_str!("../../tests/fixtures/api-leakage/private-implementation.rs"); 1403 #[derive(Deserialize)] 1404 struct ArchitectureCatalog { 1405 package: Vec<ArchitecturePackage>, 1406 } 1407 1408 #[derive(Deserialize)] 1409 struct ArchitecturePackage { 1410 name: String, 1411 } 1412 1413 fn policy() -> ApiBoundaryPolicy { 1414 toml::from_str(POLICY).expect("API boundary policy") 1415 } 1416 1417 fn expected_packages() -> BTreeSet<String> { 1418 toml::from_str::<ArchitectureCatalog>(ARCHITECTURE) 1419 .expect("architecture") 1420 .package 1421 .into_iter() 1422 .map(|package| package.name) 1423 .collect() 1424 } 1425 1426 fn fixture_metadata(root: &std::path::Path, package: &str, source: &str) -> CargoMetadata { 1427 let package_root = root.join(package); 1428 fs::create_dir_all(package_root.join("src")).expect("create fixture source"); 1429 fs::write( 1430 package_root.join("Cargo.toml"), 1431 "[package]\nname='fixture'\n", 1432 ) 1433 .expect("write fixture manifest"); 1434 fs::write(package_root.join("src/lib.rs"), source).expect("write fixture source"); 1435 let id = format!("fixture#{package}@0.1.0"); 1436 CargoMetadata { 1437 workspace_members: vec![id.clone()], 1438 packages: vec![super::CargoPackage { 1439 id, 1440 name: package.to_owned(), 1441 manifest_path: package_root 1442 .join("Cargo.toml") 1443 .to_string_lossy() 1444 .into_owned(), 1445 targets: vec![super::CargoTarget { 1446 kind: vec!["lib".to_owned()], 1447 src_path: package_root 1448 .join("src/lib.rs") 1449 .to_string_lossy() 1450 .into_owned(), 1451 }], 1452 }], 1453 } 1454 } 1455 1456 fn scan_fixture(package: &str, source: &str) -> Vec<super::ApiFinding> { 1457 let root = tempfile::TempDir::new().expect("fixture root"); 1458 scan_workspace( 1459 root.path(), 1460 &policy(), 1461 &fixture_metadata(root.path(), package, source), 1462 ) 1463 .expect("scan fixture") 1464 } 1465 1466 fn write_baseline_decision(root: &std::path::Path) { 1467 fs::create_dir_all(root.join("contracts/architecture/decisions")) 1468 .expect("baseline decision directory"); 1469 fs::write( 1470 root.join( 1471 "contracts/architecture/decisions/public_api_leakage_migration_baseline.v1.toml", 1472 ), 1473 DECISION, 1474 ) 1475 .expect("baseline decision"); 1476 } 1477 1478 #[test] 1479 fn policy_covers_exact_architecture_catalog() { 1480 let root = tempfile::TempDir::new().expect("policy root"); 1481 write_baseline_decision(root.path()); 1482 validate_policy(root.path(), &policy(), &expected_packages()) 1483 .expect("policy without exceptions"); 1484 } 1485 1486 #[test] 1487 fn generic_sqlx_public_field_is_forbidden() { 1488 let findings = scan_fixture("radroots_storage", GENERIC_SQLX); 1489 assert_eq!(findings.len(), 1); 1490 assert_eq!(findings[0].item, "StorageHandle"); 1491 assert_eq!(findings[0].forbidden_path, "sqlx"); 1492 assert_eq!(findings[0].observed_path, "sqlx::SqlitePool"); 1493 } 1494 1495 #[test] 1496 fn renamed_tokio_return_type_is_resolved() { 1497 let findings = scan_fixture("radroots_sync", GENERIC_RENAMED_TOKIO); 1498 assert_eq!(findings.len(), 1); 1499 assert_eq!(findings[0].item, "executor"); 1500 assert_eq!(findings[0].forbidden_path, "tokio"); 1501 assert_eq!(findings[0].observed_path, "tokio::runtime::Handle"); 1502 } 1503 1504 #[test] 1505 fn specified_nostr_adapter_may_expose_protocol_types() { 1506 let findings = scan_fixture("radroots_nostr", ALLOWED_ADAPTER); 1507 assert!(findings.is_empty()); 1508 } 1509 1510 #[test] 1511 fn private_implementation_types_are_not_public_api() { 1512 let findings = scan_fixture("radroots_sdk", PRIVATE_IMPLEMENTATION); 1513 assert!(findings.is_empty()); 1514 } 1515 1516 #[test] 1517 fn invented_exception_is_rejected_by_the_completed_decision() { 1518 let root = tempfile::TempDir::new().expect("exception root"); 1519 write_baseline_decision(root.path()); 1520 let mut policy = policy(); 1521 policy.exception.push(ApiException { 1522 id: "RCRV1-API-999".to_owned(), 1523 package: "radroots_sdk".to_owned(), 1524 source: "src/lib.rs".to_owned(), 1525 forbidden_path: "reqwest".to_owned(), 1526 items: vec!["temporary_client".to_owned()], 1527 observed_paths: vec!["reqwest::Client".to_owned()], 1528 decision: API_DECISION_RELATIVE.to_owned(), 1529 removal_step: 400, 1530 rationale: "test-only exception".to_owned(), 1531 }); 1532 let error = validate_policy(root.path(), &policy, &expected_packages()) 1533 .expect_err("invented exception must fail"); 1534 assert!(error.contains("is not active")); 1535 } 1536 1537 #[test] 1538 fn expired_historical_exception_cannot_return() { 1539 let root = tempfile::TempDir::new().expect("expired exception root"); 1540 write_baseline_decision(root.path()); 1541 let mut policy = policy(); 1542 policy.exception.push(ApiException { 1543 id: "RCRV1-API-006".to_owned(), 1544 package: "radroots_nostr_connect".to_owned(), 1545 source: "src/client.rs".to_owned(), 1546 forbidden_path: "nostr".to_owned(), 1547 items: vec!["client::RadrootsNostrConnectClientTarget".to_owned()], 1548 observed_paths: vec!["nostr::PublicKey".to_owned()], 1549 decision: API_DECISION_RELATIVE.to_owned(), 1550 removal_step: 313, 1551 rationale: "retired historical exception".to_owned(), 1552 }); 1553 let error = validate_policy(root.path(), &policy, &expected_packages()) 1554 .expect_err("expired historical exception must fail"); 1555 assert!(error.contains("expired at Step 313")); 1556 } 1557 1558 #[test] 1559 fn malformed_or_publication_authorizing_decision_fails_closed() { 1560 let root = tempfile::TempDir::new().expect("decision validation root"); 1561 write_baseline_decision(root.path()); 1562 let decision_path = root.path().join(API_DECISION_RELATIVE); 1563 fs::write(&decision_path, "not valid toml = [\n").expect("malformed decision"); 1564 let malformed = validate_policy(root.path(), &policy(), &expected_packages()) 1565 .expect_err("malformed decision must fail"); 1566 assert!(malformed.contains("parse")); 1567 1568 fs::write( 1569 &decision_path, 1570 DECISION.replace( 1571 "publication_authorized = false", 1572 "publication_authorized = true", 1573 ), 1574 ) 1575 .expect("publication-authorizing decision"); 1576 let authorized = validate_policy(root.path(), &policy(), &expected_packages()) 1577 .expect_err("publication authorization must fail"); 1578 assert!(authorized.contains("publication policy drifted")); 1579 } 1580 }