forbidden_strings/lib.rs
1//! lib support for the forbidden-strings scanner.
2
3/// Registers the `cli` child module.
4// What: `mod cli;` declares a child module whose source lives in `cli.rs`.
5// `mod` is Rust's module system: it does NOT import names; it tells
6// the compiler "this file/module exists, compile it". Names referenced
7// via `crate::cli::xxx` afterward.
8// Why: Clap-backed argument parsing now lives beside the run loop but outside
9// it, replacing the previous handwritten argv scanner.
10// Gotcha: `mod foo;` without a body is NOT an import; it's a registration.
11//
12// In TS you'd write (pseudocode):
13// ```ts
14// // No equivalent. Closest: TypeScript automatically picks up files
15// // in `include` paths; Rust requires explicit `mod` declarations.
16// ```
17mod cli;
18
19/// Registers the `rules` child module.
20// What: `mod walk;` declares child modules whose source lives in sibling
21// `.rs` files. `mod` registers each file with the compiler; it does
22// not import names into local scope.
23// Why: We split the binary into focused files: CLI parsing, rules, scanning,
24// hit formatting, and the working-tree walker.
25//
26// In TS you'd write (pseudocode):
27// ```ts
28// // No equivalent. Closest: TypeScript automatically picks up files
29// // in `include` paths; Rust requires explicit `mod` declarations.
30// ```
31mod rule;
32/// Registers the `walk` child module.
33mod walk;
34/// Registers the `frx_load` child module:
35/// the forbidden-regex rule loader.
36mod frx_load;
37/// Registers the `frx_scan` child module:
38/// the forbidden-regex line scan.
39mod frx_scan;
40/// Shared structured findings keep embedded policies independent of terminal formatting.
41mod scan_finding;
42/// Public findings contain only source locations and non-secret rule identities.
43pub use scan_finding::ScanFinding;
44/// Thread-isolated named-callback boundary for embedded rule loading.
45mod load_request;
46/// In-process candidate snapshots reuse the standalone scan core and redaction rules.
47mod scanner;
48/// Embedded consumers identify candidates independently of redacted display labels.
49pub use scanner::{CandidateScan, Scanner};
50/// Fixed-token cache warnings remain structured for embedded event reporting.
51pub use runtime_cache::CacheWarning;
52/// Native-byte path normalization and protocol-safe display encoding.
53mod path_name_bytes;
54/// Registers component-level pathname matching and masked display labels.
55mod path_scan;
56/// Registers runtime cache envelope,
57/// path,
58/// warning,
59/// and publication implementation.
60mod runtime_cache;
61/// Registers hybrid exact-literal and restricted-regex runtime matcher.
62mod runtime_matcher;
63
64/// Registers the `fuzz_api` child module.
65// What: `#[cfg(feature = "fuzzing")] pub mod fuzz_api;` registers
66// the curated re-export module ONLY when the `fuzzing`
67// Cargo feature is on. The bin target leaves the feature
68// off and never sees this module; fuzz targets (in
69// `fuzz/Cargo.toml`) enable the feature and use
70// `forbidden_strings::fuzz_api::*` to import internals.
71// Why: Keep the production public surface unchanged while
72// letting fuzz targets reach the internal helpers they
73// need.
74//
75// In TS you'd write (pseudocode):
76// ```ts
77// // No clean equivalent; conditional re-export at build time.
78// ```
79#[cfg(feature = "fuzzing")]
80pub mod fuzz_api;
81
82/// What:
83/// `pub const BUILTIN_RULES: &str = include_str!("../data/builtin-rules.txt");`
84/// declares an exported constant whose value is the entire
85/// betterleaks-ported baseline ruleset,
86/// pasted into the binary at
87/// COMPILE time by the `include_str!` macro.
88/// `&str` is a read-only
89/// view of UTF-8 bytes baked into the binary;
90/// the sibling `String`
91/// would heap-allocate the same bytes again at runtime for no
92/// benefit.
93/// Why:
94/// Ships the baseline inside the published binary so the opt-in
95/// `--builtin-rules` flag works with no rules file on disk,
96/// and lets
97/// consumers (the forbidden-regex bench crate) import the same rules
98/// instead of `include_str!`-ing a fragile relative path into this
99/// repository.
100/// Gotcha:
101/// Editing `data/builtin-rules.txt` changes nothing until the crate
102/// is rebuilt,
103/// and the file itself is GENERATED by
104/// `src/mise.port-betterleaks.ts`;
105/// never edit it by hand.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// import BUILTIN_RULES from '../data/builtin-rules.txt' with { type: 'text' };
110/// export { BUILTIN_RULES };
111/// ```
112pub const BUILTIN_RULES: &str = include_str!("../data/builtin-rules.txt");
113
114/// The builtin baseline precompiled into a serialized `RegexSet`,
115/// embedded at build
116/// time.
117///
118/// What:
119/// `include_bytes!(concat!(env!("OUT_DIR"), "/builtin-rules-precompiled.bin"))`
120/// bakes the byte blob `build.rs` produced from `data/builtin-rules.txt`
121/// into the binary.
122/// `&[u8]` is a read-only view of those bytes;
123/// the runtime
124/// loader hands them to `load_precompiled`,
125/// which the engine's validating
126/// `from_bytes` decodes without recompiling.
127/// Why:
128/// Compiling the full baseline at startup is not viable (the migration
129/// measured tens of seconds),
130/// so the baseline is compiled once at build time
131/// and only decoded at runtime.
132/// Mutable runtime rules use their separate
133/// content-addressed per-user cache with text fallback.
134/// Gotcha:
135/// The blob is regenerated whenever `data/builtin-rules.txt` or the
136/// shared frx parser sources change (`build.rs` `rerun-if-changed`);
137/// editing
138/// the baseline file changes nothing until the crate is rebuilt.
139pub const BUILTIN_PRECOMPILED: &[u8] =
140 include_bytes!(concat!(env!("OUT_DIR"), "/builtin-rules-precompiled.bin"));
141
142/// The builtin baseline's rule-name sidecar,
143/// embedded at build time.
144///
145/// One line per baseline rule,
146/// in compiled order:
147/// the rule's tail-format section
148/// name (its betterleaks id),
149/// or an empty line for an unnamed legacy rule.
150/// The
151/// loader pairs it with `BUILTIN_PRECOMPILED` so a baseline finding renders as
152/// `rule=<name>` instead of a drifting numeric index;
153/// a count mismatch against the
154/// decoded set fails the load closed.
155pub const BUILTIN_NAMES: &str =
156 include_str!(concat!(env!("OUT_DIR"), "/builtin-rules-names.txt"));
157
158/// Re-exports the forbidden-regex rule compiler's public surface.
159// What: `pub use rule::frx::{...}` lifts the engine's rule-compiler entry
160// points to the crate root so they are reachable crate-public API.
161// `frx_load::load` calls the runtime cache and `load_precompiled`;
162// `frx_scan::scan_file` runs the resulting sets against each file.
163// Why: These are the live load-path construction functions and the redacted
164// error they return. Exposing them at the crate root lets the loader,
165// the build script's sibling parser, and the fuzz surface share one API.
166//
167// In TS you'd write (pseudocode):
168// ```ts
169// export { compileFromText, loadPrecompiled, LoadError } from "./rule/frx";
170// ```
171pub use rule::frx::{compile_from_text, compile_rules, load_precompiled, CompiledRules, LoadError};
172
173/// Imports dependencies used by this module.
174// What: `use anyhow::Result;` imports `anyhow`'s one-parameter application
175// result alias. Sibling typed results name their exact error type.
176// Why: The CLI boundary keeps one catastrophic-error channel while ordinary
177// scan findings continue to return numeric exit codes.
178//
179// In TS you'd write (pseudocode):
180// ```ts
181// type Result<T> = T; // failures throw Error objects
182// ```
183use anyhow::Result;
184
185/// Imports dependencies used by this module.
186// What: `use clap::Parser;` brings the parser trait into this module. Rust
187// only lets trait methods such as `Cli::try_parse_from(...)` be called
188// when the trait is in scope. `::` is Rust's namespace separator.
189// Why: `run_cli_from_env` should let clap validate argv and render help,
190// version, and parse errors.
191//
192// In TS you'd write (pseudocode):
193// ```ts
194// import { parseArgs } from "some-cli-parser";
195// ```
196use clap::Parser;
197
198/// Imports dependencies used by this module.
199// What: `use std::env;` imports the std `env` module so we can reference
200// `env::args` / `env::var`.
201// Why: Reading argv and environment variables.
202//
203// In TS you'd write (pseudocode):
204// ```ts
205// import { argv, env } from "node:process";
206// ```
207use std::env;
208
209// What: `std::fs::canonicalize` is referenced via the full path at
210// three sites (rules-path skip set, walker skip lookup); no
211// bare `use std::fs;` because the per-file `fs::read` slurp
212// moved into `read_with_binary_check` which uses
213// `std::fs::File` directly.
214// Why: Background on file-reading performance choices: `fs::read`
215// is empirically faster than `mmap`-based access on this
216// workload (many small files; per-file VMA setup cost
217// dominates the saved alloc) -- the E2 mmap experiment
218// regressed wall time by 35% on Mono and 43% on the Linux
219// kernel. See PERF.md "Mmap experiment (rejected)".
220// Thread-local scratch buffers were also tried 2026-05-03;
221// rayon's nested-parallelism work-stealing (scan_content
222// uses inner par_iter via prefix-matched and combined-
223// shard fan-out) re-entered the outer flat_map_iter on
224// the SAME thread while the buffer was borrowed,
225// triggering a `RefCell already borrowed` panic. The
226// per-file alloc cost is dwarfed by the unicode-mode
227// speedup; not worth the re-entrancy hazard.
228
229/// Imports dependencies used by this module.
230// What: `use std::io::Write;` imports the `Write` TRAIT (interface-
231// like). Methods declared by a trait are only callable when
232// the trait is in scope, even when used via macros like
233// `writeln!`.
234// Why: We use `writeln!(handle, ...)` to emit hits.
235//
236// In TS you'd write (pseudocode):
237// ```ts
238// // Unnecessary in TS.
239// ```
240use std::io::Write;
241
242/// Imports dependencies used by this module.
243// What: `use rayon::prelude::*;` brings rayon's parallel-iterator
244// extension methods into scope (`par_iter`, `flat_map_iter`,
245// etc.).
246// Why: The two-phase main loop uses `par_iter` for both the
247// parallel-read phase and the parallel-scan phase.
248//
249// In TS you'd write (pseudocode):
250// ```ts
251// // No equivalent.
252// ```
253use rayon::prelude::*;
254
255/// Imports dependencies used by this module.
256// What: `use crate::cli::Cli;` imports the clap-backed parsed option struct
257// from the sibling module. `crate::` is the absolute root of this
258// crate.
259// Why: `run_cli_from_env` needs the generated parser and the typed fields it
260// returns.
261//
262// In TS you'd write (pseudocode):
263// ```ts
264// import { Cli } from "./cli";
265// ```
266use crate::cli::Cli;
267
268/// Imports dependencies used by this module.
269// What: `use crate::walk::list_files;` imports the working-tree walker used for
270// `--all` mode. Rule loading and file scanning now route through
271// `frx_load::load` and `frx_scan::scan_file` (the stage-two engine path),
272// referenced by their full module paths at the call sites below.
273// Why: Enumerate git-tracked files for `--all`.
274//
275// In TS you'd write (pseudocode):
276// ```ts
277// import { listFiles } from "./walk";
278// ```
279use crate::walk::list_files;
280
281/// Implements `build_skip_set`.
282// What: `fn build_skip_set(rules_path: &str) -> HashSet<PathBuf>`
283// returns the set of CANONICAL absolute paths to skip when
284// walking the tree in `--all` mode. Pre-fix this logic was a
285// basename check (`is_skipped_file`) that matched anywhere in
286// the tree, so an unrelated `sub/forbidden-strings.local.txt`
287// was silently dropped along with the actual rule file. Path-
288// anchored matching pins each skip to its specific filesystem
289// location.
290// Why: Closes BUG 6 (basename skip applies to arbitrary explicit
291// args) and BUG 11 (Windows path basename via rsplit('/')) in
292// one shape change. Path-anchoring removes both failure modes:
293// the basename collision cannot trigger because we compare
294// full canonical paths, and the Windows backslash separator
295// is handled inside `std::fs::canonicalize` / `PathBuf::eq`.
296//
297// Skip set composition:
298// - The actual rules file (whatever the user passed via
299// `--rules` or `FORBIDDEN_STRINGS_RULES`; falls back to
300// the default `forbidden-strings.local.txt` in cwd).
301// - Three canonical self-match paths at their expected
302// locations relative to repo root. Each is generated
303// source containing literal copies of rule bodies;
304// scanning them in --all mode produces noise.
305// If running from a different cwd they fail to
306// canonicalize and are silently dropped from the set;
307// matching is still correct for the rules file alone.
308//
309// The caller separately decides WHEN to apply the skip:
310// explicit positional args are NEVER skipped (the user asked
311// for them); only walker output in --all mode is filtered.
312//
313// In TS you'd write (pseudocode):
314// ```ts
315// function buildSkipSet(rulesPath: string): Set<string> {
316// const set = new Set<string>();
317// try { set.add(fs.realpathSync(rulesPath)); } catch {}
318// for (const k of CANONICAL_SELF_MATCH_PATHS) {
319// try { set.add(fs.realpathSync(k)); } catch {}
320// }
321// return set;
322// }
323// ```
324fn build_skip_set(rules_path: &str) -> std::collections::HashSet<std::path::PathBuf> {
325 // What: `let mut set: HashSet<PathBuf> = HashSet::new();` -- the
326 // usual mutable-empty-collection pattern.
327 // Why: Accumulate canonical-form paths we want to skip.
328 //
329 // In TS you'd write (pseudocode):
330 // ```ts
331 // const set = new Set<string>();
332 // ```
333 let mut set: std::collections::HashSet<std::path::PathBuf> =
334 std::collections::HashSet::new();
335
336 // What: `if let Ok(p) = std::fs::canonicalize(rules_path) { set.insert(p); }`.
337 // `canonicalize` resolves symlinks AND makes the path
338 // absolute; identical files reached via different
339 // relative paths compare equal at the canonical level.
340 // A missing rules file would fail to canonicalize -- the
341 // loader will surface that error separately via
342 // `load_ruleset`, so we silently skip the insertion here.
343 // Why: Anchor the skip on the actual filesystem identity of
344 // the rules file rather than its basename.
345 //
346 // In TS you'd write (pseudocode):
347 // ```ts
348 // try { set.add(fs.realpathSync(rulesPath)); } catch {}
349 // ```
350 if let Ok(p) = std::fs::canonicalize(rules_path) {
351 set.insert(p);
352 }
353 // What: Canonical self-match paths relative to the repo root.
354 // Each is generated source containing literal copies of
355 // rule bodies; scanning them in --all mode would produce
356 // self-matches. Pinned by their expected location so the
357 // matcher does not fire on unrelated files of the same
358 // name elsewhere in the tree.
359 // Why: Same anti-self-match guard as the previous basename
360 // list, but anchored to specific paths. If the binary is
361 // run from outside the monorepo or these files have been
362 // relocated, canonicalize fails and the entry is dropped
363 // -- still no false negative because the file does not
364 // exist at the expected location, so the walker would
365 // not encounter it either.
366 //
367 // In TS you'd write (pseudocode):
368 // ```ts
369 // const CANONICAL_SELF_MATCH_PATHS = [ "...", "...", "..." ];
370 // ```
371 let canonical_self_match_paths = [
372 "package/cli/forbidden-strings/data/betterleaks-default-config.toml",
373 "package/cli/forbidden-strings/data/builtin-rules.txt",
374 "package/cli/forbidden-strings/src/port-betterleaks-relaxations.ts",
375 ];
376 for k in canonical_self_match_paths {
377 if let Ok(p) = std::fs::canonicalize(k) {
378 set.insert(p);
379 }
380 }
381 return set
382}
383
384/// Implements `is_walker_skipped`.
385// What: `fn is_walker_skipped(path: &str, skip_set: &HashSet<PathBuf>) -> bool`
386// returns true when the path's canonical form matches a
387// skip-set entry. Used ONLY for walker output in --all mode;
388// explicit positional args bypass this check entirely.
389// Why: Closes BUG 6: the previous `is_skipped_file` ran on every
390// queued path regardless of source, hiding real positive
391// findings on `sub/forbidden-strings.local.txt`-style explicit
392// args. The path-anchored form here is consulted only when
393// the caller knows the path came from the walker.
394//
395// In TS you'd write (pseudocode):
396// ```ts
397// function isWalkerSkipped(path: string, skipSet: Set<string>): boolean {
398// try {
399// const canonical = fs.realpathSync(path);
400// return skipSet.has(canonical);
401// } catch { return false; }
402// }
403// ```
404fn is_walker_skipped(
405 path: &str,
406 skip_set: &std::collections::HashSet<std::path::PathBuf>,
407) -> bool {
408 // What: Canonicalize per file and lookup in the skip set. A
409 // canonicalize failure (broken symlink, vanished file)
410 // returns false -- if we cannot resolve the path, we are
411 // definitely not skipping it. The downstream `fs::read`
412 // will surface any read error via the BUG 4 fix.
413 // Why: Per-file canonicalize is one stat syscall; with the
414 // ~2700-file walked corpus, that's a few ms total --
415 // well under the scan cost itself.
416 //
417 // In TS you'd write (pseudocode):
418 // ```ts
419 // try {
420 // const canonical = fs.realpathSync(path);
421 // return skipSet.has(canonical);
422 // } catch { return false; }
423 // ```
424 if let Ok(canonical) = std::fs::canonicalize(path) {
425 return skip_set.contains(&canonical);
426 }
427 return false
428}
429
430/// Implements `is_config_file_at_cwd`.
431// What: `fn is_config_file_at_cwd(path, cwd_canonical) -> bool` returns
432// true when `path` names a `forbidden-strings.*.txt` file sitting
433// DIRECTLY in the current working directory. The basename check is
434// cheap and runs first; only on a name match do we canonicalize to
435// confirm the file's parent IS the cwd, so a same-named file in a
436// subdirectory is not matched.
437// Why: These are the scanner's own ruleset files
438// (`forbidden-strings.local.txt`, `.local.example.txt`,
439// `.append.txt`, `.append.local.txt`). Scanning them re-derives the
440// rule bodies as self-matches, so they are always skipped, in BOTH
441// `--all` walker mode and explicit-positional-arg mode. Unlike the
442// `--all`-only `build_skip_set` guard, this one also fires on
443// explicit args, because CI passes changed files positionally
444// (`forbidden-strings --rules ... <changed>...`) and an edited
445// `forbidden-strings.append.txt` would otherwise self-match.
446//
447// The cwd anchor keeps BUG 6 / BUG 11 closed: a file like
448// `sub/forbidden-strings.local.txt` (different parent) still scans,
449// because only a config file at the cwd root is skipped.
450fn is_config_file_at_cwd(
451 path: &str,
452 cwd_canonical: Option<&std::path::Path>,
453) -> bool {
454 let name_matches = std::path::Path::new(path)
455 .file_name()
456 .and_then(|n| return n.to_str())
457 .is_some_and(|name| {
458 return name.starts_with("forbidden-strings.") && name.ends_with(".txt")
459 });
460 if !name_matches {
461 return false;
462 }
463 let Some(cwd) = cwd_canonical else {
464 return false;
465 };
466 let Ok(canonical) = std::fs::canonicalize(path) else {
467 return false;
468 };
469 return canonical.parent() == Some(cwd)
470}
471
472/// Defines the `BIN_PROBE_SIZE` constant.
473// What: `BIN_PROBE_SIZE` is the byte length read up-front from every
474// file before deciding whether the file is binary. 8 KiB is
475// the same probe size the pre-BUG-5 `is_likely_binary`
476// heuristic used; it matches `git diff`'s "binary or text"
477// heuristic threshold.
478// Why: The probe length tunes a tradeoff: smaller probe lets a
479// binary file with a leading text header (PDF header,
480// machine-O header) sneak past as text; larger probe wastes
481// memory on small files. 8 KiB catches the common cases
482// (PNG, JPG, ELF, WASM, zip, ZSTD frames) and is the
483// established convention.
484const BIN_PROBE_SIZE: usize = 8192;
485
486/// Implements `read_with_binary_check`.
487// What: `read_with_binary_check(path)` reads a file under a binary
488// heuristic:
489// 1. Always read the first `BIN_PROBE_SIZE` bytes.
490// 2. If the file is smaller than that, return what we got.
491// 3. If the probe contains a NUL byte and the file is
492// larger than the probe, return only the probe (the
493// rest is treated as binary tail and not scanned).
494// 4. Otherwise (probe is NUL-free), read and return the
495// full file.
496// Why: Closes the BUG-5 regression without re-introducing the
497// soundness gap that BUG 5 fixed. BUG 5 removed a heuristic
498// that threw away the WHOLE file when the first 8 KiB
499// contained a NUL byte; that masked secrets sitting BEFORE
500// the NUL. This rule keeps that signal (the first 8 KiB is
501// always scanned), but caps the per-file work on large
502// binary blobs (firmware images, vmlinuz, font caches, lock
503// sidecars) at 8 KiB instead of full content. Acceptable
504// miss: a secret living AFTER a NUL byte in a file that is
505// ALSO larger than 8 KiB. Acceptable: those files are the
506// "binary blob with bytes that happen to spell a secret"
507// case, and the secret-leak risk is dominated by source
508// files and small lock files which still scan in full.
509//
510// In TS you'd write (pseudocode):
511// ```ts
512// function readWithBinaryCheck(path: string): Buffer {
513// const fd = fs.openSync(path, "r");
514// try {
515// const probe = Buffer.alloc(BIN_PROBE_SIZE);
516// const n = fs.readSync(fd, probe, 0, BIN_PROBE_SIZE, null);
517// if (n < BIN_PROBE_SIZE) return probe.subarray(0, n);
518// if (probe.indexOf(0) !== -1) return probe;
519// return Buffer.concat([probe, fs.readSync.readRestOf(fd)]);
520// } finally {
521// fs.closeSync(fd);
522// }
523// }
524// ```
525fn read_with_binary_check(path: &str) -> Result<Vec<u8>, std::io::Error> {
526/// Imports dependencies used by this module.
527 use std::fs::File;
528/// Imports dependencies used by this module.
529 use std::io::Read;
530
531 let mut file = File::open(path)?;
532 let mut buf: Vec<u8> = Vec::with_capacity(BIN_PROBE_SIZE);
533 (&mut file)
534 .take(BIN_PROBE_SIZE as u64)
535 .read_to_end(&mut buf)?;
536
537 if buf.len() < BIN_PROBE_SIZE {
538 return Ok(buf);
539 }
540
541 if memchr::memchr(0, &buf).is_some() {
542 return Ok(buf);
543 }
544
545 file.read_to_end(&mut buf)?;
546 return Ok(buf)
547}
548
549/// Run the scanner from real process arguments and environment values.
550// What: `pub fn run_cli_from_env() -> Result<i32>` is the library
551// entry point. It asks clap to parse `env::args()`, applies the
552// `FORBIDDEN_STRINGS_RULES` fallback, loads the ruleset, runs the
553// parallel scan, prints hits to stderr, and returns the exit code the
554// OS should see. `Result<i32>` lets the binary thin wrapper
555// decide how to report a catastrophic failure (the `Err` arm) versus a
556// regular run (`Ok(0)` clean, `Ok(1)` violation, `Ok(2)` usage error
557// already printed). Sibling shape considered: returning `ExitCode`
558// directly, rejected because tests written against the lib want a plain
559// `i32` they can compare, and `ExitCode` has no `Eq`.
560// Why: Coordinate clap parsing, ruleset loading, parallel scan, and result
561// reporting from a unit-testable surface. The bin target's `main` stays
562// a tiny wrapper that turns the returned code into an `ExitCode` and
563// prints `Err` to stderr with a fixed prefix.
564//
565// In TS you'd write (pseudocode):
566// ```ts
567// async function runCliFromEnv(): Promise<number> {
568// const cli = parseArgs(process.argv);
569// // ...
570// return anyViolation ? 1 : 0;
571// }
572// process.exit(await runCliFromEnv());
573// ```
574pub fn run_cli_from_env() -> Result<i32> {
575 // What: `Cli::try_parse_from(env::args())` calls the clap-generated parser.
576 // `::` is Rust's namespace operator. `env::args()` includes the
577 // program name at index 0, which clap expects so it can render usage.
578 // `match` extracts the success payload (`Ok(parsed_cli)`) or handles
579 // clap's display/error wrapper (`Err(parse_error)`).
580 // Why: Replace the handwritten argv loop with clap while preserving this
581 // function's non-panicking `Result<i32>` boundary.
582 //
583 // In TS you'd write (pseudocode):
584 // ```ts
585 // let cli: Cli;
586 // try { cli = parseArgs(process.argv); }
587 // catch (error) { print(error); return error.exitCode; }
588 // ```
589 let cli = match Cli::try_parse_from(env::args()) {
590 Ok(parsed_cli) => parsed_cli,
591 Err(parse_error) => {
592 // What: `if let Err(print_error) = parse_error.print() { ... }`
593 // is a one-arm pattern match over clap's attempt to write
594 // help, version, or parse errors to the right stream.
595 // `print_error.into()` converts an IO error into the
596 // `anyhow::Error` this function's `Err` arm carries.
597 // Why: If clap cannot print, surface that catastrophic failure to
598 // `main` instead of silently returning a success code.
599 //
600 // In TS you'd write (pseudocode):
601 // ```ts
602 // const printed = error.print();
603 // if (!printed.ok) throw new Error(String(printed.error));
604 // ```
605 if let Err(print_error) = parse_error.print() {
606 return Err(print_error.into());
607 }
608
609 // What: `return Ok(parse_error.exit_code());` converts clap's
610 // display/error outcome into this function's normal exit-code
611 // channel. Help and version return 0; invalid syntax returns 2.
612 // Why: Keep `run_cli_from_env` testable without letting clap call
613 // `std::process::exit` inside the library.
614 //
615 // In TS you'd write (pseudocode):
616 // ```ts
617 // return error.exitCode;
618 // ```
619 return Ok(parse_error.exit_code());
620 }
621 };
622
623 // A subcommand is a disjoint operation, not a scan option. Dispatch before
624 // reading scan-mode environment fallback or walking candidate files.
625 if let Some(command) = &cli.command {
626 runtime_cache::compile_rules_file_to_cache(command.rules_path())?;
627 return Ok(0);
628 }
629
630 // What: `cli.rules_path.or_else(|| env::var("...").ok()).unwrap_or_else(...)`
631 // applies the existing rules-path precedence. `Option::or_else`
632 // keeps clap's `Some(path)` when `--rules` was present; otherwise it
633 // runs the closure that reads `FORBIDDEN_STRINGS_RULES`. `.ok()`
634 // converts `Result<String, VarError>` into `Option<String>`.
635 // `.unwrap_or_else(...)` supplies the owned default `String` only
636 // when both sources are absent. Sibling type `&str` would borrow from
637 // either argv or env storage; owned `String` is simpler and matches
638 // the loader API.
639 // Why: Preserve documented precedence: `--rules`, then env var, then
640 // `./forbidden-strings.local.txt`.
641 //
642 // What: `let explicit_rules_source = cli.rules_path.is_some() || ...;`
643 // records, BEFORE the `or_else` chain below consumes
644 // `cli.rules_path`, whether the rules path was named explicitly
645 // (`--rules` or the env var) rather than falling back to the cwd
646 // default. `.is_some()` asks an `Option` "do you hold a value?";
647 // `env::var(...).is_ok()` asks the same of the env lookup's
648 // `Result`.
649 // Why: `--builtin-rules` tolerates a MISSING implicit default file
650 // (baseline-only scan) but must still error on an explicitly
651 // named missing file; that distinction is decided here.
652 //
653 // In TS you'd write (pseudocode):
654 // ```ts
655 // const explicitRulesSource = cli.rulesPath !== undefined
656 // || process.env.FORBIDDEN_STRINGS_RULES !== undefined;
657 // ```
658 let explicit_rules_source =
659 cli.rules_path.is_some() || env::var("FORBIDDEN_STRINGS_RULES").is_ok();
660
661 // What: `let builtin_rules = cli.builtin_rules;` copies the parsed
662 // boolean flag, same tiny-copy shape as `all` below.
663 // Why: The rules-loading closure below branches on it.
664 //
665 // In TS you'd write (pseudocode):
666 // ```ts
667 // const builtinRules = cli.builtinRules;
668 // ```
669 let builtin_rules = cli.builtin_rules;
670
671 // In TS you'd write (pseudocode):
672 // ```ts
673 // const rulesPath = cli.rulesPath ?? process.env.FORBIDDEN_STRINGS_RULES ??
674 // "forbidden-strings.local.txt";
675 // ```
676 let rules_path = cli
677 .rules_path
678 .or_else(|| return env::var("FORBIDDEN_STRINGS_RULES").ok())
679 .unwrap_or_else(|| return "forbidden-strings.local.txt".to_string());
680
681 // What: `let all = cli.all;` copies the parsed boolean flag. `bool` is a
682 // tiny copy type, unlike owned `String` or `Vec<String>`.
683 // Why: The rest of the run loop already branches on a local named `all`.
684 //
685 // In TS you'd write (pseudocode):
686 // ```ts
687 // const all = cli.all;
688 // ```
689 let all = cli.all;
690
691 // What: `let mut files = cli.files;` moves the owned vector of parsed
692 // positional file paths out of the `Cli` struct and makes the local
693 // binding mutable. Sibling `Vec<&str>` would borrow, but clap gives us
694 // owned `String`s that can outlive parsing.
695 // Why: `--all` mode replaces this vector with walker output later; explicit
696 // positional mode keeps the clap-parsed values.
697 //
698 // In TS you'd write (pseudocode):
699 // ```ts
700 // let files = cli.files;
701 // ```
702 let mut files = cli.files;
703 // Logical path overrides pair one-for-one with explicit content file operands.
704 let name_paths = cli.name_paths;
705 if !name_paths.is_empty() && (all || name_paths.len() != files.len()) {
706 eprintln!("forbidden-strings: --name-path requires one value per positional file and cannot accompany --all");
707 return Ok(2);
708 }
709
710 // Run `load_ruleset` and `list_files` concurrently when --all is
711 // set: rules loading is CPU-bound (regex compile + AC build);
712 // file walking is I/O-bound (directory traversal + gitignore parse).
713 // They share no state, so overlapping them shaves whichever side
714 // is shorter.
715 // What: `rayon::join(|| f1(), || f2())` runs two closures in
716 // parallel using the rayon threadpool. Returns a tuple
717 // of their return values once both finish. If only one
718 // closure has substantial work (e.g. when --all is off,
719 // we have no file walk to do), join still runs both --
720 // but the empty closure adds negligible cost.
721 // Why: Rules load is ~12ms for a 1k-rule ruleset; file walk
722 // is ~7ms on this repo. Sequential = 19ms; parallel = 12ms.
723 //
724 // In TS you'd write (pseudocode):
725 // ```ts
726 // const [rulesetResult, filesResult] = await Promise.all([
727 // loadRuleset(rulesPath),
728 // all ? listFiles(".") : Promise.resolve(null),
729 // ]);
730 // ```
731 // What: The first closure picks the loader by the `--builtin-rules`
732 // flag: `load_ruleset_with_builtin` appends the embedded
733 // baseline (and tolerates a missing implicit default file);
734 // `load_ruleset` is the unchanged flagless path.
735 // Why: Existing users see byte-identical behavior unless they opt in.
736 //
737 // In TS you'd write (pseudocode):
738 // ```ts
739 // const ruleset = builtinRules
740 // ? loadRulesetWithBuiltin(rulesPath, explicitRulesSource)
741 // : loadRuleset(rulesPath);
742 // ```
743 // What: `frx_load::load(rules_path, builtin_rules, explicit, BUILTIN_PRECOMPILED)`
744 // is the stage-two engine loader. It compiles the resolved runtime rules
745 // file from text and, under `--builtin-rules`, appends the embedded
746 // precompiled baseline; it returns a `LoadedRules` holding the ordered
747 // sets with their rule-id offsets. Errors are redacted (an I/O error
748 // names only the path; a compile error carries only an opaque index).
749 // Why: Replaces the resharp/`regex`/aho-corasick `load_ruleset` pipeline with
750 // the in-house engine while keeping this coordination point unchanged.
751 //
752 // In TS you'd write (pseudocode):
753 // ```ts
754 // const rulesResult = frxLoad(rulesPath, builtinRules, explicitRulesSource, BUILTIN_PRECOMPILED);
755 // ```
756 let (rules_result, listed_result): (Result<_>, Option<Result<Vec<String>>>) =
757 rayon::join(
758 || return frx_load::load(
759 &rules_path,
760 builtin_rules,
761 explicit_rules_source,
762 crate::BUILTIN_PRECOMPILED,
763 crate::BUILTIN_NAMES,
764 ),
765 || if all { return Some(list_files(".")) } else { return None },
766 );
767
768 // What: `let loaded = match rules_result { Ok(r) => r, Err(e) => { ...; return ... } };`
769 // destructures the `Result<LoadedRules>`. `Ok(r)` binds the loaded sets;
770 // `Err(e)` prints the redacted error and early-returns exit 2.
771 // Why: Unwrap the `Result` while presenting a friendly error instead of a panic.
772 //
773 // In TS you'd write (pseudocode):
774 // ```ts
775 // let loaded: LoadedRules;
776 // try { loaded = rulesResult; }
777 // catch (e) { console.error(`forbidden-strings: ${e}`); process.exit(2); }
778 // ```
779 let loaded = match rules_result {
780 Ok(r) => r,
781 Err(e) => {
782 // eprintln, not tracing: the user-facing CLI error contract is "forbidden-strings:
783 // <msg>" on stderr, asserted by integration tests; a tracing target/level prefix
784 // would break it.
785 eprintln!("forbidden-strings: {}", e);
786 return Ok(2);
787 }
788 };
789
790 // Cache diagnostics are compact redacted JSON lines. Emit them before any
791 // scan findings so cli-git's mixed-protocol parser sees deterministic order.
792 for warning in loaded.cache_warnings() {
793 eprintln!("{}", warning);
794 }
795
796 // What: `if let Some(listed) = listed_result { match listed { ... } }`.
797 // One-arm pattern match: enter the block ONLY when
798 // `listed_result` is `Some`, binding the inner
799 // `Result<Vec<String>>` to `listed`. Inside, a
800 // regular `match` extracts `Ok` (replace `files` with the
801 // walker's output) or `Err` (print, exit 2).
802 // Why: `listed_result` is `Some(...)` only when `--all` was
803 // passed; otherwise `None` and we skip silently, leaving
804 // `files` set to whatever came from positional args.
805 //
806 // In TS you'd write (pseudocode):
807 // ```ts
808 // if (listedResult !== null) {
809 // try { files = listedResult; }
810 // catch (e) { console.error(`forbidden-strings: ${e}`); process.exit(2); }
811 // }
812 // ```
813 if let Some(listed) = listed_result {
814 match listed {
815 Ok(f) => files = f,
816 Err(e) => {
817 // eprintln, not tracing: same user-facing "forbidden-strings: <msg>" CLI error
818 // contract on stderr asserted by integration tests.
819 eprintln!("forbidden-strings: {}", e);
820 return Ok(2);
821 }
822 }
823 }
824
825 // Fused read+scan: each rayon thread maps one file's bytes
826 // (via mmap; falls back to `fs::read` if mmap fails) and
827 // immediately scans them. The two-phase split that used to live
828 // here (Phase A reads, Phase B scans) traded cache locality for
829 // a clean separation but produced no measurable speedup -- after
830 // P1 the AC scan is so fast that file bytes go from disk to AC to
831 // discard within tens of microseconds. Fusing keeps each file's
832 // bytes hot in L1/L2 across the read->scan boundary instead of
833 // risking eviction during the materialize-then-iterate round trip.
834 // What: `files.par_iter().flat_map_iter(|p| { try mmap(p); scan_content(p, &bytes, &rs) }).collect::<Vec<String>>()`
835 // runs map+scan as one rayon work unit per file. The
836 // closure's `Mmap` (or `Vec<u8>` fallback) lives only
837 // until the scan finishes for that file; rayon
838 // work-steals across cores.
839 // Why: Mmap saves the alloc + memcpy that `fs::read` does.
840 // On a hot page cache, that's measurable on `--all`;
841 // on a cold cache, MADV_SEQUENTIAL lets the kernel
842 // readahead-pipeline files. Fallback to `fs::read`
843 // handles the cases mmap can't (empty files, /proc
844 // entries, character devices).
845 //
846 // In TS you'd write (pseudocode):
847 // ```ts
848 // const hits = (await Promise.all(
849 // files.map(async (p) => scanContent(p, await readFileFastest(p), ruleset))
850 // )).flat();
851 // ```
852 // What: Build the canonical-path skip set once at startup
853 // (rather than per-file). The set captures the actual
854 // rules file plus the canonical generated-source paths;
855 // empty when none of them resolve. Used only in --all
856 // mode to filter walker output.
857 // Why: Closes BUG 6: explicit positional args are never
858 // skipped; only walker output is filtered, and the filter
859 // is path-anchored (not basename-anchored), so
860 // `sub/forbidden-strings.local.txt` no longer collides
861 // with the actual rules file path.
862 //
863 // In TS you'd write (pseudocode):
864 // ```ts
865 // const skipSet = buildSkipSet(rulesPath);
866 // ```
867 let skip_set = if all { build_skip_set(&rules_path) } else { std::collections::HashSet::new() };
868
869 // What: Canonical cwd, resolved once. `is_config_file_at_cwd`
870 // compares each candidate's canonical parent against this to
871 // skip the scanner's own `forbidden-strings.*.txt` ruleset
872 // files at the repo root, in both --all and explicit-arg modes.
873 // Why: Resolve symlinks once here rather than per file.
874 let cwd_canonical = std::fs::canonicalize(".").ok();
875 // A present `.git` at an ancestor identifies repository-relative name labels.
876 let repository_root = path_scan::repository_root();
877
878 let hits: Vec<String> = files
879 .par_iter()
880 .enumerate()
881 .flat_map_iter(|(index, p)| {
882 // Always skip the scanner's own ruleset files at cwd
883 // (forbidden-strings.*.txt), regardless of --all vs explicit
884 // args: they hold literal rule bodies that self-match.
885 if is_config_file_at_cwd(p, cwd_canonical.as_deref()) {
886 return Vec::new();
887 }
888 // What: `if all && is_walker_skipped(p, &skip_set) { return Vec::new(); }`.
889 // Only runs the skip check on walker output
890 // (--all mode). For explicit positional args
891 // (`forbidden-strings <path>...` without --all),
892 // the file is ALWAYS scanned -- the user asked.
893 // Why: Closes BUG 6: the previous basename-based skip
894 // hid real findings on
895 // `sub/forbidden-strings.local.txt` and friends
896 // passed as explicit args. The new check applies
897 // only when the walker discovered the file
898 // automatically.
899 //
900 // Inside the conditional, `is_walker_skipped`
901 // canonicalizes the path and compares against
902 // the pre-built skip set. Path-anchored matching
903 // also closes BUG 11 (Windows backslash basename)
904 // by routing through `std::fs::canonicalize`.
905 //
906 // In TS you'd write (pseudocode):
907 // ```ts
908 // if (all && isWalkerSkipped(p, skipSet)) return [];
909 // ```
910 if all && is_walker_skipped(p, &skip_set) {
911 return Vec::new();
912 }
913 // What: `let content = fs::read(p).unwrap_or_default();`.
914 // `fs::read` returns `Result<Vec<u8>, io::Error>`
915 // (the file's raw bytes or an I/O error).
916 // `.unwrap_or_default()` extracts the `Ok` value or
917 // substitutes `Vec::<u8>::default()` (the empty
918 // vec) and SILENTLY DROPS the error. The implicit
919 // inferred type is `Vec<u8>`. Sibling pattern:
920 // `fs::read_to_string` returns `Result<String, _>`
921 // but requires UTF-8 -- we want raw bytes here
922 // because rules scan binary files too.
923 // Why: A file we can't read (permissions, vanished,
924 // etc.) becomes "empty content" and the scan
925 // pass produces zero hits for it. Crashing the
926 // whole walk on one unreadable file is worse.
927 // Gotcha: `.unwrap_or_default()` SILENTLY discards the
928 // `io::Error`. We accept that here because the
929 // per-file scan is best-effort.
930 //
931 // In TS you'd write (pseudocode):
932 // ```ts
933 // let content: Uint8Array;
934 // try { content = await readFile(p); }
935 // catch (e) { return [`${p}: read error: ${e.message}`]; }
936 // return scanContent(p, content, ruleset);
937 // ```
938 // What: `match fs::read(p) { Ok(c) => ..., Err(e) => ... }`.
939 // Read error path now emits a synthetic "hit"
940 // string formatted as `{path}: read error: {err}`
941 // instead of silently substituting empty content.
942 // The synthetic entry makes the file appear in the
943 // output report AND keeps the exit code at 1 (hits
944 // non-empty -> ExitCode::from(1) downstream).
945 // Why: Closes BUG 4. Pre-fix, `fs::read(p).unwrap_or_default()`
946 // dropped every io::Error: permissions, missing
947 // file, broken symlink, /proc EACCES, etc. became
948 // "empty content", the scan emitted zero hits, and
949 // the run exited 0. A secret-scanning CI control
950 // must NOT silently pass on unreadable files; the
951 // operator needs to know they had no signal.
952 //
953 // In TS you'd write (pseudocode):
954 // ```ts
955 // try { content = await readFile(p); }
956 // catch (e) { return [`${p}: read error: ${e}`]; }
957 // ```
958 // Overrides name historical candidate paths while keeping temporary
959 // content files isolated. Without an override, use the real file's
960 // repository-relative name or all supplied external path segments.
961 let logical = name_paths.get(index).cloned().unwrap_or_else(|| {
962 return path_scan::logical_path(p, repository_root.as_deref());
963 });
964 let pathname = path_scan::scan_path(&logical, &loaded);
965 let mut findings = pathname.findings;
966 let content_result = match read_with_binary_check(p) {
967 Ok(c) => Some(c),
968 Err(e) => {
969 findings.push(format!("{}: read error: {}", pathname.display, e));
970 None
971 }
972 };
973 // What: `frx_scan::scan_file(p, &content, &loaded)` splits the file
974 // into lines and runs each loaded set's `line_matches` under a
975 // fail-closed unwind boundary, returning `PATH:LINE rule=N`
976 // findings. `&content` and `&loaded` are read-only borrows; the
977 // returned `Vec<String>` is this closure's tail expression.
978 // Why: Hand the just-read bytes to the engine line scan; the returned
979 // findings become this closure's contribution to the parallel
980 // flat_map output.
981 //
982 // In TS you'd write (pseudocode):
983 // ```ts
984 // return scanFile(p, content, loaded);
985 // ```
986 if let Some(content) = content_result {
987 findings.extend(frx_scan::scan_file(&pathname.display, &content, &loaded));
988 }
989 // An opaque operand index lets policy clients recover candidate
990 // identity even when several paths redact to the same label.
991 if !name_paths.is_empty() {
992 return findings.into_iter().map(|hit| return format!("{} input={}", hit, index)).collect();
993 }
994 return findings
995 })
996 .collect();
997
998 // What: `std::io::stderr().lock()` returns a `StderrLock`, an
999 // RAII handle holding the stderr mutex. Held writes
1000 // don't interleave with other threads.
1001 // Why: Print all hits in one batch.
1002 //
1003 // In TS you'd write (pseudocode):
1004 // ```ts
1005 // for (const h of hits) process.stderr.write(h + "\n");
1006 // ```
1007 let stderr = std::io::stderr();
1008 let mut handle = stderr.lock();
1009 for h in &hits {
1010 let _ = writeln!(handle, "{}", h);
1011 }
1012
1013 // What: `if hits.is_empty() { Ok(0) } else { Ok(1) }`.
1014 // This is an `if`-as-EXPRESSION (not statement) with no
1015 // trailing `;`: its value becomes the function's return.
1016 // `Ok(0)` and `Ok(1)` construct the success variant of
1017 // `Result<i32>` with the OS-exit code inside; the
1018 // bin wrapper converts to `ExitCode` for the actual exit.
1019 // Why: No hits = clean exit; one or more hits = "violation"
1020 // exit so CI marks the run as failed.
1021 //
1022 // In TS you'd write (pseudocode):
1023 // ```ts
1024 // return hits.length === 0 ? 0 : 1;
1025 // ```
1026 if hits.is_empty() {
1027 return Ok(0)
1028 } else {
1029 return Ok(1)
1030 }
1031}