Skip to main content

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}