commit 1162b1c3f2f8bc05d3616945751727123c233b53 parent 349ca744e7051138e16be9d902d095ae4c2cd125 Author: triesap <tyson@radroots.org> Date: Sun, 9 Aug 2026 03:05:15 +0000 build: add prototype contract source guard - add a bounded report-only prototype contract census - wire the guard through xtask and Nix contract surfaces - enforce exact allowlists and fail-closed source discovery - reconcile inherited TOML with the repository formatter Diffstat:
20 files changed, 2198 insertions(+), 243 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md @@ -63,6 +63,9 @@ Before editing code: - `cargo xtask dto-roots --write` after changing configured DTO exports and `cargo xtask dto-roots --check` for exact generated-root freshness - targeted `cargo xtask contract ...`, `cargo xtask coverage ...`, `cargo xtask release ...`, or `cargo xtask hygiene ...` only when narrowing a repo-owned workflow +- `cargo xtask hygiene prototype-contracts` for the governed report-only + service-prototype census; use `--strict` only when the cleanup sequence has + made every non-allowlisted finding release-blocking - if Beads is active, read `.beads/PRIME.md` ## 6. Rust engineering rules diff --git a/AGENT_INSTRUCTIONS.md b/AGENT_INSTRUCTIONS.md @@ -165,6 +165,9 @@ Targeted iteration inside the Nix shell: - `cargo xtask dto-roots --write` after changing configured DTO exports - `cargo xtask release preflight` - `cargo xtask hygiene forbidden-identifiers` +- `cargo xtask hygiene prototype-contracts` for the deterministic report-only + service-prototype census; strict mode is enabled only after the owning + cleanup sequence clears its findings Validation rules: diff --git a/build/nix/apps.nix b/build/nix/apps.nix @@ -90,6 +90,7 @@ in runtimeInputs = common.runtimeInputs.stable; command = '' cargo run -q -p xtask -- hygiene forbidden-identifiers + cargo run -q -p xtask -- hygiene prototype-contracts ''; }; diff --git a/build/nix/checks.nix b/build/nix/checks.nix @@ -216,6 +216,7 @@ in initGit = true; command = '' xtask hygiene forbidden-identifiers + xtask hygiene prototype-contracts ''; }; } diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -195,6 +195,7 @@ let ''; contractCommand = '' cargo run -q -p xtask -- hygiene forbidden-identifiers + cargo run -q -p xtask -- hygiene prototype-contracts cargo check -q ${coreContractCargoArgs} cargo test -q ${coreContractCargoArgs} cargo run -q -p xtask -- contract validate diff --git a/contracts/hygiene/prototype-contracts.v1.toml b/contracts/hygiene/prototype-contracts.v1.toml @@ -0,0 +1,227 @@ +schema = "radroots.prototype-contract-source-guard.v1" +mode = "report_only" + +[scan] +roots = [ + ".cargo", + ".envrc", + ".gitignore", + "AGENTS.md", + "AGENT_INSTRUCTIONS.md", + "BUILD.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "Cargo.lock", + "Cargo.toml", + "README.md", + "build", + "crates", + "deny.toml", + "docs", + "dto_bindgen.toml", + "flake.lock", + "flake.nix", + "fuzz", + "rust-toolchain-coverage.toml", + "rust-toolchain.toml", + "tools", + "treefmt.nix", +] +path_roots = ["."] +path_excludes = [".direnv", ".git", ".treefmt-cache", "result", "target"] +extensions = [ + "capnp", + "csv", + "dot", + "json", + "lock", + "md", + "nix", + "rs", + "sha256", + "sh", + "sql", + "toml", + "ts", + "txt", +] +extensionless_names = [ + ".envrc", + ".gitignore", + "LICENSE-APACHE", + "LICENSE-MIT", + "README", +] + +[limits] +max_scan_entries = 20000 +max_inventory_bytes = 33554432 +max_file_bytes = 8388608 +max_matches = 4096 +max_reported_findings = 200 +max_reported_allowlisted = 200 + +[[pattern]] +id = "config-env" +needle = "config.env" +match_kind = "substring" +description = "prototype environment-file configuration selector" + +[[pattern]] +id = "env-example" +needle = ".env.example" +match_kind = "substring" +description = "prototype service environment example" +match_path = true + +[[pattern]] +id = "env-file-flag" +needle = "--env-file" +match_kind = "substring" +description = "prototype environment-file CLI flag" + +[[pattern]] +id = "myc-paths-environment" +needle = "MYC_PATHS_" +match_kind = "substring" +description = "prototype Myc path environment contract" + +[[pattern]] +id = "rhi-paths-environment" +needle = "RHI_PATHS_" +match_kind = "substring" +description = "prototype RHI path environment contract" + +[[pattern]] +id = "trade-validation-receipt" +needle = "trade_validation_receipt" +match_kind = "substring" +description = "prototype trade validation receipt surface" + +[[pattern]] +id = "json-file-state" +needle = "JsonFile" +match_kind = "substring" +description = "prototype JSON mutable-state backend" + +[[pattern]] +id = "jsonl-file-state" +needle = "JsonlFile" +match_kind = "substring" +description = "prototype JSONL mutable-state backend" + +[[pattern]] +id = "identity-auto-generation" +needle = "allow_generate_identity" +match_kind = "substring" +description = "prototype ordinary-run identity generation" + +[[pattern]] +id = "identity-json-example" +needle = "identity.example.json" +match_kind = "substring" +description = "prototype plaintext identity example" +match_path = true + +[[pattern]] +id = "rhi-worker-path" +needle = "workers/rhi" +match_kind = "substring" +description = "prototype RHI worker path" +match_path = true + +[[pattern]] +id = "external-command-provider" +needle = "external_command" +match_kind = "substring" +description = "prototype executable signer-provider selector" + +[[pattern]] +id = "logging-output-directory" +needle = "logging.output_dir" +match_kind = "substring" +description = "prototype daemon-owned log-directory selector" + +[[pattern]] +id = "never-rolling-appender" +needle = "rolling::never" +match_kind = "substring" +description = "prototype service-owned non-rolling file logger" + +[[pattern]] +id = "daily-rolling-appender" +needle = "rolling::daily" +match_kind = "substring" +description = "prototype service-owned daily file logger" + +[[pattern]] +id = "import-json-flag" +needle = "import-json" +match_kind = "substring" +description = "prototype mutable-state import CLI surface" + +[[pattern]] +id = "import-json-identifier" +needle = "import_json" +match_kind = "substring" +description = "prototype mutable-state import implementation surface" + +[[pattern]] +id = "legacy-concept" +needle = "legacy" +match_kind = "word_prefix" +description = "legacy product or compatibility concept requiring review" +path_prefixes = [ + "crates/runtime_paths", + "crates/secrets", + "crates/service_host", + "crates/service_sqlite", +] + +[[pattern]] +id = "compatibility-concept" +needle = "compat" +match_kind = "word_prefix" +description = "compatibility product concept requiring review" +path_prefixes = [ + "crates/runtime_paths", + "crates/secrets", + "crates/service_host", + "crates/service_sqlite", +] + +[[pattern]] +id = "deprecated-concept" +needle = "deprecated" +match_kind = "word_prefix" +description = "deprecated product concept requiring review" +path_prefixes = [ + "crates/runtime_paths", + "crates/secrets", + "crates/service_host", + "crates/service_sqlite", +] + +[[allow]] +pattern_id = "import-json-identifier" +path = "crates/replica_store_wasm/src/wasm_impl.rs" +line_contains = "#[wasm_bindgen(js_name = replica_store_import_json)]" +reason = "The replica-store interchange API is not a Myc or RHI mutable service-state importer." + +[[allow]] +pattern_id = "import-json-identifier" +path = "crates/replica_store_wasm/src/wasm_impl.rs" +line_contains = "pub fn replica_store_import_json" +reason = "The replica-store interchange API is not a Myc or RHI mutable service-state importer." + +[[allow]] +pattern_id = "import-json-identifier" +path = "tools/xtask/src/sdk_generation/wasm_declarations.rs" +line_contains = "replica_store_import_json" +reason = "The generated replica-store interchange declaration is not a Myc or RHI mutable service-state importer." + +[[allow]] +pattern_id = "compatibility-concept" +path = "crates/secrets/src/wrapping.rs" +line_contains = "dyn-compatible data-key wrapping" +reason = "This describes Rust trait object safety rather than a Radroots-owned compatibility path." diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -21,25 +21,38 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [features] default = [] mobile-social = [ - "radroots_sdk/blossom", - "radroots_sdk/nostr", - "radroots_sdk/sync", + "radroots_sdk/blossom", + "radroots_sdk/nostr", + "radroots_sdk/sync", ] [dependencies] radroots_blossom = { workspace = true, default-features = false, features = [ - "serde", - "std", + "serde", + "std", ] } radroots_sdk = { workspace = true, features = ["sqlite"] } -radroots_event = { workspace = true, default-features = false, features = ["std"] } -radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] } -radroots_identity = { workspace = true, default-features = false, features = ["std"] } -radroots_protocol = { workspace = true, default-features = false, features = ["std"] } -radroots_signing = { workspace = true, default-features = false, features = ["std"] } +radroots_event = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_event_codec = { workspace = true, default-features = false, features = [ + "json", + "std", +] } +radroots_identity = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_protocol = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_signing = { workspace = true, default-features = false, features = [ + "std", +] } radroots_storage = { workspace = true, default-features = false } radroots_sync = { workspace = true, default-features = false } -radroots_transport = { workspace = true, default-features = false, features = ["std"] } +radroots_transport = { workspace = true, default-features = false, features = [ + "std", +] } radroots_transport_nostr = { workspace = true } chrono = { workspace = true } hex = { workspace = true } diff --git a/crates/mobile_ffi/Cargo.toml b/crates/mobile_ffi/Cargo.toml @@ -43,4 +43,11 @@ nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = " nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } secp256k1 = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } +tokio = { workspace = true, features = [ + "io-util", + "macros", + "net", + "rt-multi-thread", + "sync", + "time", +] } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -118,7 +118,11 @@ nostr = { workspace = true, features = ["std"] } radroots_blossom = { workspace = true } serde_json = { workspace = true, features = ["std"] } tempfile = { workspace = true } -tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread"] } +tokio = { workspace = true, features = [ + "io-util", + "macros", + "rt-multi-thread", +] } [[test]] name = "package_boundary" diff --git a/crates/studio_application/Cargo.toml b/crates/studio_application/Cargo.toml @@ -14,7 +14,12 @@ include = ["src/**", "tests/**", "Cargo.toml"] [dependencies] radroots_studio_domain.workspace = true secrecy = "=0.10.3" -tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } +tokio = { version = "=1.47.1", features = [ + "macros", + "rt-multi-thread", + "sync", + "time", +] } [lints] workspace = true diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml @@ -22,7 +22,12 @@ radroots_studio_domain.workspace = true radroots_studio_nostr.workspace = true radroots_studio_runtime.workspace = true radroots_studio_storage.workspace = true -tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } +tokio = { version = "=1.47.1", features = [ + "macros", + "rt-multi-thread", + "sync", + "time", +] } uniffi = "=0.32.0" [build-dependencies] diff --git a/crates/studio_nostr/Cargo.toml b/crates/studio_nostr/Cargo.toml @@ -23,7 +23,12 @@ tokio = { version = "=1.47.1", features = ["sync", "time"] } [dev-dependencies] nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } -tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } +tokio = { version = "=1.47.1", features = [ + "macros", + "rt-multi-thread", + "sync", + "time", +] } [lints] workspace = true diff --git a/crates/studio_runtime/Cargo.toml b/crates/studio_runtime/Cargo.toml @@ -16,7 +16,12 @@ radroots_studio_application.workspace = true radroots_studio_domain.workspace = true radroots_studio_nostr.workspace = true radroots_studio_storage.workspace = true -tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } +tokio = { version = "=1.47.1", features = [ + "macros", + "rt-multi-thread", + "sync", + "time", +] } uuid.workspace = true [dev-dependencies] diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml @@ -16,7 +16,9 @@ fs2 = "=0.4.3" keyring = "=4.1.6" radroots_studio_application.workspace = true radroots_studio_domain.workspace = true -refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } +refinery = { version = "=0.9.2", default-features = false, features = [ + "rusqlite", +] } getrandom.workspace = true hmac.workspace = true rusqlite = { version = "=0.39.0", features = ["backup", "bundled"] } diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml @@ -53,7 +53,12 @@ tokio-tungstenite = { workspace = true } url = { workspace = true } [dev-dependencies] -tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] } +tokio = { workspace = true, features = [ + "macros", + "net", + "rt-multi-thread", + "time", +] } [lints] workspace = true diff --git a/imports/studio_mpl_legacy_core/Cargo.toml b/imports/studio_mpl_legacy_core/Cargo.toml @@ -1,8 +1,5 @@ [workspace] -members = [ - "crates/core", - "tools/uniffi-bindgen", -] +members = ["crates/core", "tools/uniffi-bindgen"] resolver = "2" [workspace.package] diff --git a/supply-chain/config.toml b/supply-chain/config.toml @@ -83,9 +83,9 @@ criteria = "secret-handling-reviewed" [[exemptions.allocator-api2]] version = "0.2.21" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.android_system_properties]] @@ -115,9 +115,9 @@ criteria = "safe-to-deploy" [[exemptions.anyhow]] version = "1.0.102" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.apple-native-keyring-store]] @@ -291,17 +291,17 @@ criteria = "safe-to-deploy" [[exemptions.bitflags]] version = "2.11.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.block-buffer]] version = "0.10.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.block-buffer]] @@ -311,9 +311,9 @@ criteria = "safe-to-deploy" [[exemptions.block-padding]] version = "0.3.3" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.blocking]] @@ -327,9 +327,9 @@ criteria = "safe-to-deploy" [[exemptions.bumpalo]] version = "3.20.2" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.bytecount]] @@ -375,9 +375,9 @@ criteria = ["network-parser-reviewed", "secret-handling-reviewed"] [[exemptions.cc]] version = "1.2.57" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.cexpr]] @@ -387,9 +387,9 @@ criteria = "safe-to-deploy" [[exemptions.cfg-if]] version = "1.0.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.cfg_aliases]] @@ -415,9 +415,9 @@ criteria = "safe-to-deploy" [[exemptions.cipher]] version = "0.4.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.clang-sys]] @@ -483,9 +483,9 @@ criteria = "safe-to-deploy" [[exemptions.cpufeatures]] version = "0.2.17" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.cpufeatures]] @@ -527,9 +527,9 @@ criteria = "safe-to-deploy" [[exemptions.crypto-common]] version = "0.1.7" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.crypto-common]] @@ -587,9 +587,9 @@ criteria = "safe-to-deploy" [[exemptions.digest]] version = "0.10.7" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.digest]] @@ -675,9 +675,9 @@ criteria = "secret-handling-reviewed" [[exemptions.equivalent]] version = "1.0.2" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.errno]] @@ -727,9 +727,9 @@ criteria = "safe-to-deploy" [[exemptions.find-msvc-tools]] version = "0.1.9" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.flatbuffers]] @@ -751,17 +751,17 @@ criteria = "safe-to-deploy" [[exemptions.foldhash]] version = "0.1.5" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.foldhash]] version = "0.2.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.foreign-types]] @@ -839,25 +839,25 @@ criteria = ["network-parser-reviewed", "secret-handling-reviewed"] [[exemptions.generic-array]] version = "0.14.7" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.getrandom]] version = "0.2.17" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.getrandom]] version = "0.3.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.getrandom]] @@ -899,17 +899,17 @@ criteria = "safe-to-deploy" [[exemptions.hashbrown]] version = "0.15.5" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.hashbrown]] version = "0.16.1" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.hashbrown]] @@ -923,9 +923,9 @@ criteria = "safe-to-deploy" [[exemptions.heck]] version = "0.5.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.hermit-abi]] @@ -947,9 +947,9 @@ criteria = "secret-handling-reviewed" [[exemptions.hmac]] version = "0.12.1" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.home]] @@ -1027,9 +1027,9 @@ criteria = "network-parser-reviewed" [[exemptions.id-arena]] version = "2.3.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.idna]] @@ -1047,17 +1047,17 @@ criteria = "network-parser-reviewed" [[exemptions.indexmap]] version = "2.13.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.inout]] version = "0.1.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.instant]] @@ -1083,9 +1083,9 @@ criteria = "safe-to-deploy" [[exemptions.itoa]] version = "1.0.18" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.jiff-tzdb]] @@ -1095,17 +1095,17 @@ criteria = "safe-to-deploy" [[exemptions.jobserver]] version = "0.1.34" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.js-sys]] version = "0.3.91" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.jsonschema]] @@ -1151,17 +1151,17 @@ criteria = "safe-to-deploy" [[exemptions.leb128fmt]] version = "0.1.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.libc]] version = "0.2.183" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.libdbus-sys]] @@ -1219,9 +1219,9 @@ criteria = "safe-to-deploy" [[exemptions.log]] version = "0.4.29" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.lru]] @@ -1239,9 +1239,9 @@ criteria = "safe-to-deploy" [[exemptions.memchr]] version = "2.8.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.memoffset]] @@ -1371,9 +1371,9 @@ criteria = "safe-to-deploy" [[exemptions.once_cell]] version = "1.21.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.once_cell_polyfill]] @@ -1495,9 +1495,9 @@ criteria = "network-parser-reviewed" [[exemptions.prettyplease]] version = "0.2.37" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.proc-macro-crate]] @@ -1507,10 +1507,10 @@ criteria = "secret-handling-reviewed" [[exemptions.proc-macro2]] version = "1.0.106" criteria = [ - "build-execution-reviewed", - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.quinn]] @@ -1528,18 +1528,18 @@ criteria = "safe-to-deploy" [[exemptions.quote]] version = "1.0.45" criteria = [ - "build-execution-reviewed", - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.r-efi]] version = "5.3.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.r-efi]] @@ -1573,9 +1573,9 @@ criteria = "safe-to-deploy" [[exemptions.rand_core]] version = "0.6.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.rand_core]] @@ -1709,9 +1709,9 @@ criteria = "network-parser-reviewed" [[exemptions.rustversion]] version = "1.0.22" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.ryu]] @@ -1781,17 +1781,17 @@ criteria = "secret-handling-reviewed" [[exemptions.semver]] version = "1.0.27" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.serde]] version = "1.0.228" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.serde-wasm-bindgen]] @@ -1801,25 +1801,25 @@ criteria = "safe-to-deploy" [[exemptions.serde_core]] version = "1.0.228" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.serde_derive]] version = "1.0.228" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.serde_json]] version = "1.0.149" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.serde_repr]] @@ -1845,17 +1845,17 @@ criteria = "network-parser-reviewed" [[exemptions.sha2]] version = "0.10.9" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.sha2-asm]] version = "0.6.4" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.sha3]] @@ -1873,9 +1873,9 @@ criteria = "safe-to-deploy" [[exemptions.shlex]] version = "1.3.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.signal-hook-registry]] @@ -1973,9 +1973,9 @@ criteria = "safe-to-deploy" [[exemptions.subtle]] version = "2.6.1" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.symlink]] @@ -1985,10 +1985,10 @@ criteria = "safe-to-deploy" [[exemptions.syn]] version = "2.0.117" criteria = [ - "build-execution-reviewed", - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.syn]] @@ -2178,9 +2178,9 @@ criteria = "safe-to-deploy" [[exemptions.typenum]] version = "1.20.1" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.uds_windows]] @@ -2198,10 +2198,10 @@ criteria = "safe-to-deploy" [[exemptions.unicode-ident]] version = "1.0.24" criteria = [ - "build-execution-reviewed", - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.unicode-normalization]] @@ -2211,9 +2211,9 @@ criteria = "network-parser-reviewed" [[exemptions.unicode-xid]] version = "0.2.6" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.uniffi]] @@ -2335,9 +2335,9 @@ criteria = "secret-handling-reviewed" [[exemptions.version_check]] version = "0.9.5" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.vsimd]] @@ -2355,17 +2355,17 @@ criteria = "safe-to-deploy" [[exemptions.wasi]] version = "0.11.1+wasi-snapshot-preview1" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasip2]] version = "1.0.2+wasi-0.2.9" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasip3]] @@ -2375,9 +2375,9 @@ criteria = "secret-handling-reviewed" [[exemptions.wasm-bindgen]] version = "0.2.114" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasm-bindgen-futures]] @@ -2387,25 +2387,25 @@ criteria = "network-parser-reviewed" [[exemptions.wasm-bindgen-macro]] version = "0.2.114" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasm-bindgen-macro-support]] version = "0.2.114" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasm-bindgen-shared]] version = "0.2.114" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasm-bindgen-test]] @@ -2423,25 +2423,25 @@ criteria = "safe-to-run" [[exemptions.wasm-encoder]] version = "0.244.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasm-metadata]] version = "0.244.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wasmparser]] version = "0.244.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.web-sys]] @@ -2611,49 +2611,49 @@ criteria = "secret-handling-reviewed" [[exemptions.wit-bindgen]] version = "0.51.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wit-bindgen-core]] version = "0.51.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wit-bindgen-rust]] version = "0.51.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wit-bindgen-rust-macro]] version = "0.51.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wit-component]] version = "0.244.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.wit-parser]] version = "0.244.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.writeable]] @@ -2727,17 +2727,17 @@ criteria = "network-parser-reviewed" [[exemptions.zeroize]] version = "1.9.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.zeroize_derive]] version = "1.5.0" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.zerotrie]] @@ -2763,9 +2763,9 @@ criteria = "safe-to-deploy" [[exemptions.zmij]] version = "1.0.21" criteria = [ - "crypto-reviewed", - "network-parser-reviewed", - "secret-handling-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", ] [[exemptions.zopfli]] diff --git a/tools/xtask/README b/tools/xtask/README @@ -9,6 +9,9 @@ tasks for the `radroots` core libraries. release workflows; * `contract`, `coverage`, `dto-roots`, `release`, and `hygiene` command families for core-library governance; + * configurable deterministic prototype-contract reporting through + `hygiene prototype-contracts`, with explicit narrow allowlists and a future + strict enforcement mode; * deterministic `dto-roots --write|--check` generation for every source-manifest package in the workspace DTO authority; * command-dispatch code used for contract, coverage, hygiene, and release diff --git a/tools/xtask/src/hygiene.rs b/tools/xtask/src/hygiene.rs @@ -1,5 +1,130 @@ +use serde::Deserialize; +use std::collections::HashSet; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const PROTOTYPE_CONTRACT_CONFIG_PATH: &str = "contracts/hygiene/prototype-contracts.v1.toml"; +const PROTOTYPE_CONTRACT_SCHEMA: &str = "radroots.prototype-contract-source-guard.v1"; +const PROTOTYPE_MAX_CONFIG_BYTES: u64 = 1024 * 1024; +const PROTOTYPE_MAX_CONFIG_STRING_BYTES: usize = 1024; +const PROTOTYPE_MAX_CONFIG_PATHS: usize = 256; +const PROTOTYPE_MAX_CONFIG_EXTENSIONS: usize = 128; +const PROTOTYPE_MAX_CONFIG_PATTERNS: usize = 1024; +const PROTOTYPE_MAX_CONFIG_ALLOWLIST: usize = 4096; +const PROTOTYPE_MAX_REASON_BYTES: usize = 512; +const PROTOTYPE_MAX_CONFIGURED_SCAN_ENTRIES: usize = 100_000; +const PROTOTYPE_MAX_CONFIGURED_INVENTORY_BYTES: usize = 64 * 1024 * 1024; +const PROTOTYPE_MAX_CONFIGURED_FILE_BYTES: u64 = 64 * 1024 * 1024; +const PROTOTYPE_MAX_CONFIGURED_MATCHES: usize = 100_000; +const PROTOTYPE_MAX_CONFIGURED_REPORT_LINES: usize = 10_000; +const PROTOTYPE_MAX_GIT_STDERR_BYTES: usize = 8 * 1024; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +enum PrototypeGuardMode { + ReportOnly, + Strict, +} + +impl PrototypeGuardMode { + const fn as_str(self) -> &'static str { + match self { + Self::ReportOnly => "report_only", + Self::Strict => "strict", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +enum PrototypeMatchKind { + Substring, + WordPrefix, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrototypeGuardConfig { + schema: String, + mode: PrototypeGuardMode, + scan: PrototypeScanConfig, + limits: PrototypeGuardLimits, + pattern: Vec<PrototypePattern>, + #[serde(default)] + allow: Vec<PrototypeAllow>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrototypeScanConfig { + roots: Vec<String>, + path_roots: Vec<String>, + path_excludes: Vec<String>, + extensions: Vec<String>, + extensionless_names: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrototypeGuardLimits { + max_scan_entries: usize, + max_inventory_bytes: usize, + max_file_bytes: u64, + max_matches: usize, + max_reported_findings: usize, + max_reported_allowlisted: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrototypePattern { + id: String, + needle: String, + match_kind: PrototypeMatchKind, + description: String, + #[serde(default)] + match_path: bool, + #[serde(default)] + path_prefixes: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrototypeAllow { + pattern_id: String, + path: String, + line_contains: String, + reason: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum PrototypeFindingOrigin { + Path, + Content, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PrototypeFinding { + pattern_id: String, + path: String, + origin: PrototypeFindingOrigin, + line: Option<usize>, + excerpt: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PrototypeAllowedMatch { + finding: PrototypeFinding, + reason: String, +} + +#[derive(Debug, Default, Eq, PartialEq)] +struct PrototypeGuardReport { + findings: Vec<PrototypeFinding>, + allowed: Vec<PrototypeAllowedMatch>, +} const BINDING_DEPENDENCIES: &[&str] = &[ "serde-wasm-bindgen", @@ -125,10 +250,1044 @@ const RETIRED_LISTING_CONTRACT_ID: &str = "radroots.listing.published.v1"; pub fn run(args: &[String], root: &Path) -> Result<(), String> { match args.first().map(String::as_str) { Some("forbidden-identifiers") => validate_forbidden_identifiers(root), + Some("prototype-contracts") => run_prototype_contract_guard(&args[1..], root), _ => Err("unknown hygiene subcommand".to_string()), } } +fn run_prototype_contract_guard(args: &[String], root: &Path) -> Result<(), String> { + let (config_path, mode_override) = parse_prototype_guard_args(args)?; + let config = load_prototype_guard_config(root, &config_path)?; + let mode = mode_override.unwrap_or(config.mode); + let report = scan_prototype_contracts(root, &config)?; + print_prototype_guard_report(mode, &report, &config.limits); + if mode == PrototypeGuardMode::Strict && !report.findings.is_empty() { + return Err(format!( + "prototype contract source guard found {} non-allowlisted match(es)", + report.findings.len() + )); + } + Ok(()) +} + +fn parse_prototype_guard_args( + args: &[String], +) -> Result<(PathBuf, Option<PrototypeGuardMode>), String> { + let mut config_path = PathBuf::from(PROTOTYPE_CONTRACT_CONFIG_PATH); + let mut mode = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--config" => { + let Some(value) = args.get(index + 1) else { + return Err("prototype-contracts --config requires a path".to_string()); + }; + validate_repo_relative_path(value, "prototype guard config path")?; + config_path = PathBuf::from(value); + index += 2; + } + "--strict" => { + set_prototype_mode(&mut mode, PrototypeGuardMode::Strict)?; + index += 1; + } + "--report-only" => { + set_prototype_mode(&mut mode, PrototypeGuardMode::ReportOnly)?; + index += 1; + } + value => return Err(format!("unknown prototype-contracts argument: {value}")), + } + } + Ok((config_path, mode)) +} + +fn set_prototype_mode( + current: &mut Option<PrototypeGuardMode>, + requested: PrototypeGuardMode, +) -> Result<(), String> { + if current.replace(requested).is_some() { + return Err("prototype-contracts accepts only one mode override".to_string()); + } + Ok(()) +} + +fn load_prototype_guard_config( + root: &Path, + relative_path: &Path, +) -> Result<PrototypeGuardConfig, String> { + validate_repo_relative_path( + &relative_path.to_string_lossy(), + "prototype guard config path", + )?; + let path = root.join(relative_path); + reject_symlinked_path_components(root, relative_path, "prototype guard config path")?; + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("inspect prototype guard config {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "prototype guard config must be a regular non-symlink file: {}", + path.display() + )); + } + let display = path.display().to_string(); + let source = read_bounded_prototype_input(&path, &display, PROTOTYPE_MAX_CONFIG_BYTES)?; + let config: PrototypeGuardConfig = toml::from_str(&source) + .map_err(|error| format!("parse prototype guard config {}: {error}", path.display()))?; + validate_prototype_guard_config(&config)?; + Ok(config) +} + +fn validate_prototype_guard_config(config: &PrototypeGuardConfig) -> Result<(), String> { + if config.schema != PROTOTYPE_CONTRACT_SCHEMA { + return Err(format!( + "prototype guard schema must be {PROTOTYPE_CONTRACT_SCHEMA}" + )); + } + if config.scan.roots.is_empty() { + return Err("prototype guard scan roots must not be empty".to_string()); + } + if config.scan.path_roots.is_empty() { + return Err("prototype guard path scan roots must not be empty".to_string()); + } + if config.scan.extensions.is_empty() { + return Err("prototype guard extensions must not be empty".to_string()); + } + if config.limits.max_scan_entries == 0 + || config.limits.max_inventory_bytes == 0 + || config.limits.max_file_bytes == 0 + || config.limits.max_matches == 0 + || config.limits.max_reported_findings == 0 + || config.limits.max_reported_allowlisted == 0 + || config.limits.max_reported_findings > config.limits.max_matches + || config.limits.max_reported_allowlisted > config.limits.max_matches + || config.limits.max_scan_entries > PROTOTYPE_MAX_CONFIGURED_SCAN_ENTRIES + || config.limits.max_inventory_bytes > PROTOTYPE_MAX_CONFIGURED_INVENTORY_BYTES + || config.limits.max_file_bytes > PROTOTYPE_MAX_CONFIGURED_FILE_BYTES + || config.limits.max_matches > PROTOTYPE_MAX_CONFIGURED_MATCHES + || config.limits.max_reported_findings > PROTOTYPE_MAX_CONFIGURED_REPORT_LINES + || config.limits.max_reported_allowlisted > PROTOTYPE_MAX_CONFIGURED_REPORT_LINES + { + return Err( + "prototype guard limits must be positive, report limits must not exceed max_matches, and every value must remain within the compiled resource ceiling" + .to_string(), + ); + } + if config.pattern.is_empty() { + return Err("prototype guard patterns must not be empty".to_string()); + } + if config.scan.roots.len() > PROTOTYPE_MAX_CONFIG_PATHS + || config.scan.path_roots.len() > PROTOTYPE_MAX_CONFIG_PATHS + || config.scan.path_excludes.len() > PROTOTYPE_MAX_CONFIG_PATHS + || config.scan.extensions.len() > PROTOTYPE_MAX_CONFIG_EXTENSIONS + || config.scan.extensionless_names.len() > PROTOTYPE_MAX_CONFIG_EXTENSIONS + || config.pattern.len() > PROTOTYPE_MAX_CONFIG_PATTERNS + || config.allow.len() > PROTOTYPE_MAX_CONFIG_ALLOWLIST + { + return Err("prototype guard configuration collection exceeds compiled limit".to_string()); + } + + let mut roots = HashSet::new(); + for root in &config.scan.roots { + validate_repo_relative_path(root, "prototype guard scan root")?; + if !roots.insert(root.as_str()) { + return Err(format!("duplicate prototype guard scan root: {root}")); + } + } + + let mut path_roots = HashSet::new(); + for root in &config.scan.path_roots { + validate_repo_relative_or_root_path(root, "prototype guard path scan root")?; + if !path_roots.insert(root.as_str()) { + return Err(format!("duplicate prototype guard path scan root: {root}")); + } + } + + let mut path_excludes = HashSet::new(); + for excluded in &config.scan.path_excludes { + validate_repo_relative_path(excluded, "prototype guard path exclusion")?; + if !config + .scan + .path_roots + .iter() + .any(|root| root == "." || repository_path_is_within(excluded, root)) + { + return Err(format!( + "prototype guard path exclusion is outside path scan roots: {excluded}" + )); + } + if !path_excludes.insert(excluded.as_str()) { + return Err(format!( + "duplicate prototype guard path exclusion: {excluded}" + )); + } + } + + let mut extensions = HashSet::new(); + for extension in &config.scan.extensions { + if extension.is_empty() + || extension.len() > 32 + || extension.starts_with('.') + || !extension + .chars() + .all(|character| character.is_ascii_alphanumeric()) + { + return Err(format!( + "invalid prototype guard extension (omit the dot): {extension:?}" + )); + } + if !extensions.insert(extension.as_str()) { + return Err(format!("duplicate prototype guard extension: {extension}")); + } + } + + let mut extensionless_names = HashSet::new(); + for name in &config.scan.extensionless_names { + if name.is_empty() + || name.len() > 128 + || !name + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) + { + return Err(format!( + "invalid prototype guard extensionless file name: {name:?}" + )); + } + if !extensionless_names.insert(name.as_str()) { + return Err(format!( + "duplicate prototype guard extensionless file name: {name}" + )); + } + } + + let mut pattern_ids = HashSet::new(); + for pattern in &config.pattern { + if pattern.id.is_empty() + || pattern.id.len() > 64 + || !pattern.id.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + { + return Err(format!( + "invalid prototype guard pattern id: {:?}", + pattern.id + )); + } + if !pattern_ids.insert(pattern.id.as_str()) { + return Err(format!( + "duplicate prototype guard pattern id: {}", + pattern.id + )); + } + if pattern.needle.is_empty() + || pattern.needle.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES + || pattern.needle.chars().any(char::is_control) + { + return Err(format!( + "prototype guard pattern {} has an invalid needle", + pattern.id + )); + } + if pattern.match_kind == PrototypeMatchKind::WordPrefix && !pattern.needle.is_ascii() { + return Err(format!( + "prototype guard word-prefix pattern {} must use an ASCII needle", + pattern.id + )); + } + if pattern.description.trim().is_empty() + || pattern.description.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES + || pattern.description.chars().any(char::is_control) + { + return Err(format!( + "prototype guard pattern {} requires a description", + pattern.id + )); + } + let mut prefixes = HashSet::new(); + for prefix in &pattern.path_prefixes { + validate_repo_relative_path(prefix, "prototype guard pattern path prefix")?; + if !config + .scan + .roots + .iter() + .any(|root| repository_path_is_within(prefix, root)) + { + return Err(format!( + "prototype guard pattern {} path prefix is outside scan roots: {prefix}", + pattern.id + )); + } + if !prefixes.insert(prefix.as_str()) { + return Err(format!( + "duplicate path prefix for prototype guard pattern {}: {prefix}", + pattern.id + )); + } + } + } + + let mut allow_keys = HashSet::new(); + for allowed in &config.allow { + if !pattern_ids.contains(allowed.pattern_id.as_str()) { + return Err(format!( + "prototype guard allowlist references unknown pattern: {:?}", + allowed.pattern_id + )); + } + validate_repo_relative_path(&allowed.path, "prototype guard allowlist path")?; + if !config + .scan + .roots + .iter() + .any(|root| repository_path_is_within(&allowed.path, root)) + { + return Err(format!( + "prototype guard allowlist path is outside scan roots: {}", + allowed.path + )); + } + let pattern = config + .pattern + .iter() + .find(|pattern| pattern.id == allowed.pattern_id) + .expect("validated pattern id must resolve"); + if !pattern.path_prefixes.is_empty() + && !pattern + .path_prefixes + .iter() + .any(|prefix| repository_path_is_within(&allowed.path, prefix)) + { + return Err(format!( + "prototype guard allowlist path {} is outside pattern {} path prefixes", + allowed.path, allowed.pattern_id + )); + } + if allowed.line_contains.is_empty() + || allowed.line_contains.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES + || allowed.line_contains.chars().any(char::is_control) + { + return Err(format!( + "prototype guard allowlist for {} requires one line fragment", + allowed.pattern_id + )); + } + if allowed.reason.trim().is_empty() + || allowed.reason.len() > PROTOTYPE_MAX_REASON_BYTES + || allowed.reason.chars().any(char::is_control) + { + return Err(format!( + "prototype guard allowlist for {} requires a reason", + allowed.pattern_id + )); + } + let key = ( + allowed.pattern_id.as_str(), + allowed.path.as_str(), + allowed.line_contains.as_str(), + ); + if !allow_keys.insert(key) { + return Err(format!( + "duplicate prototype guard allowlist entry: {} {}", + allowed.pattern_id, allowed.path + )); + } + } + Ok(()) +} + +fn validate_repo_relative_path(value: &str, label: &str) -> Result<(), String> { + let path = Path::new(value); + if value.is_empty() + || value.len() > PROTOTYPE_MAX_CONFIG_STRING_BYTES + || value.chars().any(char::is_control) + || value.contains('\\') + || value.contains(':') + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(format!( + "{label} must be a normalized repository-relative path: {value:?}" + )); + } + Ok(()) +} + +fn validate_repo_relative_or_root_path(value: &str, label: &str) -> Result<(), String> { + if value == "." { + return Ok(()); + } + validate_repo_relative_path(value, label) +} + +fn reject_symlinked_path_components( + root: &Path, + relative_path: &Path, + label: &str, +) -> Result<(), String> { + let mut candidate = root.to_path_buf(); + for component in relative_path.components() { + let std::path::Component::Normal(component) = component else { + return Err(format!( + "{label} must contain only normalized path components: {}", + relative_path.display() + )); + }; + candidate.push(component); + let metadata = fs::symlink_metadata(&candidate).map_err(|error| { + format!("inspect {label} component {}: {error}", candidate.display()) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "{label} must not contain a symlinked component: {}", + candidate.display() + )); + } + } + Ok(()) +} + +fn repository_path_is_within(path: &str, prefix: &str) -> bool { + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn scan_prototype_contracts( + root: &Path, + config: &PrototypeGuardConfig, +) -> Result<PrototypeGuardReport, String> { + let extensions: HashSet<&str> = config.scan.extensions.iter().map(String::as_str).collect(); + let extensionless_names: HashSet<&str> = config + .scan + .extensionless_names + .iter() + .map(String::as_str) + .collect(); + let mut inputs = PrototypeInputs::default(); + for relative_root in &config.scan.roots { + reject_symlinked_path_components( + root, + Path::new(relative_root), + "prototype guard scan root", + )?; + } + for relative_root in &config.scan.path_roots { + let relative_path = Path::new(relative_root); + if relative_root != "." { + reject_symlinked_path_components( + root, + relative_path, + "prototype guard path scan root", + )?; + } + } + if let Some(governed_paths) = git_governed_paths( + root, + config.limits.max_scan_entries, + config.limits.max_inventory_bytes, + )? { + for candidate in governed_paths { + let relative = prototype_display_path(root, &candidate)?; + if path_is_excluded(&relative, &config.scan.path_excludes) { + continue; + } + if path_is_within_any_root(&relative, &config.scan.path_roots) { + inputs.paths.push(candidate.clone()); + } + if path_is_within_any_root(&relative, &config.scan.roots) + && is_prototype_text_input(&candidate, &extensions, &extensionless_names) + { + inputs.files.push(candidate); + } + } + } else { + for relative_root in &config.scan.roots { + collect_prototype_inputs( + root, + &root.join(relative_root), + &extensions, + &extensionless_names, + &config.scan.path_excludes, + config.limits.max_scan_entries, + &mut inputs, + )?; + } + for relative_root in &config.scan.path_roots { + collect_prototype_paths( + root, + &root.join(relative_root), + &config.scan.path_excludes, + config.limits.max_scan_entries, + &mut inputs.paths, + )?; + } + } + inputs.files.sort(); + inputs.files.dedup(); + inputs.paths.sort(); + inputs.paths.dedup(); + if inputs.paths.len() > config.limits.max_scan_entries { + return Err(format!( + "prototype guard scan contains {} entries, limit is {}", + inputs.paths.len(), + config.limits.max_scan_entries + )); + } + + let mut report = PrototypeGuardReport::default(); + let mut allow_match_counts = vec![0_usize; config.allow.len()]; + for candidate in inputs.paths { + let path = prototype_display_path(root, &candidate)?; + for pattern in config.pattern.iter().filter(|pattern| pattern.match_path) { + if !prototype_pattern_matches(&path, &path, pattern) { + continue; + } + record_prototype_match( + config, + &mut report, + &mut allow_match_counts, + PrototypeFinding { + pattern_id: pattern.id.clone(), + path: path.clone(), + origin: PrototypeFindingOrigin::Path, + line: None, + excerpt: bounded_excerpt(&path), + }, + &path, + )?; + } + } + for file in inputs.files { + let path = prototype_display_path(root, &file)?; + let source = read_bounded_prototype_input(&file, &path, config.limits.max_file_bytes)?; + for (line_index, line) in source.lines().enumerate() { + for pattern in &config.pattern { + if !prototype_pattern_matches(&path, line, pattern) { + continue; + } + let finding = PrototypeFinding { + pattern_id: pattern.id.clone(), + path: path.clone(), + origin: PrototypeFindingOrigin::Content, + line: Some(line_index + 1), + excerpt: bounded_excerpt(line), + }; + record_prototype_match( + config, + &mut report, + &mut allow_match_counts, + finding, + line, + )?; + } + } + } + for (allowed, match_count) in config.allow.iter().zip(allow_match_counts) { + if match_count != 1 { + return Err(format!( + "prototype guard allowlist entry must match exactly one line (matched {match_count}): {} {} contains {:?}", + allowed.pattern_id, allowed.path, allowed.line_contains + )); + } + } + report.findings.sort_by(|left, right| { + (&left.path, left.origin, left.line, &left.pattern_id).cmp(&( + &right.path, + right.origin, + right.line, + &right.pattern_id, + )) + }); + report.allowed.sort_by(|left, right| { + ( + &left.finding.path, + left.finding.origin, + left.finding.line, + &left.finding.pattern_id, + ) + .cmp(&( + &right.finding.path, + right.finding.origin, + right.finding.line, + &right.finding.pattern_id, + )) + }); + Ok(report) +} + +fn git_governed_paths( + root: &Path, + max_scan_entries: usize, + max_inventory_bytes: usize, +) -> Result<Option<Vec<PathBuf>>, String> { + if !root.join(".git").exists() { + return Ok(None); + } + let mut child = Command::new("git") + .arg("-C") + .arg(root) + .args([ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("run git source inventory for prototype guard: {error}"))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| "Git source inventory stdout was not captured".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "Git source inventory stderr was not captured".to_string())?; + let stderr_reader = std::thread::spawn(move || read_bounded_and_drain(stderr)); + let inventory = parse_git_inventory(root, stdout, max_scan_entries, max_inventory_bytes); + if inventory.is_err() { + let _ = child.kill(); + } + let status = child + .wait() + .map_err(|error| format!("wait for Git source inventory: {error}"))?; + let stderr = stderr_reader + .join() + .map_err(|_| "Git source inventory stderr reader panicked".to_string())? + .map_err(|error| format!("read Git source inventory stderr: {error}"))?; + let paths = inventory?; + if !status.success() { + return Err(format!( + "Git source inventory for prototype guard failed: {}", + escape_report_text(String::from_utf8_lossy(&stderr).trim()) + )); + } + Ok(Some(paths)) +} + +fn read_bounded_and_drain(mut reader: impl Read) -> std::io::Result<Vec<u8>> { + let mut captured = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + return Ok(captured); + } + let remaining = PROTOTYPE_MAX_GIT_STDERR_BYTES.saturating_sub(captured.len()); + captured.extend_from_slice(&buffer[..read.min(remaining)]); + } +} + +fn parse_git_inventory( + root: &Path, + mut reader: impl Read, + max_scan_entries: usize, + max_inventory_bytes: usize, +) -> Result<Vec<PathBuf>, String> { + let mut paths = Vec::new(); + let mut raw_path = Vec::new(); + let mut total_bytes = 0_usize; + let mut buffer = [0_u8; 4096]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| format!("read Git source inventory: {error}"))?; + if read == 0 { + break; + } + total_bytes = total_bytes.checked_add(read).ok_or_else(|| { + "prototype guard Git source inventory byte count overflowed".to_string() + })?; + if total_bytes > max_inventory_bytes { + return Err(format!( + "prototype guard Git source inventory exceeds configured byte limit {max_inventory_bytes}" + )); + } + for byte in &buffer[..read] { + if *byte != 0 { + if raw_path.len() >= PROTOTYPE_MAX_CONFIG_STRING_BYTES { + return Err(format!( + "prototype guard Git source path exceeds compiled byte limit {PROTOTYPE_MAX_CONFIG_STRING_BYTES}" + )); + } + raw_path.push(*byte); + continue; + } + if raw_path.is_empty() { + continue; + } + if paths.len() >= max_scan_entries { + return Err(format!( + "prototype guard Git source inventory exceeds configured entry limit {max_scan_entries}" + )); + } + let relative = std::str::from_utf8(&raw_path) + .map_err(|error| format!("prototype guard Git path is not UTF-8: {error}"))?; + validate_repo_relative_path(relative, "prototype guard Git source path")?; + let candidate = root.join(relative); + match fs::symlink_metadata(&candidate) { + Ok(_) => { + reject_symlinked_path_components( + root, + Path::new(relative), + "prototype guard Git source path", + )?; + paths.push(candidate); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "inspect prototype guard Git source {}: {error}", + candidate.display() + )); + } + } + raw_path.clear(); + } + } + if !raw_path.is_empty() { + return Err( + "prototype guard Git source inventory ended without a NUL delimiter".to_string(), + ); + } + paths.sort(); + paths.dedup(); + Ok(paths) +} + +fn path_is_within_any_root(path: &str, roots: &[String]) -> bool { + roots + .iter() + .any(|root| root == "." || repository_path_is_within(path, root)) +} + +fn path_is_excluded(path: &str, excluded_prefixes: &[String]) -> bool { + excluded_prefixes + .iter() + .any(|prefix| repository_path_is_within(path, prefix)) +} + +fn prototype_display_path(root: &Path, path: &Path) -> Result<String, String> { + let relative = path.strip_prefix(root).map_err(|_| { + format!( + "prototype guard path is outside repository root: {}", + path.display() + ) + })?; + if relative.as_os_str().is_empty() { + return Ok(".".to_string()); + } + let mut components = Vec::new(); + for component in relative.components() { + let std::path::Component::Normal(component) = component else { + return Err("prototype guard path must contain only normal components".to_string()); + }; + components.push( + component + .to_str() + .ok_or_else(|| "prototype guard path is not UTF-8".to_string())?, + ); + } + let relative = components.join("/"); + validate_repo_relative_path(&relative, "prototype guard source path")?; + Ok(relative) +} + +fn is_prototype_text_input( + path: &Path, + extensions: &HashSet<&str>, + extensionless_names: &HashSet<&str>, +) -> bool { + let extension_matches = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extensions.contains(extension)); + let extensionless_matches = path.extension().is_none() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| extensionless_names.contains(name)); + extension_matches || extensionless_matches +} + +fn record_prototype_match( + config: &PrototypeGuardConfig, + report: &mut PrototypeGuardReport, + allow_match_counts: &mut [usize], + finding: PrototypeFinding, + matched_text: &str, +) -> Result<(), String> { + let match_count = report.findings.len() + report.allowed.len(); + if match_count >= config.limits.max_matches { + return Err(format!( + "prototype guard match count exceeds configured limit {}", + config.limits.max_matches + )); + } + if let Some((allow_index, allowed)) = config.allow.iter().enumerate().find(|(_, allowed)| { + allowed.pattern_id == finding.pattern_id + && allowed.path == finding.path + && matched_text.contains(&allowed.line_contains) + }) { + allow_match_counts[allow_index] += 1; + report.allowed.push(PrototypeAllowedMatch { + finding, + reason: allowed.reason.clone(), + }); + } else { + report.findings.push(finding); + } + Ok(()) +} + +fn read_bounded_prototype_input( + path: &Path, + display_path: &str, + max_file_bytes: u64, +) -> Result<String, String> { + let file = fs::File::open(path) + .map_err(|error| format!("open prototype guard input {display_path}: {error}"))?; + let mut bytes = Vec::new(); + file.take(max_file_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("read prototype guard input {display_path}: {error}"))?; + if bytes.len() as u64 > max_file_bytes { + return Err(format!( + "prototype guard input exceeds {max_file_bytes} bytes: {display_path}" + )); + } + String::from_utf8(bytes) + .map_err(|error| format!("prototype guard input is not UTF-8 {display_path}: {error}")) +} + +#[derive(Default)] +struct PrototypeInputs { + files: Vec<PathBuf>, + paths: Vec<PathBuf>, +} + +fn collect_prototype_inputs( + root: &Path, + path: &Path, + extensions: &HashSet<&str>, + extensionless_names: &HashSet<&str>, + excluded_prefixes: &[String], + max_scan_entries: usize, + inputs: &mut PrototypeInputs, +) -> Result<(), String> { + let relative = prototype_display_path(root, path)?; + if path_is_excluded(&relative, excluded_prefixes) { + return Ok(()); + } + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "inspect required prototype guard path {}: {error}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "prototype guard refuses symlinked scan input: {}", + path.display() + )); + } + if inputs.paths.len() >= max_scan_entries { + return Err(format!( + "prototype guard scan exceeds configured entry limit {max_scan_entries}" + )); + } + inputs.paths.push(path.to_path_buf()); + if metadata.is_file() { + if is_prototype_text_input(path, extensions, extensionless_names) { + inputs.files.push(path.to_path_buf()); + } + return Ok(()); + } + if !metadata.is_dir() { + return Ok(()); + } + let entries = fs::read_dir(path) + .map_err(|error| format!("read prototype guard directory {}: {error}", path.display()))?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "read prototype guard entry under {}: {error}", + path.display() + ) + })?; + collect_prototype_inputs( + root, + &entry.path(), + extensions, + extensionless_names, + excluded_prefixes, + max_scan_entries, + inputs, + )?; + } + Ok(()) +} + +fn collect_prototype_paths( + root: &Path, + path: &Path, + excluded_prefixes: &[String], + max_scan_entries: usize, + paths: &mut Vec<PathBuf>, +) -> Result<(), String> { + let relative = prototype_display_path(root, path)?; + if excluded_prefixes + .iter() + .any(|prefix| repository_path_is_within(&relative, prefix)) + { + return Ok(()); + } + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "inspect required prototype guard path {}: {error}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "prototype guard refuses symlinked scan input: {}", + path.display() + )); + } + if paths.len() >= max_scan_entries { + return Err(format!( + "prototype guard scan exceeds configured entry limit {max_scan_entries}" + )); + } + paths.push(path.to_path_buf()); + if !metadata.is_dir() { + return Ok(()); + } + let entries = fs::read_dir(path) + .map_err(|error| format!("read prototype guard directory {}: {error}", path.display()))?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "read prototype guard entry under {}: {error}", + path.display() + ) + })?; + collect_prototype_paths( + root, + &entry.path(), + excluded_prefixes, + max_scan_entries, + paths, + )?; + } + Ok(()) +} + +fn prototype_pattern_matches(path: &str, line: &str, pattern: &PrototypePattern) -> bool { + let path_matches = pattern.path_prefixes.is_empty() + || pattern + .path_prefixes + .iter() + .any(|prefix| repository_path_is_within(path, prefix)); + path_matches + && match pattern.match_kind { + PrototypeMatchKind::Substring => line.contains(&pattern.needle), + PrototypeMatchKind::WordPrefix => { + let folded_line = line.to_ascii_lowercase(); + let folded_needle = pattern.needle.to_ascii_lowercase(); + folded_line.match_indices(&folded_needle).any(|(index, _)| { + folded_line[..index] + .chars() + .next_back() + .is_none_or(|character| !is_prototype_identifier_continue(character)) + }) + } + } +} + +fn is_prototype_identifier_continue(character: char) -> bool { + character.is_alphanumeric() || character == '_' +} + +fn bounded_excerpt(line: &str) -> String { + const LIMIT: usize = 240; + let escaped = escape_report_text(line.trim()); + if escaped.chars().count() <= LIMIT { + return escaped; + } + let mut excerpt: String = escaped.chars().take(LIMIT - 3).collect(); + excerpt.push_str("..."); + excerpt +} + +fn escape_report_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + escaped +} + +fn print_prototype_guard_report( + mode: PrototypeGuardMode, + report: &PrototypeGuardReport, + limits: &PrototypeGuardLimits, +) { + println!( + "prototype contract source guard: mode={} findings={} allowlisted={}", + mode.as_str(), + report.findings.len(), + report.allowed.len() + ); + for finding in report.findings.iter().take(limits.max_reported_findings) { + print_prototype_finding("finding", finding, None); + } + if report.findings.len() > limits.max_reported_findings { + println!( + "... {} additional finding(s) omitted by report limit", + report.findings.len() - limits.max_reported_findings + ); + } + for allowed in report.allowed.iter().take(limits.max_reported_allowlisted) { + print_prototype_finding("allowlisted", &allowed.finding, Some(&allowed.reason)); + } + if report.allowed.len() > limits.max_reported_allowlisted { + println!( + "... {} additional allowlisted match(es) omitted by report limit", + report.allowed.len() - limits.max_reported_allowlisted + ); + } +} + +fn print_prototype_finding(label: &str, finding: &PrototypeFinding, reason: Option<&str>) { + let path = escape_report_text(&finding.path); + let location = finding + .line + .map_or_else(|| format!("{path} [path]"), |line| format!("{path}:{line}")); + if let Some(reason) = reason { + let reason = escape_report_text(reason); + println!( + "{label} {} {location}: {} ({reason})", + finding.pattern_id, + escape_report_text(&finding.excerpt) + ); + } else { + println!( + "{label} {} {location}: {}", + finding.pattern_id, + escape_report_text(&finding.excerpt) + ); + } +} + pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> { let mut failures = Vec::new(); let consolidation_active = consolidation_is_active(root); @@ -887,6 +2046,510 @@ mod tests { let _ = fs::remove_dir_all(dirty_root); } + fn prototype_config( + mode: &str, + pattern_id: &str, + needle: &str, + match_kind: &str, + allow: Option<(&str, &str, &str)>, + ) -> String { + let allow = allow.map_or_else(String::new, |(path, line_contains, reason)| { + format!( + r#" +[[allow]] +pattern_id = "{pattern_id}" +path = "{path}" +line_contains = "{line_contains}" +reason = "{reason}" +"# + ) + }); + format!( + r#"schema = "{PROTOTYPE_CONTRACT_SCHEMA}" +mode = "{mode}" + +[scan] +roots = ["src", "docs"] +path_roots = ["."] +path_excludes = [".git", "target"] +extensions = ["capnp", "md", "rs", "toml", "ts"] +extensionless_names = [".gitignore", "README"] + +[limits] +max_scan_entries = 100 +max_inventory_bytes = 4096 +max_file_bytes = 1024 +max_matches = 16 +max_reported_findings = 4 +max_reported_allowlisted = 4 + +[[pattern]] +id = "{pattern_id}" +needle = "{needle}" +match_kind = "{match_kind}" +description = "test prototype pattern" +{allow}"# + ) + } + + #[test] + fn prototype_guard_reports_matches_and_narrow_allowlists() { + let root = unique_temp_dir("prototype_report"); + let needle = ["config", ".env"].concat(); + write_file( + &root, + "contracts/test-prototype-guard.toml", + &prototype_config( + "report_only", + "config-environment", + &needle, + "substring", + Some(( + "docs/history.md", + "historical fixture", + "Historical fixture text is not an active configuration path.", + )), + ), + ); + write_file( + &root, + "src/config.rs", + &format!("const PROTOTYPE: &str = \"{needle}\";\n"), + ); + write_file( + &root, + "docs/history.md", + &format!("historical fixture: {needle}\nactive example: {needle}\n"), + ); + + let config = + load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml")) + .expect("load prototype guard config"); + let report = scan_prototype_contracts(&root, &config).expect("scan prototype contracts"); + assert_eq!(report.findings.len(), 2); + assert_eq!(report.allowed.len(), 1); + assert_eq!(report.findings[0].path, "docs/history.md"); + assert_eq!(report.findings[0].origin, PrototypeFindingOrigin::Content); + assert_eq!(report.findings[1].path, "src/config.rs"); + assert_eq!(report.allowed[0].finding.path, "docs/history.md"); + + run_prototype_contract_guard( + &[ + "--config".to_string(), + "contracts/test-prototype-guard.toml".to_string(), + ], + &root, + ) + .expect("report-only prototype guard"); + let strict_error = run_prototype_contract_guard( + &[ + "--config".to_string(), + "contracts/test-prototype-guard.toml".to_string(), + "--strict".to_string(), + ], + &root, + ) + .expect_err("strict prototype guard rejects findings"); + assert!(strict_error.contains("2 non-allowlisted match(es)")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prototype_guard_word_prefix_avoids_embedded_false_positives() { + let root = unique_temp_dir("prototype_word_prefix"); + let prefix = ["com", "pat"].concat(); + write_file( + &root, + "contracts/test-prototype-guard.toml", + &prototype_config( + "strict", + "compatibility-concept", + &prefix, + "word_prefix", + Some(( + "docs/interoperability.md", + "compatible peer", + "External interoperability is not a compatibility implementation path.", + )), + ), + ); + write_file( + &root, + "docs/interoperability.md", + "incompatible input\ncompatible peer\nCompatReader\nÉcompatReader\n", + ); + write_file(&root, "src/clean.rs", "fn current_contract() {}\n"); + + let config = + load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml")) + .expect("load prototype guard config"); + let report = scan_prototype_contracts(&root, &config).expect("scan prototype contracts"); + assert_eq!(report.findings.len(), 1); + assert_eq!(report.findings[0].line, Some(3)); + assert_eq!(report.findings[0].excerpt, "CompatReader"); + assert_eq!(report.allowed.len(), 1); + assert_eq!(report.allowed[0].finding.line, Some(2)); + + let unsafe_path = + parse_prototype_guard_args(&["--config".to_string(), "../outside.toml".to_string()]) + .expect_err("parent traversal must fail"); + assert!(unsafe_path.contains("normalized repository-relative path")); + let duplicate_mode = + parse_prototype_guard_args(&["--strict".to_string(), "--report-only".to_string()]) + .expect_err("duplicate mode must fail"); + assert!(duplicate_mode.contains("only one mode override")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prototype_guard_rejects_broad_or_stale_allowlists_and_symlinks() { + let root = unique_temp_dir("prototype_allowlist_integrity"); + let needle = ["import", "_json"].concat(); + write_file( + &root, + "contracts/test-prototype-guard.toml", + &prototype_config( + "report_only", + "state-import-identifier", + &needle, + "substring", + Some(( + "src/import.rs", + "import", + "A test allowance that is intentionally too broad.", + )), + ), + ); + write_file( + &root, + "src/import.rs", + &format!("fn {needle}() {{}}\nfn second_{needle}() {{}}\n"), + ); + write_file(&root, "docs/README", "Current contract.\n"); + let config = + load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml")) + .expect("load prototype guard config"); + let broad_error = scan_prototype_contracts(&root, &config) + .expect_err("one allowance must not authorize multiple matching lines"); + assert!(broad_error.contains("must match exactly one line (matched 2)")); + + write_file(&root, "src/import.rs", "fn current_name() {}\n"); + let stale_error = + scan_prototype_contracts(&root, &config).expect_err("stale allowance must fail closed"); + assert!(stale_error.contains("must match exactly one line (matched 0)")); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let outside = unique_temp_dir("prototype_symlink_target"); + write_file(&outside, "forbidden.rs", &format!("fn {needle}() {{}}\n")); + symlink(outside.join("forbidden.rs"), root.join("src/linked.rs")) + .expect("create scan symlink"); + let symlink_error = scan_prototype_contracts(&root, &config) + .expect_err("symlinked source must fail closed"); + assert!(symlink_error.contains("refuses symlinked scan input")); + fs::remove_file(root.join("src/linked.rs")).expect("remove direct scan symlink"); + + symlink(&outside, root.join("linked-root")).expect("create intermediate scan symlink"); + let linked_root_source = prototype_config( + "report_only", + "state-import-identifier", + &needle, + "substring", + None, + ) + .replace("roots = [\"src\", \"docs\"]", "roots = [\"linked-root\"]") + .replace("path_roots = [\".\"]", "path_roots = [\"docs\"]") + .replace( + "path_excludes = [\".git\", \"target\"]", + "path_excludes = []", + ); + write_file( + &root, + "contracts/linked-root-guard.toml", + &linked_root_source, + ); + let linked_root_config = + load_prototype_guard_config(&root, Path::new("contracts/linked-root-guard.toml")) + .expect("load intermediate symlink scan config"); + let linked_root_error = scan_prototype_contracts(&root, &linked_root_config) + .expect_err("intermediate scan-root symlink must fail closed"); + assert!(linked_root_error.contains("must not contain a symlinked component")); + + fs::create_dir_all(root.join("configs")).expect("create config parent"); + symlink(&outside, root.join("configs/linked")) + .expect("create intermediate config symlink"); + write_file(&outside, "guard.toml", &linked_root_source); + let linked_config_error = + load_prototype_guard_config(&root, Path::new("configs/linked/guard.toml")) + .expect_err("intermediate config symlink must fail closed"); + assert!(linked_config_error.contains("must not contain a symlinked component")); + let _ = fs::remove_dir_all(outside); + } + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prototype_guard_scans_paths_non_rust_inputs_and_required_roots() { + let root = unique_temp_dir("prototype_path_and_fixture_scan"); + let path_needle = ["identity", ".example.json"].concat(); + let content_needle = ["allow", "_generate_identity"].concat(); + let env_path_needle = [".env", ".example"].concat(); + let worker_path_needle = ["workers", "/rhi"].concat(); + let config_source = prototype_config( + "report_only", + "identity-example-path", + &path_needle, + "substring", + None, + ) + .replace( + "roots = [\"src\", \"docs\"]", + "roots = [\"src\", \"docs\", \".gitignore\"]", + ) + .replace( + "description = \"test prototype pattern\"", + "description = \"test prototype pattern\"\nmatch_path = true", + ) + &format!( + r#" +[[pattern]] +id = "identity-generation-content" +needle = "{content_needle}" +match_kind = "substring" +description = "test non-Rust fixture pattern" + +[[pattern]] +id = "environment-example-path" +needle = "{env_path_needle}" +match_kind = "substring" +description = "test environment example path" +match_path = true + +[[pattern]] +id = "worker-directory-path" +needle = "{worker_path_needle}" +match_kind = "substring" +description = "test worker directory path" +match_path = true +"#, + ); + write_file(&root, "contracts/test-prototype-guard.toml", &config_source); + let identity_path = format!("src/{path_needle}"); + let env_path = env_path_needle.clone(); + let worker_path = format!("src/{worker_path_needle}"); + write_file(&root, &identity_path, "{}\n"); + write_file(&root, &env_path, "CURRENT_SETTING=true\n"); + write_file(&root, ".gitignore", &format!("# {content_needle}\n")); + fs::create_dir_all(root.join(&worker_path)).expect("create forbidden worker path"); + write_file( + &root, + "src/generated.ts", + &format!("export const flag = \"{content_needle}\";\n"), + ); + write_file(&root, "src/service.capnp", &format!("# {content_needle}\n")); + write_file(&root, "docs/README", &format!("{content_needle}\n")); + + let config = + load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml")) + .expect("load prototype guard config"); + let report = scan_prototype_contracts(&root, &config).expect("scan active textual inputs"); + assert_eq!(report.findings.len(), 7); + for path in [&env_path, &identity_path, &worker_path] { + assert!(report.findings.iter().any(|finding| { + finding.path == *path + && finding.origin == PrototypeFindingOrigin::Path + && finding.line.is_none() + })); + } + for path in [ + ".gitignore", + "docs/README", + "src/generated.ts", + "src/service.capnp", + ] { + assert!(report.findings.iter().any(|finding| { + finding.path == path && finding.origin == PrototypeFindingOrigin::Content + })); + } + + fs::remove_dir_all(root.join("docs")).expect("remove required scan root"); + let missing_error = scan_prototype_contracts(&root, &config) + .expect_err("a missing required scan root must fail closed"); + assert!(missing_error.contains("prototype guard scan root component")); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn prototype_guard_git_inventory_ignores_workstation_symlinks() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("prototype_git_inventory"); + let needle = [".env", ".example"].concat(); + let config_source = prototype_config( + "report_only", + "environment-example-path", + &needle, + "substring", + None, + ) + .replace( + "description = \"test prototype pattern\"", + "description = \"test prototype pattern\"\nmatch_path = true", + ); + write_file(&root, "contracts/test-prototype-guard.toml", &config_source); + write_file(&root, "src/current.rs", "fn current_contract() {}\n"); + write_file(&root, "docs/README", "Current contract.\n"); + let ignore = format!(".direnv/\nresult\n.env.*\n!{needle}\n"); + write_file(&root, ".gitignore", &ignore); + write_file(&root, &needle, "CURRENT_SETTING=true\n"); + let init = Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .status() + .expect("run git init"); + assert!(init.success()); + + let config = + load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml")) + .expect("load prototype guard config"); + let before = scan_prototype_contracts(&root, &config).expect("scan governed Git source"); + assert_eq!(before.findings.len(), 1); + assert_eq!(before.findings[0].path, needle); + assert_eq!(before.findings[0].origin, PrototypeFindingOrigin::Path); + + let control_path = "src/control\n\u{1b}.rs"; + write_file(&root, control_path, "fn current_contract() {}\n"); + let control_error = scan_prototype_contracts(&root, &config) + .expect_err("control characters in Git paths must fail closed"); + assert_eq!(control_error.lines().count(), 1); + assert!(!control_error.contains('\u{1b}')); + assert!(control_error.contains("control\\n\\u{1b}.rs")); + fs::remove_file(root.join(control_path)).expect("remove control-character path"); + + let outside = unique_temp_dir("prototype_ignored_symlink_target"); + write_file(&outside, "ignored.rs", "Current ignored cache.\n"); + fs::create_dir_all(root.join(".direnv")).expect("create ignored environment cache"); + symlink(outside.join("ignored.rs"), root.join(".direnv/linked.rs")) + .expect("create ignored environment symlink"); + symlink(&outside, root.join("result")).expect("create ignored Nix result symlink"); + + let after = scan_prototype_contracts(&root, &config) + .expect("ignored workstation symlinks must not enter the source inventory"); + assert_eq!(after, before); + let _ = fs::remove_dir_all(outside); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prototype_guard_git_inventory_parser_enforces_byte_and_entry_bounds() { + let root = unique_temp_dir("prototype_git_inventory_bounds"); + write_file(&root, "src/one.rs", "fn one() {}\n"); + write_file(&root, "src/two.rs", "fn two() {}\n"); + let inventory = b"src/one.rs\0src/two.rs\0"; + + let parsed = parse_git_inventory(&root, &inventory[..], 2, inventory.len()) + .expect("bounded inventory must parse"); + assert_eq!(parsed.len(), 2); + + let byte_error = parse_git_inventory(&root, &inventory[..], 2, inventory.len() - 1) + .expect_err("inventory bytes above the configured ceiling must fail"); + assert!(byte_error.contains("configured byte limit")); + + let entry_error = parse_git_inventory(&root, &inventory[..], 1, inventory.len()) + .expect_err("inventory entries above the configured ceiling must fail"); + assert!(entry_error.contains("configured entry limit 1")); + + let delimiter_error = + parse_git_inventory(&root, &inventory[..inventory.len() - 1], 2, 4096) + .expect_err("unterminated Git inventory must fail"); + assert!(delimiter_error.contains("without a NUL delimiter")); + + let escaped = bounded_excerpt("safe\tvalue\u{1b}[31m"); + assert_eq!(escaped, "safe\\tvalue\\u{1b}[31m"); + assert!(!escaped.chars().any(char::is_control)); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prototype_guard_bounds_configuration_bytes_counts_and_reasons() { + let root = unique_temp_dir("prototype_config_bounds"); + let config_path = "contracts/test-prototype-guard.toml"; + write_file( + &root, + config_path, + &"x".repeat(PROTOTYPE_MAX_CONFIG_BYTES as usize + 1), + ); + let bytes_error = load_prototype_guard_config(&root, Path::new(config_path)) + .expect_err("oversized configuration must fail before parsing"); + assert!(bytes_error.contains("exceeds 1048576 bytes")); + + let needle = ["config", ".env"].concat(); + let oversized_reason = "r".repeat(PROTOTYPE_MAX_REASON_BYTES + 1); + let reason_config = prototype_config( + "report_only", + "config-environment", + &needle, + "substring", + Some(("src/config.rs", "prototype", &oversized_reason)), + ); + write_file(&root, config_path, &reason_config); + let reason_error = load_prototype_guard_config(&root, Path::new(config_path)) + .expect_err("oversized printed reason must fail validation"); + assert!(reason_error.contains("requires a reason")); + + let mut pattern_config = + prototype_config("report_only", "pattern-0", &needle, "substring", None); + for index in 1..=PROTOTYPE_MAX_CONFIG_PATTERNS { + pattern_config.push_str(&format!( + r#" +[[pattern]] +id = "pattern-{index}" +needle = "current-{index}" +match_kind = "substring" +description = "bounded pattern" +"#, + )); + } + write_file(&root, config_path, &pattern_config); + let count_error = load_prototype_guard_config(&root, Path::new(config_path)) + .expect_err("excessive pattern count must fail validation"); + assert!(count_error.contains("configuration collection exceeds compiled limit")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prototype_guard_enforces_file_and_match_resource_bounds() { + let root = unique_temp_dir("prototype_resource_bounds"); + let needle = ["config", ".env"].concat(); + write_file( + &root, + "contracts/test-prototype-guard.toml", + &prototype_config( + "report_only", + "config-environment", + &needle, + "substring", + None, + ), + ); + write_file(&root, "docs/README", "Current contract.\n"); + write_file(&root, "src/large.rs", &"x".repeat(1025)); + let config = + load_prototype_guard_config(&root, Path::new("contracts/test-prototype-guard.toml")) + .expect("load prototype guard config"); + let size_error = scan_prototype_contracts(&root, &config) + .expect_err("oversized source input must fail closed"); + assert!(size_error.contains("exceeds 1024 bytes")); + + write_file(&root, "src/large.rs", &format!("{needle}\n").repeat(17)); + let match_error = scan_prototype_contracts(&root, &config) + .expect_err("excessive matches must fail closed"); + assert!(match_error.contains("match count exceeds configured limit 16")); + let _ = fs::remove_dir_all(root); + } + #[test] fn run_dispatches_forbidden_identifiers() { let root = unique_temp_dir("run"); diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -299,6 +299,9 @@ fn usage() { " cargo xtask coverage refresh-summary [--reports-root <dir>] [--out <file>] [--status-out <file>]" ); eprintln!(" cargo xtask hygiene forbidden-identifiers"); + eprintln!( + " cargo xtask hygiene prototype-contracts [--config <repo-relative-path>] [--strict|--report-only]" + ); eprintln!(" cargo xtask source-lock --consumer-root <absolute-directory>"); eprintln!( " cargo xtask source materialize --consumer-root <absolute-directory> --cache-root <absolute-directory> --mode <prefetch|offline>" @@ -782,6 +785,8 @@ mod tests { run(&["coverage".to_string(), "help".to_string()]).expect("root run coverage"); run(&["hygiene".to_string(), "forbidden-identifiers".to_string()]) .expect("hygiene forbidden identifiers"); + run(&["hygiene".to_string(), "prototype-contracts".to_string()]) + .expect("report prototype contracts"); let _ = fs::remove_dir_all(out_dir); }