Skip to main content

git_policy_cli/candidate/
scanner_adapter.rs

1//! What:
2//!  The repository's forbidden-strings scanner,
3//!  linked in and loaded once per invocation.
4//! Why:
5//!  The TypeScript wrapper wrote each candidate to a temporary file,
6//!  started the
7//!      scanner as a child process and parsed its standard error.
8//!  Here candidate bytes
9//!      go straight to the scanner library,
10//!  and its own typed findings come back.
11//!      Nothing is re-derived:
12//!  rule loading,
13//!  the rule cache,
14//!  matching,
15//!  redaction and
16//!      the fail-closed findings all stay the scanner's.
17//!
18//! What the linking executable owes the scanner (`package/forbidden-strings/cli/README.md`,
19//! "In-process candidate scans"):
20//!  an unwinding panic strategy,
21//!  which this file checks at
22//! build time,
23//!  and a panic hook that prints no payload,
24//!  installed before the first scan.
25//! The scanner catches its own loader and matcher panics;
26//!  this module adds no catch of its own.
27//!
28//! In TS you'd write (pseudocode):
29//! ```ts
30//! // const scanner = CandidateScanner.load(rules, true); const scan = scanner.scan(candidate, bytes);
31//! ```
32
33/// Import the candidate whose pathname and identity each scan carries.
34use crate::candidate_version::Candidate;
35/// Import the byte-preserving conversion from Git path bytes to a native path.
36use crate::git_metadata::path_from_git_bytes;
37/// What:
38///  The scanner library's public types:
39///  its fixed-token cache diagnostic,
40///  its
41///       per-candidate result,
42///  and the loaded rule set.
43/// Why:
44///   They are returned to callers unchanged,
45///  so findings are never re-encoded.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// import { type CacheWarning, type CandidateScan, Scanner } from 'forbidden-strings';
50/// ```
51use forbidden_strings::{CacheWarning, CandidateScan, RulesEntry, Scanner};
52/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
53use std::path::{Path, PathBuf};
54
55// What: `const _: () = assert!(...)` is a check the compiler evaluates while building;
56//       `cfg!(panic = "unwind")` is true only when panics unwind the stack.
57// Why:  The scanner turns a matcher panic into a fail-closed finding by catching the
58//       unwind. Under `panic = "abort"` there is nothing to catch and the process
59//       would die mid-scan, so such a build is refused instead of shipped.
60//
61// In TS you'd write (pseudocode):
62// ```ts
63// // No equivalent: a build-time assertion about how thrown errors propagate.
64// ```
65const _: () = assert!(
66    cfg!(panic = "unwind"),
67    "monochromatic-git-policy-cli links the forbidden-strings scanner, whose fail-closed catch boundaries need panic = \"unwind\""
68);
69
70/// What:
71///  Why a scan could not be set up or carried out.
72///       `#[derive(...)]` generates copying,
73///  debug printing and `==`.
74/// Why:
75///   Both causes stop policy evaluation:
76///  no rules means nothing was checked,
77///  and a
78///       pathname that cannot be handed to the scanner was not checked.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// type ScannerFailure = 'rules-not-loaded' | 'pathname-unrepresentable';
83/// ```
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum ScannerFailure {
86    /// The scanner refused to load its rules;
87    ///  no partial rule set exists.
88    RulesNotLoaded,
89    /// A candidate pathname cannot be expressed as a native path on this platform.
90    PathnameUnrepresentable,
91}
92
93/// What:
94///  One scanner failure:
95///  its cause and a complete explanation.
96///       `String` owns the text (sibling `&str` would borrow a temporary).
97/// Why:
98///   The explanation of a load failure is the scanner's own redacted message,
99///       which names a path or an opaque rule index and never rule text.
100///
101/// In TS you'd write (pseudocode):
102/// ```ts
103/// class ScannerError extends Error { failure: ScannerFailure; message: string }
104/// ```
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct ScannerError {
107    /// Closed cause callers branch on.
108    pub failure: ScannerFailure,
109    /// Complete diagnostic.
110    pub message: String,
111}
112
113/// What:
114///  `impl std::fmt::Display for ScannerError` supplies Rust's "print me" interface.
115/// Why:
116///   The executable writes the message with ordinary formatting,
117///  not field access.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// toString(): string { return this.message; }
122/// ```
123impl std::fmt::Display for ScannerError {
124    /// `&self` borrows this error read-only;
125    ///  `&mut` lends the formatter for writing.
126    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        // `.as_str()` lends the owned message as a borrowed view for the writer.
128        return formatter.write_str(self.message.as_str());
129    }
130}
131
132/// An empty `impl` marks the type as a standard error value.
133impl std::error::Error for ScannerError {}
134
135/// What:
136///  Which rules files to load,
137///  in which order,
138///  and whether each one's absence is an error.
139///       `Vec<RulesEntry>` is an owned list of the scanner library's own entries
140///  (a path and an `optional` flag).
141/// Why:
142///   The standalone scanner tolerates a missing default rules file when the
143///       built-in baseline is on,
144///  and never a missing file that was named
145///       explicitly unless its entry is optional.
146///  The same facts are passed to the library.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// type RulesSource = { entries: { path: string; optional: boolean }[]; explicit: boolean };
151/// ```
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct RulesSource {
154    /// The runtime rules files in the order they are read,
155    ///  each path absolute;
156    ///  one entry when `explicit` is false.
157    pub entries: Vec<RulesEntry>,
158    /// Whether the files were named by configuration or the environment variable instead of being the default location.
159    pub explicit: bool,
160}
161
162/// What:
163///  The loaded scanner.
164///  The field is private:
165///  the only way to obtain a value is `load`.
166/// Why:
167///   Holding the loaded rules in one value is what makes "load once,
168///  scan many"
169///       a property of the type:
170///  scanning borrows it and cannot reload.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// class CandidateScanner { readonly #scanner: Scanner }
175/// ```
176pub struct CandidateScanner {
177    /// The scanner library's loaded runtime rules,
178    ///  optional baseline and cache warnings.
179    scanner: Scanner,
180}
181
182/// What:
183///  `impl CandidateScanner { ... }` attaches the adapter's operations,
184///  like class methods.
185/// Why:
186///   Loading is the only fallible,
187///  expensive step;
188///  scanning afterwards is cheap and repeatable.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// class CandidateScanner { static load(rules, builtinRules) {} scan(candidate, bytes) {} }
193/// ```
194impl CandidateScanner {
195    /// What:
196    ///  Load the runtime rules file and,
197    ///  when asked,
198    ///  the built-in baseline.
199    ///       `&RulesSource` borrows the selection;
200    ///  `Result<T, E>` is "a value or a failure".
201    /// Why:
202    ///   The scanner's loader validates rules,
203    ///  reuses its compiled-rule cache and
204    ///       catches its own panics.
205    ///  Any failure leaves no scanner at all,
206    ///  so nothing
207    ///       can be scanned with half a rule set.
208    ///
209    /// In TS you'd write (pseudocode):
210    /// ```ts
211    /// static load(rules: RulesSource, builtinRules: boolean): CandidateScanner
212    /// ```
213    pub fn load(
214        rules: &RulesSource,
215        builtin_rules: bool,
216    ) -> Result<CandidateScanner, ScannerError> {
217        // What: `match` on the loader's `Result`; `Err(error)` binds the scanner's failure,
218        //       whose `{error}` text is already redacted.
219        // Why:  The failure type belongs to a crate this one does not depend on by name,
220        //       so it is turned into text here, at the one place it appears.
221        //
222        // In TS you'd write (pseudocode):
223        // ```ts
224        // try { return new CandidateScanner(Scanner.load(...)); } catch (error) { throw new ScannerError(...); }
225        // ```
226        let loaded = match (rules.explicit, rules.entries.as_slice()) {
227            // The default location keeps the scanner's one-path contract: absent only under the baseline.
228            (false, [only]) => Scanner::load(only.path.as_path(), builtin_rules, false),
229            (_, entries) => Scanner::load_entries(entries, builtin_rules),
230        };
231        match loaded {
232            // `Ok(...)` is the success variant.
233            Ok(scanner) => return Ok(CandidateScanner { scanner }),
234            // `Err(...)` is the failure variant.
235            Err(error) => {
236                return Err(ScannerError {
237                    failure: ScannerFailure::RulesNotLoaded,
238                    message: format!(
239                        "cli-git could not load the forbidden-strings rules, so no file was scanned: {error}."
240                    ),
241                });
242            }
243        }
244    }
245
246    /// What:
247    ///  The cache diagnostics of this load,
248    ///  as the scanner's own fixed-token values.
249    ///       `&[CacheWarning]` borrows the list read-only.
250    /// Why:
251    ///   A stale or missing rule cache is recovered by recompiling,
252    ///  which is slow;
253    ///       the caller reports that once per invocation.
254    ///
255    /// In TS you'd write (pseudocode):
256    /// ```ts
257    /// get cacheWarnings(): readonly CacheWarning[]
258    /// ```
259    pub fn cache_warnings(&self) -> &[CacheWarning] {
260        return self.scanner.cache_warnings();
261    }
262
263    /// What:
264    ///  The optional rules files that were absent and skipped by this load,
265    ///  in list order.
266    ///       `&[PathBuf]` borrows the list read-only.
267    /// Why:
268    ///   The scanner library has no log subscriber inside cli-git,
269    ///  so the caller reports the skipped files.
270    ///
271    /// In TS you'd write (pseudocode):
272    /// ```ts
273    /// get skippedOptionalRules(): readonly string[]
274    /// ```
275    pub fn skipped_optional_rules(&self) -> &[PathBuf] {
276        return self.scanner.skipped_optional_rules();
277    }
278
279    /// What:
280    ///  Scan one candidate's pathname and exact bytes.
281    ///       `&[u8]` borrows the bytes;
282    ///  the result is the scanner's own `CandidateScan`.
283    /// Why:
284    ///   The scan's identity is the candidate's position in its version,
285    ///  so a
286    ///       finding is attributed without reading the pathname,
287    ///  which may be masked.
288    ///       Fail-closed findings (an engine error,
289    ///  a line break in a pathname) are
290    ///       returned like any other finding and are never filtered here.
291    ///
292    /// In TS you'd write (pseudocode):
293    /// ```ts
294    /// scan(candidate: Candidate, bytes: Uint8Array): CandidateScan
295    /// ```
296    pub fn scan(&self, candidate: &Candidate, bytes: &[u8]) -> Result<CandidateScan, ScannerError> {
297        // `let Some(..) = .. else { .. }` unwraps the native path or leaves with a failure.
298        let Some(path) = path_from_git_bytes(candidate.path.as_slice()) else {
299            return Err(ScannerError {
300                failure: ScannerFailure::PathnameUnrepresentable,
301                message: format!(
302                    "cli-git could not scan candidate {}: its pathname cannot be expressed as a native path on this platform.",
303                    candidate.identity.index
304                ),
305            });
306        };
307        let logical: &Path = path.as_path();
308        return Ok(self.scanner.scan(candidate.identity.index, logical, bytes));
309    }
310}
311
312/// Scanner controls stay out of the release executable.
313#[cfg(test)]
314#[path = "scanner_adapter_tests.rs"]
315mod tests;