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}