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:
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}