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