Skip to main content

git_policy_cli/
scanner_selection.rs

1//! What:
2//!  Which rules file the scanner loads,
3//!  and which candidates it is given.
4//! Why:
5//!  The scanner library filters nothing;
6//!  the standalone scanner and the TypeScript
7//!      policy applied these choices around it.
8//!  They are restated here once,
9//!  as pure
10//!      functions:
11//!  the rules-file precedence (`FORBIDDEN_STRINGS_RULES`,
12//!  else
13//!      `forbidden-strings.local.txt` in the repository root) and the paths that hold
14//!      the rules themselves,
15//!  which would otherwise match their own content.
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! // const rules = rulesSource(process.env.FORBIDDEN_STRINGS_RULES, repositoryRoot);
20//! // const eligible = candidates.filter(candidate => isScannable(candidate, rulesCandidatePath(rules.path, repositoryRoot)));
21//! ```
22
23/// Import the candidate being judged and its change kinds.
24use super::candidate_record::CandidateChange;
25use super::candidate_version::Candidate;
26/// Import the rules selection the adapter loads.
27use super::scanner_adapter::RulesSource;
28/// `OsStr` is borrowed operating-system text of raw OS bytes (sibling `str` must be UTF-8).
29use std::ffi::OsStr;
30/// What:
31///  `Component` is one piece of a path:
32///  the root,
33///  `.`,
34///  `..`,
35///  or a name.
36///       `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
37/// Why:
38///   A configured rules path is resolved name by name,
39///  without touching the filesystem.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// import { resolve, relative } from 'node:path';
44/// ```
45use std::path::{Component, Path, PathBuf};
46
47/// What:
48///  The environment variable naming the runtime rules file.
49///       `&str` is a borrowed string compiled into the program.
50/// Why:
51///   It is the standalone scanner's own variable,
52///  so one setting serves both.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// const RULES_VARIABLE = 'FORBIDDEN_STRINGS_RULES';
57/// ```
58pub const RULES_VARIABLE: &str = "FORBIDDEN_STRINGS_RULES";
59
60/// The rules file used when the variable is unset,
61///  relative to the repository root.
62pub const DEFAULT_RULES_FILE: &str = "forbidden-strings.local.txt";
63
64/// What:
65///  Repository paths never given to the scanner because they hold rule sources.
66///       `&[&[u8]]` is a borrowed list of borrowed byte strings.
67/// Why:
68///   These files contain the patterns themselves and would match their own rules.
69///       The list is the TypeScript policy's,
70///  unchanged;
71///  pathnames are compared as
72///       bytes because candidate pathnames are bytes.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// const SCANNER_SELF_MATCH_PATHS = new Set(['package/cli/forbidden-strings/data/builtin-rules.txt', ...]);
77/// ```
78pub const SCANNER_SELF_MATCH_PATHS: &[&[u8]] = &[
79    b"package/cli/forbidden-strings/data/betterleaks-default-config.toml",
80    b"package/cli/forbidden-strings/data/builtin-rules.txt",
81    b"package/cli/forbidden-strings/src/port-betterleaks-relaxations.ts",
82];
83
84/// What:
85///  Select the rules file:
86///  the configured `rulesFile`,
87///  else the variable's value,
88///  else
89///       the default file,
90///  each relative to the repository root.
91///  `Option<&str>` is "the
92///       configured name or nothing";
93///  `Option<&OsStr>` is "the variable's value or nothing".
94/// Why:
95///   The user decided on 2026-10-05 that configuration names the file first and the
96///       variable second.
97///  The spawned scanner ran with the repository root as its working
98///       directory,
99///  so a relative setting meant "relative to the root".
100///  In-process there
101///       is no such directory change,
102///  so the root is joined explicitly;
103///  `.join(..)` keeps
104///       an absolute variable value as it is,
105///  and a configured name is relative by
106///       construction (`config_rules_file.rs`).
107///  A configured name or a set variable makes
108///       a missing file an error;
109///  only the default file may be missing.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// function rulesSource(rulesFile: string | undefined, variable: string | undefined, repositoryRoot: string): RulesSource;
114/// ```
115pub fn rules_source(
116    rules_file: Option<&str>,
117    configured: Option<&OsStr>,
118    repository_root: &Path,
119) -> RulesSource {
120    // `if let Some(name) = rules_file` runs only when the configuration names a file.
121    if let Some(name) = rules_file {
122        return RulesSource {
123            path: repository_root.join(name),
124            explicit: true,
125        };
126    }
127    // `match` on the optional value: `Some(value)` is "present", `None` is "absent".
128    match configured {
129        Some(value) => {
130            return RulesSource {
131                path: repository_root.join(value),
132                explicit: true,
133            };
134        }
135        None => {
136            return RulesSource {
137                path: repository_root.join(DEFAULT_RULES_FILE),
138                explicit: false,
139            };
140        }
141    }
142}
143
144/// What:
145///  Resolve `.` and `..` in a path by its names alone.
146///       `PathBuf::new()` is an empty owned path;
147///  `.push(..)` appends one piece and
148///       `.pop()` removes the last one.
149/// Why:
150///   The rules file need not exist,
151///  so the filesystem cannot be asked;
152///  this is the
153///       same textual resolution the TypeScript policy applied.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// const normalized = path.resolve(configured);
158/// ```
159fn lexically_normalized(path: &Path) -> PathBuf {
160    let mut normalized: PathBuf = PathBuf::new();
161    // `.components()` yields the path's pieces from left to right.
162    for component in path.components() {
163        // What: `match` on the piece's kind; `Component::Normal(_)` is an ordinary name.
164        // Why:  `.` adds nothing and `..` removes the name before it; everything else is kept.
165        //
166        // In TS you'd write (pseudocode):
167        // ```ts
168        // if (part === '.') continue; if (part === '..') parts.pop(); else parts.push(part);
169        // ```
170        match component {
171            Component::CurDir => {}
172            Component::ParentDir => {
173                normalized.pop();
174            }
175            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
176                // `.as_os_str()` lends the piece's own text.
177                normalized.push(component.as_os_str());
178            }
179        }
180    }
181    return normalized;
182}
183
184/// What:
185///  Turn a repository-relative native path into Git's pathname bytes:
186///  its names joined by `/`.
187///       `Vec<u8>` is an owned byte list (sibling `String` would require UTF-8).
188/// Why:
189///   Git stores `/` between names on every system.
190///  Joining the names here,
191///  instead
192///       of copying the path's own text,
193///  gives the same bytes whatever separator the
194///       operating system uses,
195///  with one implementation for all of them.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// const gitPath = relativePath.split(path.sep).join('/');
200/// ```
201fn git_path_bytes(relative: &Path) -> Vec<u8> {
202    let mut bytes: Vec<u8> = Vec::new();
203    for component in relative.components() {
204        if !bytes.is_empty() {
205            bytes.push(b'/');
206        }
207        // `.as_encoded_bytes()` views one name as bytes: the raw bytes on Unix, UTF-8 elsewhere.
208        bytes.extend_from_slice(component.as_os_str().as_encoded_bytes());
209    }
210    return bytes;
211}
212
213/// What:
214///  The rules file's pathname as a candidate would carry it,
215///  when it lies inside the repository.
216///       `Option<Vec<u8>>` is "pathname bytes or nothing".
217/// Why:
218///   A rules file tracked in the repository would match its own patterns,
219///  so its
220///       candidate is not scanned.
221///  A rules file outside the repository can never be a
222///       candidate,
223///  which is "nothing".
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// function rulesCandidatePath(rulesPath: string, repositoryRoot: string): string | undefined;
228/// ```
229pub fn rules_candidate_path(rules_path: &Path, repository_root: &Path) -> Option<Vec<u8>> {
230    let normalized: PathBuf = lexically_normalized(rules_path);
231    // What: `.strip_prefix(root)` is `Ok(rest)` when the path starts with the root's
232    //       names; `.ok()?` turns a failure into `None` and returns it.
233    // Why:  Only a path below the root has a repository-relative pathname.
234    //
235    // In TS you'd write (pseudocode):
236    // ```ts
237    // const relative = path.relative(root, normalized); if (relative.startsWith('..')) return undefined;
238    // ```
239    let relative: &Path = normalized.strip_prefix(repository_root).ok()?;
240    if relative.as_os_str().is_empty() {
241        // `None` is the "absent" variant: the root itself is not a file.
242        return None;
243    }
244    // `Some(...)` is the "present" variant.
245    return Some(git_path_bytes(relative));
246}
247
248/// What:
249///  Whether a candidate is given to the scanner.
250///       `Option<&[u8]>` is "the rules file's pathname bytes or nothing".
251/// Why:
252///   A deleted path has no content and leaves the tree;
253///  the rule-source files and
254///       the rules file itself would match their own patterns.
255///  Every other candidate
256///       is scanned,
257///  whatever its mode.
258///
259/// In TS you'd write (pseudocode):
260/// ```ts
261/// function isScannable(candidate: Candidate, rulesPath?: string): boolean;
262/// ```
263pub fn is_scannable(candidate: &Candidate, rules_path: Option<&[u8]>) -> bool {
264    if candidate.change == CandidateChange::Deleted {
265        return false;
266    }
267    if rules_path == Some(candidate.path.as_slice()) {
268        return false;
269    }
270    // `.contains(&..)` asks whether the list holds this exact byte string.
271    return !SCANNER_SELF_MATCH_PATHS.contains(&candidate.path.as_slice());
272}
273
274/// Selection controls stay out of the release executable.
275#[cfg(test)]
276#[path = "scanner_selection_tests.rs"]
277mod tests;