Skip to main content

git_policy_cli/
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 super::candidate_record::CandidateChange;
25use super::candidate_version::Candidate;
26/// Import the configured rules files.
27use super::config_schema::RulesFileEntry;
28/// Import the rules selection the adapter loads.
29use super::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;