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 }