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