lib

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

hygiene.rs (92062B)


      1 use serde::Deserialize;
      2 use std::collections::HashSet;
      3 use std::fs;
      4 use std::io::Read;
      5 use std::path::{Path, PathBuf};
      6 use std::process::{Command, Stdio};
      7 
      8 const PROTOTYPE_CONTRACT_CONFIG_PATH: &str = "contracts/hygiene/prototype-contracts.v1.toml";
      9 const PROTOTYPE_CONTRACT_SCHEMA: &str = "radroots.prototype-contract-source-guard.v1";
     10 const PROTOTYPE_MAX_CONFIG_BYTES: u64 = 1024 * 1024;
     11 const PROTOTYPE_MAX_CONFIG_STRING_BYTES: usize = 1024;
     12 const PROTOTYPE_MAX_CONFIG_PATHS: usize = 256;
     13 const PROTOTYPE_MAX_CONFIG_EXTENSIONS: usize = 128;
     14 const PROTOTYPE_MAX_CONFIG_PATTERNS: usize = 1024;
     15 const PROTOTYPE_MAX_CONFIG_ALLOWLIST: usize = 4096;
     16 const PROTOTYPE_MAX_REASON_BYTES: usize = 512;
     17 const PROTOTYPE_MAX_CONFIGURED_SCAN_ENTRIES: usize = 100_000;
     18 const PROTOTYPE_MAX_CONFIGURED_INVENTORY_BYTES: usize = 64 * 1024 * 1024;
     19 const PROTOTYPE_MAX_CONFIGURED_FILE_BYTES: u64 = 64 * 1024 * 1024;
     20 const PROTOTYPE_MAX_CONFIGURED_MATCHES: usize = 100_000;
     21 const PROTOTYPE_MAX_CONFIGURED_REPORT_LINES: usize = 10_000;
     22 const PROTOTYPE_MAX_GIT_STDERR_BYTES: usize = 8 * 1024;
     23 
     24 #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
     25 #[serde(rename_all = "snake_case")]
     26 enum PrototypeGuardMode {
     27     ReportOnly,
     28     Strict,
     29 }
     30 
     31 impl PrototypeGuardMode {
     32     const fn as_str(self) -> &'static str {
     33         match self {
     34             Self::ReportOnly => "report_only",
     35             Self::Strict => "strict",
     36         }
     37     }
     38 }
     39 
     40 #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
     41 #[serde(rename_all = "snake_case")]
     42 enum PrototypeMatchKind {
     43     Substring,
     44     WordPrefix,
     45 }
     46 
     47 #[derive(Debug, Deserialize)]
     48 #[serde(deny_unknown_fields)]
     49 struct PrototypeGuardConfig {
     50     schema: String,
     51     mode: PrototypeGuardMode,
     52     scan: PrototypeScanConfig,
     53     limits: PrototypeGuardLimits,
     54     pattern: Vec<PrototypePattern>,
     55     #[serde(default)]
     56     allow: Vec<PrototypeAllow>,
     57 }
     58 
     59 #[derive(Debug, Deserialize)]
     60 #[serde(deny_unknown_fields)]
     61 struct PrototypeScanConfig {
     62     roots: Vec<String>,
     63     path_roots: Vec<String>,
     64     path_excludes: Vec<String>,
     65     extensions: Vec<String>,
     66     extensionless_names: Vec<String>,
     67 }
     68 
     69 #[derive(Debug, Deserialize)]
     70 #[serde(deny_unknown_fields)]
     71 struct PrototypeGuardLimits {
     72     max_scan_entries: usize,
     73     max_inventory_bytes: usize,
     74     max_file_bytes: u64,
     75     max_matches: usize,
     76     max_reported_findings: usize,
     77     max_reported_allowlisted: usize,
     78 }
     79 
     80 #[derive(Debug, Deserialize)]
     81 #[serde(deny_unknown_fields)]
     82 struct PrototypePattern {
     83     id: String,
     84     needle: String,
     85     match_kind: PrototypeMatchKind,
     86     description: String,
     87     #[serde(default)]
     88     match_path: bool,
     89     #[serde(default)]
     90     path_prefixes: Vec<String>,
     91 }
     92 
     93 #[derive(Debug, Deserialize)]
     94 #[serde(deny_unknown_fields)]
     95 struct PrototypeAllow {
     96     pattern_id: String,
     97     path: String,
     98     line_contains: String,
     99     reason: String,
    100 }
    101 
    102 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
    103 enum PrototypeFindingOrigin {
    104     Path,
    105     Content,
    106 }
    107 
    108 #[derive(Clone, Debug, Eq, PartialEq)]
    109 struct PrototypeFinding {
    110     pattern_id: String,
    111     path: String,
    112     origin: PrototypeFindingOrigin,
    113     line: Option<usize>,
    114     excerpt: String,
    115 }
    116 
    117 #[derive(Clone, Debug, Eq, PartialEq)]
    118 struct PrototypeAllowedMatch {
    119     finding: PrototypeFinding,
    120     reason: String,
    121 }
    122 
    123 #[derive(Debug, Default, Eq, PartialEq)]
    124 struct PrototypeGuardReport {
    125     findings: Vec<PrototypeFinding>,
    126     allowed: Vec<PrototypeAllowedMatch>,
    127 }
    128 
    129 const BINDING_DEPENDENCIES: &[&str] = &[
    130     "serde-wasm-bindgen",
    131     "ts-rs",
    132     "typeshare",
    133     "uniffi",
    134     "uniffi-build",
    135     "uniffi_build",
    136     "wasm-bindgen",
    137     "wasm-bindgen-futures",
    138     "wasm-bindgen-test",
    139 ];
    140 
    141 const RETIRED_PROTOCOL_EVENT_SURFACE_PATTERNS: &[&str] = &[
    142     "KIND_LISTING_DRAFT",
    143     "KIND_CLASSIFIED_LISTING_DRAFT",
    144     "KIND_OPERATIONAL_LISTING_DRAFT",
    145     "KIND_ORDER_REVISION",
    146     "KIND_TRADE_QUESTION",
    147     "KIND_TRADE_ANSWER",
    148     "KIND_TRADE_DISCOUNT_REQUEST",
    149     "KIND_TRADE_DISCOUNT_OFFER",
    150     "KIND_TRADE_DISCOUNT_ACCEPT",
    151     "KIND_TRADE_FULFILLMENT_UPDATE",
    152     "KIND_TRADE_RECEIPT",
    153     "KIND_TRADE_LISTING_VALIDATION_REQUEST",
    154     "KIND_TRADE_LISTING_VALIDATION_RESULT",
    155     "KIND_TRADE_TRANSITION_PROOF_REQUEST",
    156     "KIND_TRADE_TRANSITION_PROOF_RESULT",
    157     "RADROOTS_SP1_TRADE_KIND_LISTING_DRAFT",
    158     "RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING_DRAFT",
    159     "RADROOTS_SP1_TRADE_KIND_OPERATIONAL_LISTING_DRAFT",
    160     "RadrootsListingDraft",
    161     "RadrootsClassifiedListingDraft",
    162     "RadrootsOperationalListingDraft",
    163     "RadrootsCanonicalListingDraft",
    164     "RadrootsListingDraftError",
    165     "RadrootsClassifiedListingDraftError",
    166     "RadrootsOperationalListingDraftError",
    167     "OrderRevision",
    168     "RadrootsOrderRevisionId",
    169     "TradeValidationListingRequest",
    170     "TradeValidationListingResult",
    171     "ListingValidationRequest",
    172     "ListingValidationResult",
    173     "TransitionProof",
    174     "TradeQuestion",
    175     "TradeAnswer",
    176     "TradeFulfillmentUpdated",
    177     "TradeReceipt",
    178     "canonicalize_listing_draft",
    179     "listing_draft",
    180     "order_revision",
    181     "pending_revision_event_id",
    182     "trade_answer",
    183     "trade_discount_accept",
    184     "trade_discount_offer",
    185     "trade_discount_request",
    186     "trade_fulfillment_update",
    187     "trade_listing_validation_request",
    188     "trade_listing_validation_result",
    189     "trade_order_revision_decision",
    190     "trade_order_revision_proposal",
    191     "trade_question",
    192     "trade_receipt",
    193     "trade_transition_proof_request",
    194     "trade_transition_proof_result",
    195 ];
    196 
    197 const RETIRED_PROTOCOL_EVENT_SURFACE_ALLOWED_PATHS: &[&str] = &[
    198     "crates/event/src/dto.rs",
    199     "crates/protocol_contract_v1/src/lib.rs",
    200     "tools/xtask/src/hygiene.rs",
    201 ];
    202 
    203 const RETIRED_LISTING_ALIAS_IDENTIFIER_TOKENS: &[&str] = &[
    204     "KIND_LISTING",
    205     "LISTING_EVENT_KINDS",
    206     "is_listing_kind",
    207     "is_listing_event_kind",
    208     "RADROOTS_SP1_TRADE_KIND_LISTING",
    209     "ListingTagOptions",
    210     "ListingDecodeError",
    211     "ListingParseError",
    212     "ListingProjection",
    213     "ListingInventoryAccounting",
    214     "ListingAddress",
    215     "ListingSnapshot",
    216     "RADROOTS_LISTING_PRODUCT_TAG_KEYS",
    217     "RadrootsCanonicalListingEdit",
    218     "RadrootsPublicListingAddress",
    219     "RadrootsPublicListingAddressError",
    220     "RadrootsTradeListing",
    221     "RadrootsTradeListingSubtotal",
    222     "RadrootsTradeListingTotal",
    223     "RadrootsTradeValidationListingError",
    224     "farm_listings_list_set",
    225     "farm_listings_list_set_from_listings",
    226     "listing_from_event",
    227     "listing_from_event_parts",
    228     "listing_from_nostr_event",
    229     "parse_listing_address",
    230     "parse_listing_address_str",
    231     "parse_listing_event",
    232     "parse_public_listing_address",
    233     "search_listing_projection",
    234     "to_json_wire_parts_with_kind",
    235     "validate_listing_event",
    236     "listing_tags",
    237     "listing_tags_with_options",
    238     "listing_tags_full",
    239     "listing_build_tags",
    240     "decode_listing_from_event_parts",
    241     "listing_markdown_content",
    242     "build_listing_mutation_draft",
    243     "canonicalize_listing_edit",
    244     "reduce_listing_inventory_accounting",
    245 ];
    246 
    247 const RETIRED_LISTING_ALIAS_IDENTIFIER_PREFIXES: &[&str] = &["RadrootsListing"];
    248 const RETIRED_LISTING_CONTRACT_ID: &str = "radroots.listing.published.v1";
    249 
    250 pub fn run(args: &[String], root: &Path) -> Result<(), String> {
    251     match args.first().map(String::as_str) {
    252         Some("forbidden-identifiers") => validate_forbidden_identifiers(root),
    253         Some("prototype-contracts") => run_prototype_contract_guard(&args[1..], root),
    254         _ => Err("unknown hygiene subcommand".to_string()),
    255     }
    256 }
    257 
    258 fn run_prototype_contract_guard(args: &[String], root: &Path) -> Result<(), String> {
    259     let (config_path, mode_override) = parse_prototype_guard_args(args)?;
    260     let config = load_prototype_guard_config(root, &config_path)?;
    261     let mode = mode_override.unwrap_or(config.mode);
    262     let report = scan_prototype_contracts(root, &config)?;
    263     print_prototype_guard_report(mode, &report, &config.limits);
    264     if mode == PrototypeGuardMode::Strict && !report.findings.is_empty() {
    265         return Err(format!(
    266             "prototype contract source guard found {} non-allowlisted match(es)",
    267             report.findings.len()
    268         ));
    269     }
    270     Ok(())
    271 }
    272 
    273 fn parse_prototype_guard_args(
    274     args: &[String],
    275 ) -> Result<(PathBuf, Option<PrototypeGuardMode>), String> {
    276     let mut config_path = PathBuf::from(PROTOTYPE_CONTRACT_CONFIG_PATH);
    277     let mut mode = None;
    278     let mut index = 0;
    279     while index < args.len() {
    280         match args[index].as_str() {
    281             "--config" => {
    282                 let Some(value) = args.get(index + 1) else {
    283                     return Err("prototype-contracts --config requires a path".to_string());
    284                 };
    285                 validate_repo_relative_path(value, "prototype guard config path")?;
    286                 config_path = PathBuf::from(value);
    287                 index += 2;
    288             }
    289             "--strict" => {
    290                 set_prototype_mode(&mut mode, PrototypeGuardMode::Strict)?;
    291                 index += 1;
    292             }
    293             "--report-only" => {
    294                 set_prototype_mode(&mut mode, PrototypeGuardMode::ReportOnly)?;
    295                 index += 1;
    296             }
    297             value => return Err(format!("unknown prototype-contracts argument: {value}")),
    298         }
    299     }
    300     Ok((config_path, mode))
    301 }
    302 
    303 fn set_prototype_mode(
    304     current: &mut Option<PrototypeGuardMode>,
    305     requested: PrototypeGuardMode,
    306 ) -> Result<(), String> {
    307     if current.replace(requested).is_some() {
    308         return Err("prototype-contracts accepts only one mode override".to_string());
    309     }
    310     Ok(())
    311 }
    312 
    313 fn load_prototype_guard_config(
    314     root: &Path,
    315     relative_path: &Path,
    316 ) -> Result<PrototypeGuardConfig, String> {
    317     validate_repo_relative_path(
    318         &relative_path.to_string_lossy(),
    319         "prototype guard config path",
    320     )?;
    321     let path = root.join(relative_path);
    322     reject_symlinked_path_components(root, relative_path, "prototype guard config path")?;
    323     let metadata = fs::symlink_metadata(&path)
    324         .map_err(|error| format!("inspect prototype guard config {}: {error}", path.display()))?;
    325     if metadata.file_type().is_symlink() || !metadata.is_file() {
    326         return Err(format!(
    327             "prototype guard config must be a regular non-symlink file: {}",
    328             path.display()
    329         ));
    330     }
    331     let display = path.display().to_string();
    332     let source = read_bounded_prototype_input(&path, &display, PROTOTYPE_MAX_CONFIG_BYTES)?;
    333     let config: PrototypeGuardConfig = toml::from_str(&source)
    334         .map_err(|error| format!("parse prototype guard config {}: {error}", path.display()))?;
    335     validate_prototype_guard_config(&config)?;
    336     Ok(config)
    337 }
    338 
    339 fn validate_prototype_guard_config(config: &PrototypeGuardConfig) -> Result<(), String> {
    340     if config.schema != PROTOTYPE_CONTRACT_SCHEMA {
    341         return Err(format!(
    342             "prototype guard schema must be {PROTOTYPE_CONTRACT_SCHEMA}"
    343         ));
    344     }
    345     if config.scan.roots.is_empty() {
    346         return Err("prototype guard scan roots must not be empty".to_string());
    347     }
    348     if config.scan.path_roots.is_empty() {
    349         return Err("prototype guard path scan roots must not be empty".to_string());
    350     }
    351     if config.scan.extensions.is_empty() {
    352         return Err("prototype guard extensions must not be empty".to_string());
    353     }
    354     if config.limits.max_scan_entries == 0
    355         || config.limits.max_inventory_bytes == 0
    356         || config.limits.max_file_bytes == 0
    357         || config.limits.max_matches == 0
    358         || config.limits.max_reported_findings == 0
    359         || config.limits.max_reported_allowlisted == 0
    360         || config.limits.max_reported_findings > config.limits.max_matches
    361         || config.limits.max_reported_allowlisted > config.limits.max_matches
    362         || config.limits.max_scan_entries > PROTOTYPE_MAX_CONFIGURED_SCAN_ENTRIES
    363         || config.limits.max_inventory_bytes > PROTOTYPE_MAX_CONFIGURED_INVENTORY_BYTES
    364         || config.limits.max_file_bytes > PROTOTYPE_MAX_CONFIGURED_FILE_BYTES
    365         || config.limits.max_matches > PROTOTYPE_MAX_CONFIGURED_MATCHES
    366         || config.limits.max_reported_findings > PROTOTYPE_MAX_CONFIGURED_REPORT_LINES
    367         || config.limits.max_reported_allowlisted > PROTOTYPE_MAX_CONFIGURED_REPORT_LINES
    368     {
    369         return Err(
    370             "prototype guard limits must be positive, report limits must not exceed max_matches, and every value must remain within the compiled resource ceiling"
    371                 .to_string(),
    372         );
    373     }
    374     if config.pattern.is_empty() {
    375         return Err("prototype guard patterns must not be empty".to_string());
    376     }
    377     if config.scan.roots.len() > PROTOTYPE_MAX_CONFIG_PATHS
    378         || config.scan.path_roots.len() > PROTOTYPE_MAX_CONFIG_PATHS
    379         || config.scan.path_excludes.len() > PROTOTYPE_MAX_CONFIG_PATHS
    380         || config.scan.extensions.len() > PROTOTYPE_MAX_CONFIG_EXTENSIONS
    381         || config.scan.extensionless_names.len() > PROTOTYPE_MAX_CONFIG_EXTENSIONS
    382         || config.pattern.len() > PROTOTYPE_MAX_CONFIG_PATTERNS
    383         || config.allow.len() > PROTOTYPE_MAX_CONFIG_ALLOWLIST
    384     {
    385         return Err("prototype guard configuration collection exceeds compiled limit".to_string());
    386     }
    387 
    388     let mut roots = HashSet::new();
    389     for root in &config.scan.roots {
    390         validate_repo_relative_path(root, "prototype guard scan root")?;
    391         if !roots.insert(root.as_str()) {
    392             return Err(format!("duplicate prototype guard scan root: {root}"));
    393         }
    394     }
    395 
    396     let mut path_roots = HashSet::new();
    397     for root in &config.scan.path_roots {
    398         validate_repo_relative_or_root_path(root, "prototype guard path scan root")?;
    399         if !path_roots.insert(root.as_str()) {
    400             return Err(format!("duplicate prototype guard path scan root: {root}"));
    401         }
    402     }
    403 
    404     let mut path_excludes = HashSet::new();
    405     for excluded in &config.scan.path_excludes {
    406         validate_repo_relative_path(excluded, "prototype guard path exclusion")?;
    407         if !config
    408             .scan
    409             .path_roots
    410             .iter()
    411             .any(|root| root == "." || repository_path_is_within(excluded, root))
    412         {
    413             return Err(format!(
    414                 "prototype guard path exclusion is outside path scan roots: {excluded}"
    415             ));
    416         }
    417         if !path_excludes.insert(excluded.as_str()) {
    418             return Err(format!(
    419                 "duplicate prototype guard path exclusion: {excluded}"
    420             ));
    421         }
    422     }
    423 
    424     let mut extensions = HashSet::new();
    425     for extension in &config.scan.extensions {
    426         if extension.is_empty()
    427             || extension.len() > 32
    428             || extension.starts_with('.')
    429             || !extension
    430                 .chars()
    431                 .all(|character| character.is_ascii_alphanumeric())
    432         {
    433             return Err(format!(
    434                 "invalid prototype guard extension (omit the dot): {extension:?}"
    435             ));
    436         }
    437         if !extensions.insert(extension.as_str()) {
    438             return Err(format!("duplicate prototype guard extension: {extension}"));
    439         }
    440     }
    441 
    442     let mut extensionless_names = HashSet::new();
    443     for name in &config.scan.extensionless_names {
    444         if name.is_empty()
    445             || name.len() > 128
    446             || !name
    447                 .chars()
    448                 .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
    449         {
    450             return Err(format!(
    451                 "invalid prototype guard extensionless file name: {name:?}"
    452             ));
    453         }
    454         if !extensionless_names.insert(name.as_str()) {
    455             return Err(format!(
    456                 "duplicate prototype guard extensionless file name: {name}"
    457             ));
    458         }
    459     }
    460 
    461     let mut pattern_ids = HashSet::new();
    462     for pattern in &config.pattern {
    463         if pattern.id.is_empty()
    464             || pattern.id.len() > 64
    465             || !pattern.id.chars().all(|character| {
    466                 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
    467             })
    468         {
    469             return Err(format!(
    470                 "invalid prototype guard pattern id: {:?}",
    471                 pattern.id
    472             ));
    473         }
    474         if !pattern_ids.insert(pattern.id.as_str()) {
    475             return Err(format!(
    476                 "duplicate prototype guard pattern id: {}",
    477                 pattern.id
    478             ));
    479         }
    480         if pattern.needle.is_empty()
    481             || pattern.needle.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES
    482             || pattern.needle.chars().any(char::is_control)
    483         {
    484             return Err(format!(
    485                 "prototype guard pattern {} has an invalid needle",
    486                 pattern.id
    487             ));
    488         }
    489         if pattern.match_kind == PrototypeMatchKind::WordPrefix && !pattern.needle.is_ascii() {
    490             return Err(format!(
    491                 "prototype guard word-prefix pattern {} must use an ASCII needle",
    492                 pattern.id
    493             ));
    494         }
    495         if pattern.description.trim().is_empty()
    496             || pattern.description.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES
    497             || pattern.description.chars().any(char::is_control)
    498         {
    499             return Err(format!(
    500                 "prototype guard pattern {} requires a description",
    501                 pattern.id
    502             ));
    503         }
    504         let mut prefixes = HashSet::new();
    505         for prefix in &pattern.path_prefixes {
    506             validate_repo_relative_path(prefix, "prototype guard pattern path prefix")?;
    507             if !config
    508                 .scan
    509                 .roots
    510                 .iter()
    511                 .any(|root| repository_path_is_within(prefix, root))
    512             {
    513                 return Err(format!(
    514                     "prototype guard pattern {} path prefix is outside scan roots: {prefix}",
    515                     pattern.id
    516                 ));
    517             }
    518             if !prefixes.insert(prefix.as_str()) {
    519                 return Err(format!(
    520                     "duplicate path prefix for prototype guard pattern {}: {prefix}",
    521                     pattern.id
    522                 ));
    523             }
    524         }
    525     }
    526 
    527     let mut allow_keys = HashSet::new();
    528     for allowed in &config.allow {
    529         if !pattern_ids.contains(allowed.pattern_id.as_str()) {
    530             return Err(format!(
    531                 "prototype guard allowlist references unknown pattern: {:?}",
    532                 allowed.pattern_id
    533             ));
    534         }
    535         validate_repo_relative_path(&allowed.path, "prototype guard allowlist path")?;
    536         if !config
    537             .scan
    538             .roots
    539             .iter()
    540             .any(|root| repository_path_is_within(&allowed.path, root))
    541         {
    542             return Err(format!(
    543                 "prototype guard allowlist path is outside scan roots: {}",
    544                 allowed.path
    545             ));
    546         }
    547         let pattern = config
    548             .pattern
    549             .iter()
    550             .find(|pattern| pattern.id == allowed.pattern_id)
    551             .expect("validated pattern id must resolve");
    552         if !pattern.path_prefixes.is_empty()
    553             && !pattern
    554                 .path_prefixes
    555                 .iter()
    556                 .any(|prefix| repository_path_is_within(&allowed.path, prefix))
    557         {
    558             return Err(format!(
    559                 "prototype guard allowlist path {} is outside pattern {} path prefixes",
    560                 allowed.path, allowed.pattern_id
    561             ));
    562         }
    563         if allowed.line_contains.is_empty()
    564             || allowed.line_contains.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES
    565             || allowed.line_contains.chars().any(char::is_control)
    566         {
    567             return Err(format!(
    568                 "prototype guard allowlist for {} requires one line fragment",
    569                 allowed.pattern_id
    570             ));
    571         }
    572         if allowed.reason.trim().is_empty()
    573             || allowed.reason.len() > PROTOTYPE_MAX_REASON_BYTES
    574             || allowed.reason.chars().any(char::is_control)
    575         {
    576             return Err(format!(
    577                 "prototype guard allowlist for {} requires a reason",
    578                 allowed.pattern_id
    579             ));
    580         }
    581         let key = (
    582             allowed.pattern_id.as_str(),
    583             allowed.path.as_str(),
    584             allowed.line_contains.as_str(),
    585         );
    586         if !allow_keys.insert(key) {
    587             return Err(format!(
    588                 "duplicate prototype guard allowlist entry: {} {}",
    589                 allowed.pattern_id, allowed.path
    590             ));
    591         }
    592     }
    593     Ok(())
    594 }
    595 
    596 fn validate_repo_relative_path(value: &str, label: &str) -> Result<(), String> {
    597     let path = Path::new(value);
    598     if value.is_empty()
    599         || value.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES
    600         || value.chars().any(char::is_control)
    601         || value.contains('\\')
    602         || value.contains(':')
    603         || path.is_absolute()
    604         || path
    605             .components()
    606             .any(|component| !matches!(component, std::path::Component::Normal(_)))
    607     {
    608         return Err(format!(
    609             "{label} must be a normalized repository-relative path: {value:?}"
    610         ));
    611     }
    612     Ok(())
    613 }
    614 
    615 fn validate_repo_relative_or_root_path(value: &str, label: &str) -> Result<(), String> {
    616     if value == "." {
    617         return Ok(());
    618     }
    619     validate_repo_relative_path(value, label)
    620 }
    621 
    622 fn reject_symlinked_path_components(
    623     root: &Path,
    624     relative_path: &Path,
    625     label: &str,
    626 ) -> Result<(), String> {
    627     let mut candidate = root.to_path_buf();
    628     for component in relative_path.components() {
    629         let std::path::Component::Normal(component) = component else {
    630             return Err(format!(
    631                 "{label} must contain only normalized path components: {}",
    632                 relative_path.display()
    633             ));
    634         };
    635         candidate.push(component);
    636         let metadata = fs::symlink_metadata(&candidate).map_err(|error| {
    637             format!("inspect {label} component {}: {error}", candidate.display())
    638         })?;
    639         if metadata.file_type().is_symlink() {
    640             return Err(format!(
    641                 "{label} must not contain a symlinked component: {}",
    642                 candidate.display()
    643             ));
    644         }
    645     }
    646     Ok(())
    647 }
    648 
    649 fn repository_path_is_within(path: &str, prefix: &str) -> bool {
    650     path == prefix
    651         || path
    652             .strip_prefix(prefix)
    653             .is_some_and(|rest| rest.starts_with('/'))
    654 }
    655 
    656 fn scan_prototype_contracts(
    657     root: &Path,
    658     config: &PrototypeGuardConfig,
    659 ) -> Result<PrototypeGuardReport, String> {
    660     let extensions: HashSet<&str> = config.scan.extensions.iter().map(String::as_str).collect();
    661     let extensionless_names: HashSet<&str> = config
    662         .scan
    663         .extensionless_names
    664         .iter()
    665         .map(String::as_str)
    666         .collect();
    667     let mut inputs = PrototypeInputs::default();
    668     for relative_root in &config.scan.roots {
    669         reject_symlinked_path_components(
    670             root,
    671             Path::new(relative_root),
    672             "prototype guard scan root",
    673         )?;
    674     }
    675     for relative_root in &config.scan.path_roots {
    676         let relative_path = Path::new(relative_root);
    677         if relative_root != "." {
    678             reject_symlinked_path_components(
    679                 root,
    680                 relative_path,
    681                 "prototype guard path scan root",
    682             )?;
    683         }
    684     }
    685     if let Some(governed_paths) = git_governed_paths(
    686         root,
    687         config.limits.max_scan_entries,
    688         config.limits.max_inventory_bytes,
    689     )? {
    690         for candidate in governed_paths {
    691             let relative = prototype_display_path(root, &candidate)?;
    692             if path_is_excluded(&relative, &config.scan.path_excludes) {
    693                 continue;
    694             }
    695             if path_is_within_any_root(&relative, &config.scan.path_roots) {
    696                 inputs.paths.push(candidate.clone());
    697             }
    698             if path_is_within_any_root(&relative, &config.scan.roots)
    699                 && is_prototype_text_input(&candidate, &extensions, &extensionless_names)
    700             {
    701                 inputs.files.push(candidate);
    702             }
    703         }
    704     } else {
    705         for relative_root in &config.scan.roots {
    706             collect_prototype_inputs(
    707                 root,
    708                 &root.join(relative_root),
    709                 &extensions,
    710                 &extensionless_names,
    711                 &config.scan.path_excludes,
    712                 config.limits.max_scan_entries,
    713                 &mut inputs,
    714             )?;
    715         }
    716         for relative_root in &config.scan.path_roots {
    717             collect_prototype_paths(
    718                 root,
    719                 &root.join(relative_root),
    720                 &config.scan.path_excludes,
    721                 config.limits.max_scan_entries,
    722                 &mut inputs.paths,
    723             )?;
    724         }
    725     }
    726     inputs.files.sort();
    727     inputs.files.dedup();
    728     inputs.paths.sort();
    729     inputs.paths.dedup();
    730     if inputs.paths.len() > config.limits.max_scan_entries {
    731         return Err(format!(
    732             "prototype guard scan contains {} entries, limit is {}",
    733             inputs.paths.len(),
    734             config.limits.max_scan_entries
    735         ));
    736     }
    737 
    738     let mut report = PrototypeGuardReport::default();
    739     let mut allow_match_counts = vec![0_usize; config.allow.len()];
    740     for candidate in inputs.paths {
    741         let path = prototype_display_path(root, &candidate)?;
    742         for pattern in config.pattern.iter().filter(|pattern| pattern.match_path) {
    743             if !prototype_pattern_matches(&path, &path, pattern) {
    744                 continue;
    745             }
    746             record_prototype_match(
    747                 config,
    748                 &mut report,
    749                 &mut allow_match_counts,
    750                 PrototypeFinding {
    751                     pattern_id: pattern.id.clone(),
    752                     path: path.clone(),
    753                     origin: PrototypeFindingOrigin::Path,
    754                     line: None,
    755                     excerpt: bounded_excerpt(&path),
    756                 },
    757                 &path,
    758             )?;
    759         }
    760     }
    761     for file in inputs.files {
    762         let path = prototype_display_path(root, &file)?;
    763         let source = read_bounded_prototype_input(&file, &path, config.limits.max_file_bytes)?;
    764         for (line_index, line) in source.lines().enumerate() {
    765             for pattern in &config.pattern {
    766                 if !prototype_pattern_matches(&path, line, pattern) {
    767                     continue;
    768                 }
    769                 let finding = PrototypeFinding {
    770                     pattern_id: pattern.id.clone(),
    771                     path: path.clone(),
    772                     origin: PrototypeFindingOrigin::Content,
    773                     line: Some(line_index + 1),
    774                     excerpt: bounded_excerpt(line),
    775                 };
    776                 record_prototype_match(
    777                     config,
    778                     &mut report,
    779                     &mut allow_match_counts,
    780                     finding,
    781                     line,
    782                 )?;
    783             }
    784         }
    785     }
    786     for (allowed, match_count) in config.allow.iter().zip(allow_match_counts) {
    787         if match_count != 1 {
    788             return Err(format!(
    789                 "prototype guard allowlist entry must match exactly one line (matched {match_count}): {} {} contains {:?}",
    790                 allowed.pattern_id, allowed.path, allowed.line_contains
    791             ));
    792         }
    793     }
    794     report.findings.sort_by(|left, right| {
    795         (&left.path, left.origin, left.line, &left.pattern_id).cmp(&(
    796             &right.path,
    797             right.origin,
    798             right.line,
    799             &right.pattern_id,
    800         ))
    801     });
    802     report.allowed.sort_by(|left, right| {
    803         (
    804             &left.finding.path,
    805             left.finding.origin,
    806             left.finding.line,
    807             &left.finding.pattern_id,
    808         )
    809             .cmp(&(
    810                 &right.finding.path,
    811                 right.finding.origin,
    812                 right.finding.line,
    813                 &right.finding.pattern_id,
    814             ))
    815     });
    816     Ok(report)
    817 }
    818 
    819 fn git_governed_paths(
    820     root: &Path,
    821     max_scan_entries: usize,
    822     max_inventory_bytes: usize,
    823 ) -> Result<Option<Vec<PathBuf>>, String> {
    824     if !root.join(".git").exists() {
    825         return Ok(None);
    826     }
    827     let mut child = Command::new("git")
    828         .arg("-C")
    829         .arg(root)
    830         .args([
    831             "ls-files",
    832             "-z",
    833             "--cached",
    834             "--others",
    835             "--exclude-standard",
    836         ])
    837         .stdout(Stdio::piped())
    838         .stderr(Stdio::piped())
    839         .spawn()
    840         .map_err(|error| format!("run git source inventory for prototype guard: {error}"))?;
    841 
    842     let stdout = child
    843         .stdout
    844         .take()
    845         .ok_or_else(|| "Git source inventory stdout was not captured".to_string())?;
    846     let stderr = child
    847         .stderr
    848         .take()
    849         .ok_or_else(|| "Git source inventory stderr was not captured".to_string())?;
    850     let stderr_reader = std::thread::spawn(move || read_bounded_and_drain(stderr));
    851     let inventory = parse_git_inventory(root, stdout, max_scan_entries, max_inventory_bytes);
    852     if inventory.is_err() {
    853         let _ = child.kill();
    854     }
    855     let status = child
    856         .wait()
    857         .map_err(|error| format!("wait for Git source inventory: {error}"))?;
    858     let stderr = stderr_reader
    859         .join()
    860         .map_err(|_| "Git source inventory stderr reader panicked".to_string())?
    861         .map_err(|error| format!("read Git source inventory stderr: {error}"))?;
    862     let paths = inventory?;
    863     if !status.success() {
    864         return Err(format!(
    865             "Git source inventory for prototype guard failed: {}",
    866             escape_report_text(String::from_utf8_lossy(&stderr).trim())
    867         ));
    868     }
    869     Ok(Some(paths))
    870 }
    871 
    872 fn read_bounded_and_drain(mut reader: impl Read) -> std::io::Result<Vec<u8>> {
    873     let mut captured = Vec::new();
    874     let mut buffer = [0_u8; 4096];
    875     loop {
    876         let read = reader.read(&mut buffer)?;
    877         if read == 0 {
    878             return Ok(captured);
    879         }
    880         let remaining = PROTOTYPE_MAX_GIT_STDERR_BYTES.saturating_sub(captured.len());
    881         captured.extend_from_slice(&buffer[..read.min(remaining)]);
    882     }
    883 }
    884 
    885 fn parse_git_inventory(
    886     root: &Path,
    887     mut reader: impl Read,
    888     max_scan_entries: usize,
    889     max_inventory_bytes: usize,
    890 ) -> Result<Vec<PathBuf>, String> {
    891     let mut paths = Vec::new();
    892     let mut raw_path = Vec::new();
    893     let mut total_bytes = 0_usize;
    894     let mut buffer = [0_u8; 4096];
    895     loop {
    896         let read = reader
    897             .read(&mut buffer)
    898             .map_err(|error| format!("read Git source inventory: {error}"))?;
    899         if read == 0 {
    900             break;
    901         }
    902         total_bytes = total_bytes.checked_add(read).ok_or_else(|| {
    903             "prototype guard Git source inventory byte count overflowed".to_string()
    904         })?;
    905         if total_bytes > max_inventory_bytes {
    906             return Err(format!(
    907                 "prototype guard Git source inventory exceeds configured byte limit {max_inventory_bytes}"
    908             ));
    909         }
    910         for byte in &buffer[..read] {
    911             if *byte != 0 {
    912                 if raw_path.len() >= PROTOTYPE_MAX_CONFIG_STRING_BYTES {
    913                     return Err(format!(
    914                         "prototype guard Git source path exceeds compiled byte limit {PROTOTYPE_MAX_CONFIG_STRING_BYTES}"
    915                     ));
    916                 }
    917                 raw_path.push(*byte);
    918                 continue;
    919             }
    920             if raw_path.is_empty() {
    921                 continue;
    922             }
    923             if paths.len() >= max_scan_entries {
    924                 return Err(format!(
    925                     "prototype guard Git source inventory exceeds configured entry limit {max_scan_entries}"
    926                 ));
    927             }
    928             let relative = std::str::from_utf8(&raw_path)
    929                 .map_err(|error| format!("prototype guard Git path is not UTF-8: {error}"))?;
    930             validate_repo_relative_path(relative, "prototype guard Git source path")?;
    931             let candidate = root.join(relative);
    932             match fs::symlink_metadata(&candidate) {
    933                 Ok(_) => {
    934                     reject_symlinked_path_components(
    935                         root,
    936                         Path::new(relative),
    937                         "prototype guard Git source path",
    938                     )?;
    939                     paths.push(candidate);
    940                 }
    941                 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
    942                 Err(error) => {
    943                     return Err(format!(
    944                         "inspect prototype guard Git source {}: {error}",
    945                         candidate.display()
    946                     ));
    947                 }
    948             }
    949             raw_path.clear();
    950         }
    951     }
    952     if !raw_path.is_empty() {
    953         return Err(
    954             "prototype guard Git source inventory ended without a NUL delimiter".to_string(),
    955         );
    956     }
    957     paths.sort();
    958     paths.dedup();
    959     Ok(paths)
    960 }
    961 
    962 fn path_is_within_any_root(path: &str, roots: &[String]) -> bool {
    963     roots
    964         .iter()
    965         .any(|root| root == "." || repository_path_is_within(path, root))
    966 }
    967 
    968 fn path_is_excluded(path: &str, excluded_prefixes: &[String]) -> bool {
    969     excluded_prefixes
    970         .iter()
    971         .any(|prefix| repository_path_is_within(path, prefix))
    972 }
    973 
    974 fn prototype_display_path(root: &Path, path: &Path) -> Result<String, String> {
    975     let relative = path.strip_prefix(root).map_err(|_| {
    976         format!(
    977             "prototype guard path is outside repository root: {}",
    978             path.display()
    979         )
    980     })?;
    981     if relative.as_os_str().is_empty() {
    982         return Ok(".".to_string());
    983     }
    984     let mut components = Vec::new();
    985     for component in relative.components() {
    986         let std::path::Component::Normal(component) = component else {
    987             return Err("prototype guard path must contain only normal components".to_string());
    988         };
    989         components.push(
    990             component
    991                 .to_str()
    992                 .ok_or_else(|| "prototype guard path is not UTF-8".to_string())?,
    993         );
    994     }
    995     let relative = components.join("/");
    996     validate_repo_relative_path(&relative, "prototype guard source path")?;
    997     Ok(relative)
    998 }
    999 
   1000 fn is_prototype_text_input(
   1001     path: &Path,
   1002     extensions: &HashSet<&str>,
   1003     extensionless_names: &HashSet<&str>,
   1004 ) -> bool {
   1005     let extension_matches = path
   1006         .extension()
   1007         .and_then(|extension| extension.to_str())
   1008         .is_some_and(|extension| extensions.contains(extension));
   1009     let extensionless_matches = path.extension().is_none()
   1010         && path
   1011             .file_name()
   1012             .and_then(|name| name.to_str())
   1013             .is_some_and(|name| extensionless_names.contains(name));
   1014     extension_matches || extensionless_matches
   1015 }
   1016 
   1017 fn record_prototype_match(
   1018     config: &PrototypeGuardConfig,
   1019     report: &mut PrototypeGuardReport,
   1020     allow_match_counts: &mut [usize],
   1021     finding: PrototypeFinding,
   1022     matched_text: &str,
   1023 ) -> Result<(), String> {
   1024     let match_count = report.findings.len() + report.allowed.len();
   1025     if match_count >= config.limits.max_matches {
   1026         return Err(format!(
   1027             "prototype guard match count exceeds configured limit {}",
   1028             config.limits.max_matches
   1029         ));
   1030     }
   1031     if let Some((allow_index, allowed)) = config.allow.iter().enumerate().find(|(_, allowed)| {
   1032         allowed.pattern_id == finding.pattern_id
   1033             && allowed.path == finding.path
   1034             && matched_text.contains(&allowed.line_contains)
   1035     }) {
   1036         allow_match_counts[allow_index] += 1;
   1037         report.allowed.push(PrototypeAllowedMatch {
   1038             finding,
   1039             reason: allowed.reason.clone(),
   1040         });
   1041     } else {
   1042         report.findings.push(finding);
   1043     }
   1044     Ok(())
   1045 }
   1046 
   1047 fn read_bounded_prototype_input(
   1048     path: &Path,
   1049     display_path: &str,
   1050     max_file_bytes: u64,
   1051 ) -> Result<String, String> {
   1052     let file = fs::File::open(path)
   1053         .map_err(|error| format!("open prototype guard input {display_path}: {error}"))?;
   1054     let mut bytes = Vec::new();
   1055     file.take(max_file_bytes + 1)
   1056         .read_to_end(&mut bytes)
   1057         .map_err(|error| format!("read prototype guard input {display_path}: {error}"))?;
   1058     if bytes.len() as u64 > max_file_bytes {
   1059         return Err(format!(
   1060             "prototype guard input exceeds {max_file_bytes} bytes: {display_path}"
   1061         ));
   1062     }
   1063     String::from_utf8(bytes)
   1064         .map_err(|error| format!("prototype guard input is not UTF-8 {display_path}: {error}"))
   1065 }
   1066 
   1067 #[derive(Default)]
   1068 struct PrototypeInputs {
   1069     files: Vec<PathBuf>,
   1070     paths: Vec<PathBuf>,
   1071 }
   1072 
   1073 fn collect_prototype_inputs(
   1074     root: &Path,
   1075     path: &Path,
   1076     extensions: &HashSet<&str>,
   1077     extensionless_names: &HashSet<&str>,
   1078     excluded_prefixes: &[String],
   1079     max_scan_entries: usize,
   1080     inputs: &mut PrototypeInputs,
   1081 ) -> Result<(), String> {
   1082     let relative = prototype_display_path(root, path)?;
   1083     if path_is_excluded(&relative, excluded_prefixes) {
   1084         return Ok(());
   1085     }
   1086     let metadata = fs::symlink_metadata(path).map_err(|error| {
   1087         format!(
   1088             "inspect required prototype guard path {}: {error}",
   1089             path.display()
   1090         )
   1091     })?;
   1092     if metadata.file_type().is_symlink() {
   1093         return Err(format!(
   1094             "prototype guard refuses symlinked scan input: {}",
   1095             path.display()
   1096         ));
   1097     }
   1098     if inputs.paths.len() >= max_scan_entries {
   1099         return Err(format!(
   1100             "prototype guard scan exceeds configured entry limit {max_scan_entries}"
   1101         ));
   1102     }
   1103     inputs.paths.push(path.to_path_buf());
   1104     if metadata.is_file() {
   1105         if is_prototype_text_input(path, extensions, extensionless_names) {
   1106             inputs.files.push(path.to_path_buf());
   1107         }
   1108         return Ok(());
   1109     }
   1110     if !metadata.is_dir() {
   1111         return Ok(());
   1112     }
   1113     let entries = fs::read_dir(path)
   1114         .map_err(|error| format!("read prototype guard directory {}: {error}", path.display()))?;
   1115     for entry in entries {
   1116         let entry = entry.map_err(|error| {
   1117             format!(
   1118                 "read prototype guard entry under {}: {error}",
   1119                 path.display()
   1120             )
   1121         })?;
   1122         collect_prototype_inputs(
   1123             root,
   1124             &entry.path(),
   1125             extensions,
   1126             extensionless_names,
   1127             excluded_prefixes,
   1128             max_scan_entries,
   1129             inputs,
   1130         )?;
   1131     }
   1132     Ok(())
   1133 }
   1134 
   1135 fn collect_prototype_paths(
   1136     root: &Path,
   1137     path: &Path,
   1138     excluded_prefixes: &[String],
   1139     max_scan_entries: usize,
   1140     paths: &mut Vec<PathBuf>,
   1141 ) -> Result<(), String> {
   1142     let relative = prototype_display_path(root, path)?;
   1143     if excluded_prefixes
   1144         .iter()
   1145         .any(|prefix| repository_path_is_within(&relative, prefix))
   1146     {
   1147         return Ok(());
   1148     }
   1149     let metadata = fs::symlink_metadata(path).map_err(|error| {
   1150         format!(
   1151             "inspect required prototype guard path {}: {error}",
   1152             path.display()
   1153         )
   1154     })?;
   1155     if metadata.file_type().is_symlink() {
   1156         return Err(format!(
   1157             "prototype guard refuses symlinked scan input: {}",
   1158             path.display()
   1159         ));
   1160     }
   1161     if paths.len() >= max_scan_entries {
   1162         return Err(format!(
   1163             "prototype guard scan exceeds configured entry limit {max_scan_entries}"
   1164         ));
   1165     }
   1166     paths.push(path.to_path_buf());
   1167     if !metadata.is_dir() {
   1168         return Ok(());
   1169     }
   1170     let entries = fs::read_dir(path)
   1171         .map_err(|error| format!("read prototype guard directory {}: {error}", path.display()))?;
   1172     for entry in entries {
   1173         let entry = entry.map_err(|error| {
   1174             format!(
   1175                 "read prototype guard entry under {}: {error}",
   1176                 path.display()
   1177             )
   1178         })?;
   1179         collect_prototype_paths(
   1180             root,
   1181             &entry.path(),
   1182             excluded_prefixes,
   1183             max_scan_entries,
   1184             paths,
   1185         )?;
   1186     }
   1187     Ok(())
   1188 }
   1189 
   1190 fn prototype_pattern_matches(path: &str, line: &str, pattern: &PrototypePattern) -> bool {
   1191     let path_matches = pattern.path_prefixes.is_empty()
   1192         || pattern
   1193             .path_prefixes
   1194             .iter()
   1195             .any(|prefix| repository_path_is_within(path, prefix));
   1196     path_matches
   1197         && match pattern.match_kind {
   1198             PrototypeMatchKind::Substring => line.contains(&pattern.needle),
   1199             PrototypeMatchKind::WordPrefix => {
   1200                 let folded_line = line.to_ascii_lowercase();
   1201                 let folded_needle = pattern.needle.to_ascii_lowercase();
   1202                 folded_line.match_indices(&folded_needle).any(|(index, _)| {
   1203                     folded_line[..index]
   1204                         .chars()
   1205                         .next_back()
   1206                         .is_none_or(|character| !is_prototype_identifier_continue(character))
   1207                 })
   1208             }
   1209         }
   1210 }
   1211 
   1212 fn is_prototype_identifier_continue(character: char) -> bool {
   1213     character.is_alphanumeric() || character == '_'
   1214 }
   1215 
   1216 fn bounded_excerpt(line: &str) -> String {
   1217     const LIMIT: usize = 240;
   1218     let escaped = escape_report_text(line.trim());
   1219     if escaped.chars().count() <= LIMIT {
   1220         return escaped;
   1221     }
   1222     let mut excerpt: String = escaped.chars().take(LIMIT - 3).collect();
   1223     excerpt.push_str("...");
   1224     excerpt
   1225 }
   1226 
   1227 fn escape_report_text(value: &str) -> String {
   1228     let mut escaped = String::with_capacity(value.len());
   1229     for character in value.chars() {
   1230         if character.is_control() {
   1231             escaped.extend(character.escape_default());
   1232         } else {
   1233             escaped.push(character);
   1234         }
   1235     }
   1236     escaped
   1237 }
   1238 
   1239 fn print_prototype_guard_report(
   1240     mode: PrototypeGuardMode,
   1241     report: &PrototypeGuardReport,
   1242     limits: &PrototypeGuardLimits,
   1243 ) {
   1244     println!(
   1245         "prototype contract source guard: mode={} findings={} allowlisted={}",
   1246         mode.as_str(),
   1247         report.findings.len(),
   1248         report.allowed.len()
   1249     );
   1250     for finding in report.findings.iter().take(limits.max_reported_findings) {
   1251         print_prototype_finding("finding", finding, None);
   1252     }
   1253     if report.findings.len() > limits.max_reported_findings {
   1254         println!(
   1255             "... {} additional finding(s) omitted by report limit",
   1256             report.findings.len() - limits.max_reported_findings
   1257         );
   1258     }
   1259     for allowed in report.allowed.iter().take(limits.max_reported_allowlisted) {
   1260         print_prototype_finding("allowlisted", &allowed.finding, Some(&allowed.reason));
   1261     }
   1262     if report.allowed.len() > limits.max_reported_allowlisted {
   1263         println!(
   1264             "... {} additional allowlisted match(es) omitted by report limit",
   1265             report.allowed.len() - limits.max_reported_allowlisted
   1266         );
   1267     }
   1268 }
   1269 
   1270 fn print_prototype_finding(label: &str, finding: &PrototypeFinding, reason: Option<&str>) {
   1271     let path = escape_report_text(&finding.path);
   1272     let location = finding
   1273         .line
   1274         .map_or_else(|| format!("{path} [path]"), |line| format!("{path}:{line}"));
   1275     if let Some(reason) = reason {
   1276         let reason = escape_report_text(reason);
   1277         println!(
   1278             "{label} {} {location}: {} ({reason})",
   1279             finding.pattern_id,
   1280             escape_report_text(&finding.excerpt)
   1281         );
   1282     } else {
   1283         println!(
   1284             "{label} {} {location}: {}",
   1285             finding.pattern_id,
   1286             escape_report_text(&finding.excerpt)
   1287         );
   1288     }
   1289 }
   1290 
   1291 pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> {
   1292     let mut failures = Vec::new();
   1293     let consolidation_active = consolidation_is_active(root);
   1294     reject_substrings(
   1295         root,
   1296         &[PathBuf::from("crates/transport_nostr/src")],
   1297         &["RadrootsEventIngest::verified"],
   1298         "relay fetch must not bypass event-store verification",
   1299         &[],
   1300         &mut failures,
   1301     );
   1302     reject_substrings(
   1303         root,
   1304         &[PathBuf::from("crates/event_store/src")],
   1305         &["last_created_at", "last_event_id"],
   1306         "event-store projection cursors must use last_event_seq",
   1307         &[],
   1308         &mut failures,
   1309     );
   1310     reject_raw_protocol_strings(root, &mut failures);
   1311     reject_substrings(
   1312         root,
   1313         &[
   1314             PathBuf::from("crates/event/src"),
   1315             PathBuf::from("crates/event_codec/src"),
   1316             PathBuf::from("crates/trade/src"),
   1317         ],
   1318         &[
   1319             "RadrootsTradeMessageType",
   1320             "RadrootsTradeEnvelope",
   1321             "RadrootsTradeMessagePayload",
   1322             "RadrootsTradeQuestion",
   1323             "RadrootsTradeAnswer",
   1324             "RadrootsTradeDiscount",
   1325             "RadrootsTradeOrder",
   1326             "RadrootsActiveOrder",
   1327             "RadrootsActiveTrade",
   1328             "RadrootsTradeListingParseError",
   1329             "RadrootsClassifiedListingTradeProjectionParseError",
   1330             "RadrootsOperationalListingTradeProjectionParseError",
   1331             "RadrootsTradeDomain",
   1332             "radroots_sdk::trade::",
   1333             "TradeListingParseError",
   1334             "TradeListingEnvelope",
   1335             "TradeListingMessage",
   1336             "KIND_TRADE_ORDER",
   1337             "TRADE_LISTING_KINDS",
   1338             "build_envelope_draft",
   1339             "parse_envelope",
   1340             "public_trade",
   1341             "events::trade::",
   1342             "event_codec::trade::",
   1343             "trade_order_economics_digest",
   1344             "trade_revision",
   1345             "trade_lifecycle",
   1346             "reduce_active_order",
   1347             "canonicalize_active_order",
   1348             "active_trade_",
   1349             "ActiveOrder",
   1350             "active_order",
   1351             "active order",
   1352             "active trade",
   1353             "RADROOTS_TRADE_LISTING_DOMAIN",
   1354             "RADROOTS_TRADE_ENVELOPE_VERSION",
   1355         ],
   1356         "removed trade identifiers must not reappear",
   1357         &[],
   1358         &mut failures,
   1359     );
   1360     reject_substrings(
   1361         root,
   1362         &[PathBuf::from("crates"), PathBuf::from("contracts")],
   1363         &[
   1364             "KIND_TRADE_LISTING_ORDER",
   1365             "KIND_TRADE_LISTING_QUESTION",
   1366             "KIND_TRADE_LISTING_ANSWER",
   1367             "KIND_TRADE_LISTING_DISCOUNT",
   1368             "KIND_TRADE_LISTING_CANCEL",
   1369             "KIND_TRADE_LISTING_FULFILLMENT",
   1370             "KIND_TRADE_LISTING_RECEIPT",
   1371             "KIND_TRADE_LISTING_VALIDATE_REQ",
   1372             "KIND_TRADE_LISTING_VALIDATE_RES",
   1373             "KIND_WORKER_TRADE_TRANSITION_PROOF_REQ",
   1374             "KIND_WORKER_TRADE_TRANSITION_PROOF_RES",
   1375         ],
   1376         "removed trade and DVM kind constants must not reappear",
   1377         &[],
   1378         &mut failures,
   1379     );
   1380     reject_substrings(
   1381         root,
   1382         &[
   1383             PathBuf::from("crates"),
   1384             PathBuf::from("contracts"),
   1385             PathBuf::from("tools"),
   1386         ],
   1387         RETIRED_PROTOCOL_EVENT_SURFACE_PATTERNS,
   1388         "retired V1 public event surfaces must not reappear outside negative contract guards",
   1389         RETIRED_PROTOCOL_EVENT_SURFACE_ALLOWED_PATHS,
   1390         &mut failures,
   1391     );
   1392     reject_substrings(
   1393         root,
   1394         &[
   1395             PathBuf::from("crates"),
   1396             PathBuf::from("contracts"),
   1397             PathBuf::from("tools"),
   1398             PathBuf::from("build"),
   1399         ],
   1400         &["tangle"],
   1401         "removed identifier 'tangle' must not reappear",
   1402         &[
   1403             "contracts/consolidation/baseline.v1.toml",
   1404             "contracts/consolidation/imports/studio_app.commit-map.v1.json",
   1405             "tools/xtask/src/sdk_generation/package_matrix.rs",
   1406             "tools/xtask/src/hygiene.rs",
   1407         ],
   1408         &mut failures,
   1409     );
   1410     reject_retired_listing_aliases(root, &mut failures);
   1411     if !consolidation_active {
   1412         reject_binding_dependencies(root, &mut failures);
   1413         reject_forbidden_crate_paths(root, &mut failures);
   1414     }
   1415     reject_existing_paths(
   1416         root,
   1417         &[
   1418             "spec",
   1419             "policy",
   1420             "nix",
   1421             "scripts",
   1422             "bindings",
   1423             "dist",
   1424             "ffi",
   1425             "generated",
   1426             "packages",
   1427             "pkg",
   1428             "contracts/exports",
   1429             "contracts/language-exports",
   1430             "contracts/language-exports.toml",
   1431             "contracts/language_exports",
   1432             "contracts/language_exports.toml",
   1433             "contracts/package-matrix",
   1434             "contracts/package-matrix.toml",
   1435             "contracts/package_matrix",
   1436             "contracts/package_matrix.toml",
   1437             "contracts/sdk-exports",
   1438             "contracts/sdk_exports",
   1439             "spec/exports",
   1440             "spec/sdk-exports",
   1441         ],
   1442         "SDK, binding, generated-package, and retired layout paths must stay outside rr-rs",
   1443         &mut failures,
   1444     );
   1445 
   1446     if failures.is_empty() {
   1447         println!("forbidden identifier hygiene passed");
   1448         Ok(())
   1449     } else {
   1450         Err(format!(
   1451             "forbidden identifier hygiene violations:\n{}",
   1452             failures.join("\n")
   1453         ))
   1454     }
   1455 }
   1456 
   1457 fn consolidation_is_active(root: &Path) -> bool {
   1458     let Ok(workspace) = fs::read_to_string(root.join("Cargo.toml")) else {
   1459         return false;
   1460     };
   1461     let Ok(workspace) = workspace.parse::<toml::Value>() else {
   1462         return false;
   1463     };
   1464     let Some(repository) = workspace
   1465         .get("workspace")
   1466         .and_then(|value| value.get("package"))
   1467         .and_then(|value| value.get("repository"))
   1468         .and_then(toml::Value::as_str)
   1469     else {
   1470         return false;
   1471     };
   1472     let Ok(consolidation) =
   1473         fs::read_to_string(root.join("contracts/consolidation/architecture.v1.toml"))
   1474     else {
   1475         return false;
   1476     };
   1477     let Ok(consolidation) = consolidation.parse::<toml::Value>() else {
   1478         return false;
   1479     };
   1480     consolidation
   1481         .get("canonical_rust_repository")
   1482         .and_then(toml::Value::as_str)
   1483         == Some(repository)
   1484 }
   1485 
   1486 fn reject_retired_listing_aliases(root: &Path, failures: &mut Vec<String>) {
   1487     let rel_roots = [
   1488         PathBuf::from("crates"),
   1489         PathBuf::from("contracts"),
   1490         PathBuf::from("tools"),
   1491         PathBuf::from("build"),
   1492         PathBuf::from("dto_bindgen.toml"),
   1493     ];
   1494 
   1495     for file in files_under(root, &rel_roots) {
   1496         let rel = display_path(root, &file);
   1497         if rel == "tools/xtask/src/hygiene.rs" {
   1498             continue;
   1499         }
   1500         if is_retired_listing_module_path(&rel) {
   1501             failures.push(format!(
   1502                 "retired listing public aliases must not reappear: {rel}: legacy listing module path"
   1503             ));
   1504         }
   1505 
   1506         let Ok(content) = fs::read_to_string(&file) else {
   1507             continue;
   1508         };
   1509         for (line_index, line) in content.lines().enumerate() {
   1510             if is_retired_listing_negative_guard(&rel, line) {
   1511                 continue;
   1512             }
   1513             let trimmed = line.trim_start();
   1514             if trimmed.starts_with("let ") || trimmed.starts_with("assert") {
   1515                 continue;
   1516             }
   1517 
   1518             for token in RETIRED_LISTING_ALIAS_IDENTIFIER_TOKENS {
   1519                 if contains_identifier_token(line, token) {
   1520                     failures.push(format!(
   1521                         "retired listing public aliases must not reappear: {}:{}: {}",
   1522                         rel,
   1523                         line_index + 1,
   1524                         line.trim()
   1525                     ));
   1526                 }
   1527             }
   1528             for prefix in RETIRED_LISTING_ALIAS_IDENTIFIER_PREFIXES {
   1529                 if contains_identifier_prefix(line, prefix) {
   1530                     failures.push(format!(
   1531                         "retired listing public aliases must not reappear: {}:{}: {}",
   1532                         rel,
   1533                         line_index + 1,
   1534                         line.trim()
   1535                     ));
   1536                 }
   1537             }
   1538             if line.contains(RETIRED_LISTING_CONTRACT_ID) {
   1539                 failures.push(format!(
   1540                     "retired listing public aliases must not reappear: {}:{}: {}",
   1541                     rel,
   1542                     line_index + 1,
   1543                     line.trim()
   1544                 ));
   1545             }
   1546             if is_listing_module_scope(&rel)
   1547                 && !is_canonical_event_listing_module_reference(&rel, line)
   1548                 && contains_retired_listing_module_reference(line)
   1549             {
   1550                 failures.push(format!(
   1551                     "retired listing public aliases must not reappear: {}:{}: {}",
   1552                     rel,
   1553                     line_index + 1,
   1554                     line.trim()
   1555                 ));
   1556             }
   1557         }
   1558     }
   1559 }
   1560 
   1561 fn contains_identifier_token(line: &str, token: &str) -> bool {
   1562     line.match_indices(token).any(|(index, _)| {
   1563         let before = line[..index].chars().next_back();
   1564         let after = line[index + token.len()..].chars().next();
   1565         before.is_none_or(|ch| !is_identifier_continue(ch))
   1566             && after.is_none_or(|ch| !is_identifier_continue(ch))
   1567     })
   1568 }
   1569 
   1570 fn contains_identifier_prefix(line: &str, prefix: &str) -> bool {
   1571     line.match_indices(prefix).any(|(index, _)| {
   1572         line[..index]
   1573             .chars()
   1574             .next_back()
   1575             .is_none_or(|ch| !is_identifier_continue(ch))
   1576     })
   1577 }
   1578 
   1579 fn is_identifier_continue(ch: char) -> bool {
   1580     ch.is_ascii_alphanumeric() || ch == '_'
   1581 }
   1582 
   1583 fn is_retired_listing_module_path(rel: &str) -> bool {
   1584     is_listing_module_scope(rel)
   1585         && !is_canonical_event_listing_module_path(rel)
   1586         && Path::new(rel).components().any(|component| {
   1587             component.as_os_str() == "listing"
   1588                 || Path::new(component.as_os_str())
   1589                     .file_stem()
   1590                     .is_some_and(|stem| stem == "listing")
   1591         })
   1592 }
   1593 
   1594 fn is_canonical_event_listing_module_path(rel: &str) -> bool {
   1595     rel == "crates/event/src/listing.rs" || rel.starts_with("crates/event/src/listing/")
   1596 }
   1597 
   1598 fn is_listing_module_scope(rel: &str) -> bool {
   1599     rel.starts_with("crates/event/src/")
   1600         || rel.starts_with("crates/event/tests/")
   1601         || rel.starts_with("crates/event_codec/src/")
   1602         || rel.starts_with("crates/event_codec/tests/")
   1603         || rel.starts_with("crates/trade/src/")
   1604         || rel.starts_with("crates/trade/tests/")
   1605         || rel.starts_with("contracts/conformance/vectors/")
   1606         || rel == "dto_bindgen.toml"
   1607 }
   1608 
   1609 fn contains_retired_listing_module_reference(line: &str) -> bool {
   1610     line.match_indices("listing").any(|(index, _)| {
   1611         let before = &line[..index];
   1612         let after = line[index + "listing".len()..].chars().next();
   1613         let starts_module_segment =
   1614             before.ends_with("::") || before.trim_end().ends_with("mod") || before.ends_with('/');
   1615         starts_module_segment && after.is_none_or(|ch| !is_identifier_continue(ch))
   1616     })
   1617 }
   1618 
   1619 fn is_canonical_event_listing_module_reference(rel: &str, line: &str) -> bool {
   1620     (rel == "crates/event/src/lib.rs" && line.trim() == "pub mod listing;")
   1621         || (rel.starts_with("crates/event/src/") && line.contains("crate::listing::"))
   1622         || line.contains("radroots_event::listing::")
   1623 }
   1624 
   1625 fn is_retired_listing_negative_guard(rel: &str, line: &str) -> bool {
   1626     (rel == "crates/event/src/dto.rs"
   1627         && (line.contains("\"ListingCancel\"") || line.contains("\"RadrootsListingCancel\"")))
   1628         || (rel == "crates/event/src/contract/registry_v7/tests.rs"
   1629             && line.contains("event_contract(\"radroots.listing.published.v1\").is_none()"))
   1630 }
   1631 
   1632 fn reject_binding_dependencies(root: &Path, failures: &mut Vec<String>) {
   1633     for file in manifest_files(root) {
   1634         let rel = display_path(root, &file);
   1635         let Ok(content) = fs::read_to_string(&file) else {
   1636             continue;
   1637         };
   1638         let Ok(manifest) = content.parse::<toml::Value>() else {
   1639             failures.push(format!("Cargo manifest must parse as TOML: {rel}"));
   1640             continue;
   1641         };
   1642         reject_binding_dependencies_in_value(&manifest, &mut Vec::new(), &rel, failures);
   1643     }
   1644 }
   1645 
   1646 fn reject_binding_dependencies_in_value(
   1647     value: &toml::Value,
   1648     path: &mut Vec<String>,
   1649     manifest_rel: &str,
   1650     failures: &mut Vec<String>,
   1651 ) {
   1652     let Some(table) = value.as_table() else {
   1653         return;
   1654     };
   1655     if path
   1656         .last()
   1657         .is_some_and(|segment| is_dependency_table_name(segment))
   1658     {
   1659         for dependency in BINDING_DEPENDENCIES {
   1660             if table.contains_key(*dependency) {
   1661                 failures.push(format!(
   1662                     "SDK, FFI, binding, and generated-package dependencies are forbidden in rr-rs: {manifest_rel}: {dependency} in [{}]",
   1663                     path.join(".")
   1664                 ));
   1665             }
   1666         }
   1667     }
   1668     for (key, child) in table {
   1669         path.push(key.clone());
   1670         reject_binding_dependencies_in_value(child, path, manifest_rel, failures);
   1671         path.pop();
   1672     }
   1673 }
   1674 
   1675 fn is_dependency_table_name(segment: &str) -> bool {
   1676     matches!(
   1677         segment,
   1678         "dependencies" | "dev-dependencies" | "build-dependencies"
   1679     )
   1680 }
   1681 
   1682 fn manifest_files(root: &Path) -> Vec<PathBuf> {
   1683     let mut files = vec![root.join("Cargo.toml")];
   1684     files.extend(files_under(
   1685         root,
   1686         &[PathBuf::from("crates"), PathBuf::from("tools")],
   1687     ));
   1688     files.retain(|path| path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml"));
   1689     files.sort();
   1690     files.dedup();
   1691     files
   1692 }
   1693 
   1694 fn reject_forbidden_crate_paths(root: &Path, failures: &mut Vec<String>) {
   1695     let Ok(entries) = fs::read_dir(root.join("crates")) else {
   1696         return;
   1697     };
   1698     for entry in entries.flatten() {
   1699         let path = entry.path();
   1700         if !path.is_dir() {
   1701             continue;
   1702         }
   1703         let name = entry.file_name().to_string_lossy().to_string();
   1704         if is_forbidden_crate_dir_name(&name) {
   1705             failures.push(format!(
   1706                 "SDK, FFI, binding, and generated-package crate paths are forbidden in rr-rs: crates/{name}"
   1707             ));
   1708         }
   1709     }
   1710 }
   1711 
   1712 fn is_forbidden_crate_dir_name(name: &str) -> bool {
   1713     let lowercase = name.to_ascii_lowercase();
   1714     lowercase.contains("ffi") || lowercase.contains("binding") || lowercase.contains("_wasm")
   1715 }
   1716 
   1717 fn reject_existing_paths(root: &Path, rel_paths: &[&str], label: &str, failures: &mut Vec<String>) {
   1718     for rel_path in rel_paths {
   1719         if root.join(rel_path).exists() {
   1720             failures.push(format!("{label}: {rel_path}"));
   1721         }
   1722     }
   1723 }
   1724 
   1725 fn reject_substrings(
   1726     root: &Path,
   1727     rel_roots: &[PathBuf],
   1728     patterns: &[&str],
   1729     label: &str,
   1730     ignored_rel_paths: &[&str],
   1731     failures: &mut Vec<String>,
   1732 ) {
   1733     for file in files_under(root, rel_roots) {
   1734         let rel = display_path(root, &file);
   1735         if ignored_rel_paths.contains(&rel.as_str()) {
   1736             continue;
   1737         }
   1738         let Ok(content) = fs::read_to_string(&file) else {
   1739             continue;
   1740         };
   1741         for (line_index, line) in content.lines().enumerate() {
   1742             for pattern in patterns {
   1743                 if line.contains(pattern) {
   1744                     failures.push(format!(
   1745                         "{label}: {}:{}: {}",
   1746                         rel,
   1747                         line_index + 1,
   1748                         line.trim()
   1749                     ));
   1750                 }
   1751             }
   1752         }
   1753     }
   1754 }
   1755 
   1756 fn reject_raw_protocol_strings(root: &Path, failures: &mut Vec<String>) {
   1757     let rel_roots = [
   1758         PathBuf::from("crates/event/src"),
   1759         PathBuf::from("crates/event_codec/src"),
   1760         PathBuf::from("crates/trade/src"),
   1761     ];
   1762     for file in files_under(root, &rel_roots) {
   1763         let Ok(content) = fs::read_to_string(&file) else {
   1764             continue;
   1765         };
   1766         let mut struct_name = String::new();
   1767         for (line_index, line) in content.lines().enumerate() {
   1768             let trimmed = line.trim();
   1769             if let Some(rest) = trimmed.strip_prefix("pub struct ") {
   1770                 struct_name = rest
   1771                     .split(['<', '{', ' ', '('])
   1772                     .next()
   1773                     .unwrap_or_default()
   1774                     .to_owned();
   1775             }
   1776             if trimmed == "}" {
   1777                 struct_name.clear();
   1778             }
   1779             if is_raw_protocol_field(trimmed) && !is_allowed_raw_boundary(&struct_name) {
   1780                 failures.push(format!(
   1781                     "raw commercial protocol identifier String fields are forbidden: {}:{}: {}",
   1782                     display_path(root, &file),
   1783                     line_index + 1,
   1784                     trimmed
   1785                 ));
   1786             }
   1787         }
   1788     }
   1789 }
   1790 
   1791 fn is_raw_protocol_field(line: &str) -> bool {
   1792     [
   1793         "pub order_id: String,",
   1794         "pub listing_addr: String,",
   1795         "pub revision_id: String,",
   1796         "pub quote_id: String,",
   1797         "pub primary_bin_id: String,",
   1798         "pub bin_id: String,",
   1799         "pub economics_digest: String,",
   1800     ]
   1801     .contains(&line)
   1802 }
   1803 
   1804 fn is_allowed_raw_boundary(struct_name: &str) -> bool {
   1805     struct_name == "OrderEnvelope"
   1806         || struct_name == "RadrootsValidationReceiptTags"
   1807         || struct_name == "RadrootsOperationalListingTradeProjection"
   1808         || struct_name.ends_with("Projection")
   1809         || struct_name.ends_with("Accounting")
   1810         || struct_name.ends_with("Availability")
   1811         || struct_name.ends_with("Reservation")
   1812         || struct_name.ends_with("Issue")
   1813         || struct_name.ends_with("NormalizedInventoryCount")
   1814 }
   1815 
   1816 fn files_under(root: &Path, rel_roots: &[PathBuf]) -> Vec<PathBuf> {
   1817     let mut files = Vec::new();
   1818     for rel_root in rel_roots {
   1819         collect_files(root.join(rel_root), &mut files);
   1820     }
   1821     files.sort();
   1822     files
   1823 }
   1824 
   1825 fn collect_files(path: PathBuf, files: &mut Vec<PathBuf>) {
   1826     let Ok(metadata) = fs::metadata(&path) else {
   1827         return;
   1828     };
   1829     if metadata.is_file() {
   1830         if matches!(
   1831             path.extension().and_then(|ext| ext.to_str()),
   1832             Some("json" | "md" | "nix" | "rs" | "sh" | "sql" | "toml")
   1833         ) {
   1834             files.push(path);
   1835         }
   1836         return;
   1837     }
   1838     let Ok(entries) = fs::read_dir(path) else {
   1839         return;
   1840     };
   1841     for entry in entries.flatten() {
   1842         collect_files(entry.path(), files);
   1843     }
   1844 }
   1845 
   1846 fn display_path(root: &Path, file: &Path) -> String {
   1847     file.strip_prefix(root)
   1848         .unwrap_or(file)
   1849         .to_string_lossy()
   1850         .to_string()
   1851 }
   1852 
   1853 #[cfg(test)]
   1854 mod tests {
   1855     use super::*;
   1856     use std::time::{SystemTime, UNIX_EPOCH};
   1857 
   1858     fn unique_temp_dir(prefix: &str) -> PathBuf {
   1859         let ns = SystemTime::now()
   1860             .duration_since(UNIX_EPOCH)
   1861             .expect("system time")
   1862             .as_nanos();
   1863         std::env::temp_dir().join(format!("radroots_xtask_hygiene_{prefix}_{ns}"))
   1864     }
   1865 
   1866     fn write_file(root: &Path, rel: &str, content: &str) {
   1867         let path = root.join(rel);
   1868         fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
   1869         fs::write(path, content).expect("write");
   1870     }
   1871 
   1872     #[test]
   1873     fn forbidden_identifiers_accept_clean_synthetic_tree() {
   1874         let root = unique_temp_dir("clean");
   1875         write_file(
   1876             &root,
   1877             "crates/transport_nostr/src/fetch.rs",
   1878             "fn fetch() { let _ = RadrootsEventIngest::new; }\n",
   1879         );
   1880         write_file(
   1881             &root,
   1882             "crates/event_store/src/store.rs",
   1883             "pub struct RadrootsProjectionCursor { pub last_event_seq: i64 }\n",
   1884         );
   1885         write_file(
   1886             &root,
   1887             "crates/trade/src/order.rs",
   1888             "pub struct RadrootsOrderProjection { pub order_id: OrderId, }\npub enum RadrootsTradeFulfillmentStateV1 { NotStarted }\n",
   1889         );
   1890         write_file(
   1891             &root,
   1892             "crates/protocol_contract_v1/src/lib.rs",
   1893             "const RETIRED: &str = \"listing_draft\";\n",
   1894         );
   1895         write_file(
   1896             &root,
   1897             "crates/event/src/dto.rs",
   1898             "const OBSOLETE: &str = \"OrderRevision\";\n",
   1899         );
   1900         write_file(
   1901             &root,
   1902             "tools/xtask/src/hygiene.rs",
   1903             "const GUARD: &str = \"trade_order_revision_proposal\";\n",
   1904         );
   1905         validate_forbidden_identifiers(&root).expect("clean tree");
   1906         let _ = fs::remove_dir_all(root);
   1907     }
   1908 
   1909     #[test]
   1910     fn consolidated_repository_accepts_governed_binding_surfaces() {
   1911         let root = unique_temp_dir("consolidated_bindings");
   1912         write_file(
   1913             &root,
   1914             "Cargo.toml",
   1915             "[workspace]\nmembers = []\n\n[workspace.package]\nrepository = \"https://github.com/radrootslabs/lib\"\n\n[workspace.dependencies]\nuniffi = \"0.29\"\nwasm-bindgen = \"0.2\"\n",
   1916         );
   1917         write_file(
   1918             &root,
   1919             "contracts/consolidation/architecture.v1.toml",
   1920             "canonical_rust_repository = \"https://github.com/radrootslabs/lib\"\n",
   1921         );
   1922         fs::create_dir_all(root.join("crates/sdk_ffi")).expect("create FFI crate dir");
   1923         fs::create_dir_all(root.join("crates/event_codec_wasm")).expect("create WASM crate dir");
   1924 
   1925         validate_forbidden_identifiers(&root).expect("consolidated binding surfaces are governed");
   1926         let _ = fs::remove_dir_all(root);
   1927     }
   1928 
   1929     #[test]
   1930     fn forbidden_identifiers_reject_regressions() {
   1931         let root = unique_temp_dir("dirty");
   1932         write_file(
   1933             &root,
   1934             "crates/transport_nostr/src/fetch.rs",
   1935             "fn fetch() { let _ = RadrootsEventIngest::verified; }\n",
   1936         );
   1937         write_file(
   1938             &root,
   1939             "crates/event_store/src/store.rs",
   1940             "pub struct Cursor { pub last_event_id: String }\n",
   1941         );
   1942         write_file(
   1943             &root,
   1944             "crates/trade/src/order.rs",
   1945             "pub struct BadOrder {\n    pub order_id: String,\n}\n",
   1946         );
   1947         write_file(&root, "contracts/events/social-events.md", "tangle\n");
   1948         write_file(
   1949             &root,
   1950             "crates/event/src/kinds.rs",
   1951             "pub const KIND_TRADE_LISTING_ORDER: u64 = 1;\npub const KIND_TRADE_LISTING_VALIDATE_REQ: u64 = 5321;\npub const KIND_LISTING_DRAFT: u32 = 30403;\npub const KIND_CLASSIFIED_LISTING_DRAFT: u32 = 30403;\npub const KIND_LISTING: u32 = 30402;\n",
   1952         );
   1953         write_file(
   1954             &root,
   1955             "contracts/conformance/retired.json",
   1956             "{\"name\":\"trade_order_revision_proposal\",\"type\":\"TradeFulfillmentUpdated\"}\n",
   1957         );
   1958         write_file(
   1959             &root,
   1960             "Cargo.toml",
   1961             "[workspace]\n[workspace.dependencies]\nwasm-bindgen = \"0.2\"\nuniffi = \"0.29\"\n",
   1962         );
   1963         fs::create_dir_all(root.join("crates/sql_wasm_bridge")).expect("create wasm crate dir");
   1964         fs::create_dir_all(root.join("scripts")).expect("create scripts dir");
   1965         fs::create_dir_all(root.join("contracts/sdk-exports")).expect("create sdk exports dir");
   1966         let err = validate_forbidden_identifiers(&root).expect_err("dirty tree");
   1967         assert!(err.contains("relay fetch must not bypass event-store verification"));
   1968         assert!(err.contains("event-store projection cursors must use last_event_seq"));
   1969         assert!(err.contains("raw commercial protocol identifier String fields are forbidden"));
   1970         assert!(err.contains("removed identifier 'tangle' must not reappear"));
   1971         assert!(err.contains("removed trade and DVM kind constants must not reappear"));
   1972         assert!(err.contains("retired V1 public event surfaces must not reappear"));
   1973         assert!(err.contains("retired listing public aliases must not reappear"));
   1974         assert!(err.contains("wasm-bindgen"));
   1975         assert!(err.contains("uniffi"));
   1976         assert!(err.contains("crates/sql_wasm_bridge"));
   1977         assert!(err.contains("scripts"));
   1978         assert!(err.contains("contracts/sdk-exports"));
   1979         let _ = fs::remove_dir_all(root);
   1980     }
   1981 
   1982     #[test]
   1983     fn listing_alias_guard_is_token_and_path_aware() {
   1984         let clean_root = unique_temp_dir("listing_alias_clean");
   1985         write_file(&clean_root, "crates/event/src/lib.rs", "pub mod listing;\n");
   1986         write_file(
   1987             &clean_root,
   1988             "crates/event/src/listing.rs",
   1989             "pub struct CanonicalListing;\n",
   1990         );
   1991         write_file(
   1992             &clean_root,
   1993             "crates/event/src/kinds.rs",
   1994             "pub const CLASSIFIED_LISTING_EVENT_KINDS: [u32; 1] = [KIND_CLASSIFIED_LISTING];\n",
   1995         );
   1996         write_file(
   1997             &clean_root,
   1998             "crates/event/src/operational_listing.rs",
   1999             "pub struct OperationalListing;\n",
   2000         );
   2001         write_file(
   2002             &clean_root,
   2003             "crates/event/src/contract/registry_v7/tests.rs",
   2004             "assert!(event_contract(\"radroots.listing.published.v1\").is_none());\n",
   2005         );
   2006         validate_forbidden_identifiers(&clean_root).expect("new listing taxonomy is accepted");
   2007         let _ = fs::remove_dir_all(clean_root);
   2008 
   2009         let dirty_root = unique_temp_dir("listing_alias_dirty");
   2010         write_file(
   2011             &dirty_root,
   2012             "crates/event/src/listing.rs",
   2013             "pub const LISTING_EVENT_KINDS: [u32; 1] = [KIND_LISTING];\n",
   2014         );
   2015         write_file(
   2016             &dirty_root,
   2017             "contracts/conformance/listing.json",
   2018             "{\"event_contract\":\"radroots.listing.published.v1\"}\n",
   2019         );
   2020         write_file(
   2021             &dirty_root,
   2022             "crates/event_codec/tests/listing.rs",
   2023             "#[test]\nfn legacy_module_path() {}\n",
   2024         );
   2025         write_file(
   2026             &dirty_root,
   2027             "contracts/conformance/vectors/listing/build_tags.v1.json",
   2028             "{}\n",
   2029         );
   2030         write_file(
   2031             &dirty_root,
   2032             "crates/event_codec/tests/retired_aliases.rs",
   2033             "use radroots_trade::{RadrootsPublicListingAddress, listing_from_event, listing_tags_with_options};\nconst _: &str = RADROOTS_LISTING_PRODUCT_TAG_KEYS;\n",
   2034         );
   2035         let err =
   2036             validate_forbidden_identifiers(&dirty_root).expect_err("old aliases are rejected");
   2037         assert!(err.contains("retired listing public aliases must not reappear"));
   2038         assert!(err.contains("legacy listing module path"));
   2039         assert!(err.contains(RETIRED_LISTING_CONTRACT_ID));
   2040         assert!(err.contains("crates/event_codec/tests/listing.rs"));
   2041         assert!(err.contains("RadrootsPublicListingAddress"));
   2042         assert!(err.contains("listing_from_event"));
   2043         assert!(err.contains("listing_tags_with_options"));
   2044         assert!(err.contains("contracts/conformance/vectors/listing/build_tags.v1.json"));
   2045         assert!(err.contains("RADROOTS_LISTING_PRODUCT_TAG_KEYS"));
   2046         let _ = fs::remove_dir_all(dirty_root);
   2047     }
   2048 
   2049     fn prototype_config(
   2050         mode: &str,
   2051         pattern_id: &str,
   2052         needle: &str,
   2053         match_kind: &str,
   2054         allow: Option<(&str, &str, &str)>,
   2055     ) -> String {
   2056         let allow = allow.map_or_else(String::new, |(path, line_contains, reason)| {
   2057             format!(
   2058                 r#"
   2059 [[allow]]
   2060 pattern_id = "{pattern_id}"
   2061 path = "{path}"
   2062 line_contains = "{line_contains}"
   2063 reason = "{reason}"
   2064 "#
   2065             )
   2066         });
   2067         format!(
   2068             r#"schema = "{PROTOTYPE_CONTRACT_SCHEMA}"
   2069 mode = "{mode}"
   2070 
   2071 [scan]
   2072 roots = ["src", "docs"]
   2073 path_roots = ["."]
   2074 path_excludes = [".git", "target"]
   2075 extensions = ["capnp", "md", "rs", "toml", "ts"]
   2076 extensionless_names = [".gitignore", "README"]
   2077 
   2078 [limits]
   2079 max_scan_entries = 100
   2080 max_inventory_bytes = 4096
   2081 max_file_bytes = 1024
   2082 max_matches = 16
   2083 max_reported_findings = 4
   2084 max_reported_allowlisted = 4
   2085 
   2086 [[pattern]]
   2087 id = "{pattern_id}"
   2088 needle = "{needle}"
   2089 match_kind = "{match_kind}"
   2090 description = "test prototype pattern"
   2091 {allow}"#
   2092         )
   2093     }
   2094 
   2095     #[test]
   2096     fn prototype_guard_reports_matches_and_narrow_allowlists() {
   2097         let root = unique_temp_dir("prototype_report");
   2098         let needle = ["config", ".env"].concat();
   2099         write_file(
   2100             &root,
   2101             "contracts/test-prototype-guard.toml",
   2102             &prototype_config(
   2103                 "report_only",
   2104                 "config-environment",
   2105                 &needle,
   2106                 "substring",
   2107                 Some((
   2108                     "docs/history.md",
   2109                     "historical fixture",
   2110                     "Historical fixture text is not an active configuration path.",
   2111                 )),
   2112             ),
   2113         );
   2114         write_file(
   2115             &root,
   2116             "src/config.rs",
   2117             &format!("const PROTOTYPE: &str = \"{needle}\";\n"),
   2118         );
   2119         write_file(
   2120             &root,
   2121             "docs/history.md",
   2122             &format!("historical fixture: {needle}\nactive example: {needle}\n"),
   2123         );
   2124 
   2125         let config =
   2126             load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml"))
   2127                 .expect("load prototype guard config");
   2128         let report = scan_prototype_contracts(&root, &config).expect("scan prototype contracts");
   2129         assert_eq!(report.findings.len(), 2);
   2130         assert_eq!(report.allowed.len(), 1);
   2131         assert_eq!(report.findings[0].path, "docs/history.md");
   2132         assert_eq!(report.findings[0].origin, PrototypeFindingOrigin::Content);
   2133         assert_eq!(report.findings[1].path, "src/config.rs");
   2134         assert_eq!(report.allowed[0].finding.path, "docs/history.md");
   2135 
   2136         run_prototype_contract_guard(
   2137             &[
   2138                 "--config".to_string(),
   2139                 "contracts/test-prototype-guard.toml".to_string(),
   2140             ],
   2141             &root,
   2142         )
   2143         .expect("report-only prototype guard");
   2144         let strict_error = run_prototype_contract_guard(
   2145             &[
   2146                 "--config".to_string(),
   2147                 "contracts/test-prototype-guard.toml".to_string(),
   2148                 "--strict".to_string(),
   2149             ],
   2150             &root,
   2151         )
   2152         .expect_err("strict prototype guard rejects findings");
   2153         assert!(strict_error.contains("2 non-allowlisted match(es)"));
   2154         let _ = fs::remove_dir_all(root);
   2155     }
   2156 
   2157     #[test]
   2158     fn prototype_guard_word_prefix_avoids_embedded_false_positives() {
   2159         let root = unique_temp_dir("prototype_word_prefix");
   2160         let prefix = ["com", "pat"].concat();
   2161         write_file(
   2162             &root,
   2163             "contracts/test-prototype-guard.toml",
   2164             &prototype_config(
   2165                 "strict",
   2166                 "compatibility-concept",
   2167                 &prefix,
   2168                 "word_prefix",
   2169                 Some((
   2170                     "docs/interoperability.md",
   2171                     "compatible peer",
   2172                     "External interoperability is not a compatibility implementation path.",
   2173                 )),
   2174             ),
   2175         );
   2176         write_file(
   2177             &root,
   2178             "docs/interoperability.md",
   2179             "incompatible input\ncompatible peer\nCompatReader\nÉcompatReader\n",
   2180         );
   2181         write_file(&root, "src/clean.rs", "fn current_contract() {}\n");
   2182 
   2183         let config =
   2184             load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml"))
   2185                 .expect("load prototype guard config");
   2186         let report = scan_prototype_contracts(&root, &config).expect("scan prototype contracts");
   2187         assert_eq!(report.findings.len(), 1);
   2188         assert_eq!(report.findings[0].line, Some(3));
   2189         assert_eq!(report.findings[0].excerpt, "CompatReader");
   2190         assert_eq!(report.allowed.len(), 1);
   2191         assert_eq!(report.allowed[0].finding.line, Some(2));
   2192 
   2193         let unsafe_path =
   2194             parse_prototype_guard_args(&["--config".to_string(), "../outside.toml".to_string()])
   2195                 .expect_err("parent traversal must fail");
   2196         assert!(unsafe_path.contains("normalized repository-relative path"));
   2197         let duplicate_mode =
   2198             parse_prototype_guard_args(&["--strict".to_string(), "--report-only".to_string()])
   2199                 .expect_err("duplicate mode must fail");
   2200         assert!(duplicate_mode.contains("only one mode override"));
   2201         let _ = fs::remove_dir_all(root);
   2202     }
   2203 
   2204     #[test]
   2205     fn prototype_guard_rejects_broad_or_stale_allowlists_and_symlinks() {
   2206         let root = unique_temp_dir("prototype_allowlist_integrity");
   2207         let needle = ["import", "_json"].concat();
   2208         write_file(
   2209             &root,
   2210             "contracts/test-prototype-guard.toml",
   2211             &prototype_config(
   2212                 "report_only",
   2213                 "state-import-identifier",
   2214                 &needle,
   2215                 "substring",
   2216                 Some((
   2217                     "src/import.rs",
   2218                     "import",
   2219                     "A test allowance that is intentionally too broad.",
   2220                 )),
   2221             ),
   2222         );
   2223         write_file(
   2224             &root,
   2225             "src/import.rs",
   2226             &format!("fn {needle}() {{}}\nfn second_{needle}() {{}}\n"),
   2227         );
   2228         write_file(&root, "docs/README", "Current contract.\n");
   2229         let config =
   2230             load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml"))
   2231                 .expect("load prototype guard config");
   2232         let broad_error = scan_prototype_contracts(&root, &config)
   2233             .expect_err("one allowance must not authorize multiple matching lines");
   2234         assert!(broad_error.contains("must match exactly one line (matched 2)"));
   2235 
   2236         write_file(&root, "src/import.rs", "fn current_name() {}\n");
   2237         let stale_error =
   2238             scan_prototype_contracts(&root, &config).expect_err("stale allowance must fail closed");
   2239         assert!(stale_error.contains("must match exactly one line (matched 0)"));
   2240 
   2241         #[cfg(unix)]
   2242         {
   2243             use std::os::unix::fs::symlink;
   2244 
   2245             let outside = unique_temp_dir("prototype_symlink_target");
   2246             write_file(&outside, "forbidden.rs", &format!("fn {needle}() {{}}\n"));
   2247             symlink(outside.join("forbidden.rs"), root.join("src/linked.rs"))
   2248                 .expect("create scan symlink");
   2249             let symlink_error = scan_prototype_contracts(&root, &config)
   2250                 .expect_err("symlinked source must fail closed");
   2251             assert!(symlink_error.contains("refuses symlinked scan input"));
   2252             fs::remove_file(root.join("src/linked.rs")).expect("remove direct scan symlink");
   2253 
   2254             symlink(&outside, root.join("linked-root")).expect("create intermediate scan symlink");
   2255             let linked_root_source = prototype_config(
   2256                 "report_only",
   2257                 "state-import-identifier",
   2258                 &needle,
   2259                 "substring",
   2260                 None,
   2261             )
   2262             .replace("roots = [\"src\", \"docs\"]", "roots = [\"linked-root\"]")
   2263             .replace("path_roots = [\".\"]", "path_roots = [\"docs\"]")
   2264             .replace(
   2265                 "path_excludes = [\".git\", \"target\"]",
   2266                 "path_excludes = []",
   2267             );
   2268             write_file(
   2269                 &root,
   2270                 "contracts/linked-root-guard.toml",
   2271                 &linked_root_source,
   2272             );
   2273             let linked_root_config =
   2274                 load_prototype_guard_config(&root, Path::new("contracts/linked-root-guard.toml"))
   2275                     .expect("load intermediate symlink scan config");
   2276             let linked_root_error = scan_prototype_contracts(&root, &linked_root_config)
   2277                 .expect_err("intermediate scan-root symlink must fail closed");
   2278             assert!(linked_root_error.contains("must not contain a symlinked component"));
   2279 
   2280             fs::create_dir_all(root.join("configs")).expect("create config parent");
   2281             symlink(&outside, root.join("configs/linked"))
   2282                 .expect("create intermediate config symlink");
   2283             write_file(&outside, "guard.toml", &linked_root_source);
   2284             let linked_config_error =
   2285                 load_prototype_guard_config(&root, Path::new("configs/linked/guard.toml"))
   2286                     .expect_err("intermediate config symlink must fail closed");
   2287             assert!(linked_config_error.contains("must not contain a symlinked component"));
   2288             let _ = fs::remove_dir_all(outside);
   2289         }
   2290         let _ = fs::remove_dir_all(root);
   2291     }
   2292 
   2293     #[test]
   2294     fn prototype_guard_scans_paths_non_rust_inputs_and_required_roots() {
   2295         let root = unique_temp_dir("prototype_path_and_fixture_scan");
   2296         let path_needle = ["identity", ".example.json"].concat();
   2297         let content_needle = ["allow", "_generate_identity"].concat();
   2298         let env_path_needle = [".env", ".example"].concat();
   2299         let worker_path_needle = ["workers", "/rhi"].concat();
   2300         let config_source = prototype_config(
   2301             "report_only",
   2302             "identity-example-path",
   2303             &path_needle,
   2304             "substring",
   2305             None,
   2306         )
   2307         .replace(
   2308             "roots = [\"src\", \"docs\"]",
   2309             "roots = [\"src\", \"docs\", \".gitignore\"]",
   2310         )
   2311         .replace(
   2312             "description = \"test prototype pattern\"",
   2313             "description = \"test prototype pattern\"\nmatch_path = true",
   2314         ) + &format!(
   2315             r#"
   2316 [[pattern]]
   2317 id = "identity-generation-content"
   2318 needle = "{content_needle}"
   2319 match_kind = "substring"
   2320 description = "test non-Rust fixture pattern"
   2321 
   2322 [[pattern]]
   2323 id = "environment-example-path"
   2324 needle = "{env_path_needle}"
   2325 match_kind = "substring"
   2326 description = "test environment example path"
   2327 match_path = true
   2328 
   2329 [[pattern]]
   2330 id = "worker-directory-path"
   2331 needle = "{worker_path_needle}"
   2332 match_kind = "substring"
   2333 description = "test worker directory path"
   2334 match_path = true
   2335 "#,
   2336         );
   2337         write_file(&root, "contracts/test-prototype-guard.toml", &config_source);
   2338         let identity_path = format!("src/{path_needle}");
   2339         let env_path = env_path_needle.clone();
   2340         let worker_path = format!("src/{worker_path_needle}");
   2341         write_file(&root, &identity_path, "{}\n");
   2342         write_file(&root, &env_path, "CURRENT_SETTING=true\n");
   2343         write_file(&root, ".gitignore", &format!("# {content_needle}\n"));
   2344         fs::create_dir_all(root.join(&worker_path)).expect("create forbidden worker path");
   2345         write_file(
   2346             &root,
   2347             "src/generated.ts",
   2348             &format!("export const flag = \"{content_needle}\";\n"),
   2349         );
   2350         write_file(&root, "src/service.capnp", &format!("# {content_needle}\n"));
   2351         write_file(&root, "docs/README", &format!("{content_needle}\n"));
   2352 
   2353         let config =
   2354             load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml"))
   2355                 .expect("load prototype guard config");
   2356         let report = scan_prototype_contracts(&root, &config).expect("scan active textual inputs");
   2357         assert_eq!(report.findings.len(), 7);
   2358         for path in [&env_path, &identity_path, &worker_path] {
   2359             assert!(report.findings.iter().any(|finding| {
   2360                 finding.path == *path
   2361                     && finding.origin == PrototypeFindingOrigin::Path
   2362                     && finding.line.is_none()
   2363             }));
   2364         }
   2365         for path in [
   2366             ".gitignore",
   2367             "docs/README",
   2368             "src/generated.ts",
   2369             "src/service.capnp",
   2370         ] {
   2371             assert!(report.findings.iter().any(|finding| {
   2372                 finding.path == path && finding.origin == PrototypeFindingOrigin::Content
   2373             }));
   2374         }
   2375 
   2376         fs::remove_dir_all(root.join("docs")).expect("remove required scan root");
   2377         let missing_error = scan_prototype_contracts(&root, &config)
   2378             .expect_err("a missing required scan root must fail closed");
   2379         assert!(missing_error.contains("prototype guard scan root component"));
   2380         let _ = fs::remove_dir_all(root);
   2381     }
   2382 
   2383     #[cfg(unix)]
   2384     #[test]
   2385     fn prototype_guard_git_inventory_ignores_workstation_symlinks() {
   2386         use std::os::unix::fs::symlink;
   2387 
   2388         let root = unique_temp_dir("prototype_git_inventory");
   2389         let needle = [".env", ".example"].concat();
   2390         let config_source = prototype_config(
   2391             "report_only",
   2392             "environment-example-path",
   2393             &needle,
   2394             "substring",
   2395             None,
   2396         )
   2397         .replace(
   2398             "description = \"test prototype pattern\"",
   2399             "description = \"test prototype pattern\"\nmatch_path = true",
   2400         );
   2401         write_file(&root, "contracts/test-prototype-guard.toml", &config_source);
   2402         write_file(&root, "src/current.rs", "fn current_contract() {}\n");
   2403         write_file(&root, "docs/README", "Current contract.\n");
   2404         let ignore = format!(".direnv/\nresult\n.env.*\n!{needle}\n");
   2405         write_file(&root, ".gitignore", &ignore);
   2406         write_file(&root, &needle, "CURRENT_SETTING=true\n");
   2407         let init = Command::new("git")
   2408             .args(["init", "-q"])
   2409             .current_dir(&root)
   2410             .status()
   2411             .expect("run git init");
   2412         assert!(init.success());
   2413 
   2414         let config =
   2415             load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml"))
   2416                 .expect("load prototype guard config");
   2417         let before = scan_prototype_contracts(&root, &config).expect("scan governed Git source");
   2418         assert_eq!(before.findings.len(), 1);
   2419         assert_eq!(before.findings[0].path, needle);
   2420         assert_eq!(before.findings[0].origin, PrototypeFindingOrigin::Path);
   2421 
   2422         let control_path = "src/control\n\u{1b}.rs";
   2423         write_file(&root, control_path, "fn current_contract() {}\n");
   2424         let control_error = scan_prototype_contracts(&root, &config)
   2425             .expect_err("control characters in Git paths must fail closed");
   2426         assert_eq!(control_error.lines().count(), 1);
   2427         assert!(!control_error.contains('\u{1b}'));
   2428         assert!(control_error.contains("control\\n\\u{1b}.rs"));
   2429         fs::remove_file(root.join(control_path)).expect("remove control-character path");
   2430 
   2431         let outside = unique_temp_dir("prototype_ignored_symlink_target");
   2432         write_file(&outside, "ignored.rs", "Current ignored cache.\n");
   2433         fs::create_dir_all(root.join(".direnv")).expect("create ignored environment cache");
   2434         symlink(outside.join("ignored.rs"), root.join(".direnv/linked.rs"))
   2435             .expect("create ignored environment symlink");
   2436         symlink(&outside, root.join("result")).expect("create ignored Nix result symlink");
   2437 
   2438         let after = scan_prototype_contracts(&root, &config)
   2439             .expect("ignored workstation symlinks must not enter the source inventory");
   2440         assert_eq!(after, before);
   2441         let _ = fs::remove_dir_all(outside);
   2442         let _ = fs::remove_dir_all(root);
   2443     }
   2444 
   2445     #[test]
   2446     fn prototype_guard_git_inventory_parser_enforces_byte_and_entry_bounds() {
   2447         let root = unique_temp_dir("prototype_git_inventory_bounds");
   2448         write_file(&root, "src/one.rs", "fn one() {}\n");
   2449         write_file(&root, "src/two.rs", "fn two() {}\n");
   2450         let inventory = b"src/one.rs\0src/two.rs\0";
   2451 
   2452         let parsed = parse_git_inventory(&root, &inventory[..], 2, inventory.len())
   2453             .expect("bounded inventory must parse");
   2454         assert_eq!(parsed.len(), 2);
   2455 
   2456         let byte_error = parse_git_inventory(&root, &inventory[..], 2, inventory.len() - 1)
   2457             .expect_err("inventory bytes above the configured ceiling must fail");
   2458         assert!(byte_error.contains("configured byte limit"));
   2459 
   2460         let entry_error = parse_git_inventory(&root, &inventory[..], 1, inventory.len())
   2461             .expect_err("inventory entries above the configured ceiling must fail");
   2462         assert!(entry_error.contains("configured entry limit 1"));
   2463 
   2464         let delimiter_error =
   2465             parse_git_inventory(&root, &inventory[..inventory.len() - 1], 2, 4096)
   2466                 .expect_err("unterminated Git inventory must fail");
   2467         assert!(delimiter_error.contains("without a NUL delimiter"));
   2468 
   2469         let escaped = bounded_excerpt("safe\tvalue\u{1b}[31m");
   2470         assert_eq!(escaped, "safe\\tvalue\\u{1b}[31m");
   2471         assert!(!escaped.chars().any(char::is_control));
   2472         let _ = fs::remove_dir_all(root);
   2473     }
   2474 
   2475     #[test]
   2476     fn prototype_guard_bounds_configuration_bytes_counts_and_reasons() {
   2477         let root = unique_temp_dir("prototype_config_bounds");
   2478         let config_path = "contracts/test-prototype-guard.toml";
   2479         write_file(
   2480             &root,
   2481             config_path,
   2482             &"x".repeat(PROTOTYPE_MAX_CONFIG_BYTES as usize + 1),
   2483         );
   2484         let bytes_error = load_prototype_guard_config(&root, Path::new(config_path))
   2485             .expect_err("oversized configuration must fail before parsing");
   2486         assert!(bytes_error.contains("exceeds 1048576 bytes"));
   2487 
   2488         let needle = ["config", ".env"].concat();
   2489         let oversized_reason = "r".repeat(PROTOTYPE_MAX_REASON_BYTES + 1);
   2490         let reason_config = prototype_config(
   2491             "report_only",
   2492             "config-environment",
   2493             &needle,
   2494             "substring",
   2495             Some(("src/config.rs", "prototype", &oversized_reason)),
   2496         );
   2497         write_file(&root, config_path, &reason_config);
   2498         let reason_error = load_prototype_guard_config(&root, Path::new(config_path))
   2499             .expect_err("oversized printed reason must fail validation");
   2500         assert!(reason_error.contains("requires a reason"));
   2501 
   2502         let mut pattern_config =
   2503             prototype_config("report_only", "pattern-0", &needle, "substring", None);
   2504         for index in 1..=PROTOTYPE_MAX_CONFIG_PATTERNS {
   2505             pattern_config.push_str(&format!(
   2506                 r#"
   2507 [[pattern]]
   2508 id = "pattern-{index}"
   2509 needle = "current-{index}"
   2510 match_kind = "substring"
   2511 description = "bounded pattern"
   2512 "#,
   2513             ));
   2514         }
   2515         write_file(&root, config_path, &pattern_config);
   2516         let count_error = load_prototype_guard_config(&root, Path::new(config_path))
   2517             .expect_err("excessive pattern count must fail validation");
   2518         assert!(count_error.contains("configuration collection exceeds compiled limit"));
   2519         let _ = fs::remove_dir_all(root);
   2520     }
   2521 
   2522     #[test]
   2523     fn prototype_guard_enforces_file_and_match_resource_bounds() {
   2524         let root = unique_temp_dir("prototype_resource_bounds");
   2525         let needle = ["config", ".env"].concat();
   2526         write_file(
   2527             &root,
   2528             "contracts/test-prototype-guard.toml",
   2529             &prototype_config(
   2530                 "report_only",
   2531                 "config-environment",
   2532                 &needle,
   2533                 "substring",
   2534                 None,
   2535             ),
   2536         );
   2537         write_file(&root, "docs/README", "Current contract.\n");
   2538         write_file(&root, "src/large.rs", &"x".repeat(1025));
   2539         let config =
   2540             load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml"))
   2541                 .expect("load prototype guard config");
   2542         let size_error = scan_prototype_contracts(&root, &config)
   2543             .expect_err("oversized source input must fail closed");
   2544         assert!(size_error.contains("exceeds 1024 bytes"));
   2545 
   2546         write_file(&root, "src/large.rs", &format!("{needle}\n").repeat(17));
   2547         let match_error = scan_prototype_contracts(&root, &config)
   2548             .expect_err("excessive matches must fail closed");
   2549         assert!(match_error.contains("match count exceeds configured limit 16"));
   2550         let _ = fs::remove_dir_all(root);
   2551     }
   2552 
   2553     #[test]
   2554     fn run_dispatches_forbidden_identifiers() {
   2555         let root = unique_temp_dir("run");
   2556         write_file(
   2557             &root,
   2558             "crates/transport_nostr/src/fetch.rs",
   2559             "fn fetch() { let _ = RadrootsEventIngest::new; }\n",
   2560         );
   2561         run(&["forbidden-identifiers".to_string()], &root).expect("hygiene run");
   2562         let unknown = run(&["unknown".to_string()], &root).expect_err("unknown hygiene command");
   2563         assert!(unknown.contains("unknown hygiene subcommand"));
   2564         let _ = fs::remove_dir_all(root);
   2565     }
   2566 }