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;