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