lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }