Skip to main content

git_policy_cli/
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/cli/forbidden-strings/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 super::candidate_version::Candidate;
35/// Import the byte-preserving conversion from Git path bytes to a native path.
36use super::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, 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 file to load and whether its absence is an error.
137///       `PathBuf` owns the path (sibling `&Path` would borrow the caller's).
138/// Why:
139///   The standalone scanner tolerates a missing default rules file when the
140///       built-in baseline is on,
141///  and never a missing file that was named
142///       explicitly.
143///  The same two facts are passed to the library.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// type RulesSource = { path: string; explicit: boolean };
148/// ```
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct RulesSource {
151    /// The runtime rules file,
152    ///  absolute.
153    pub path: PathBuf,
154    /// Whether the file was named by configuration instead of being the default location.
155    pub explicit: bool,
156}
157
158/// What:
159///  The loaded scanner.
160///  The field is private:
161///  the only way to obtain a value is `load`.
162/// Why:
163///   Holding the loaded rules in one value is what makes "load once,
164///  scan many"
165///       a property of the type:
166///  scanning borrows it and cannot reload.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// class CandidateScanner { readonly #scanner: Scanner }
171/// ```
172pub struct CandidateScanner {
173    /// The scanner library's loaded runtime rules,
174    ///  optional baseline and cache warnings.
175    scanner: Scanner,
176}
177
178/// What:
179///  `impl CandidateScanner { ... }` attaches the adapter's operations,
180///  like class methods.
181/// Why:
182///   Loading is the only fallible,
183///  expensive step;
184///  scanning afterwards is cheap and repeatable.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// class CandidateScanner { static load(rules, builtinRules) {} scan(candidate, bytes) {} }
189/// ```
190impl CandidateScanner {
191    /// What:
192    ///  Load the runtime rules file and,
193    ///  when asked,
194    ///  the built-in baseline.
195    ///       `&RulesSource` borrows the selection;
196    ///  `Result<T, E>` is "a value or a failure".
197    /// Why:
198    ///   The scanner's loader validates rules,
199    ///  reuses its compiled-rule cache and
200    ///       catches its own panics.
201    ///  Any failure leaves no scanner at all,
202    ///  so nothing
203    ///       can be scanned with half a rule set.
204    ///
205    /// In TS you'd write (pseudocode):
206    /// ```ts
207    /// static load(rules: RulesSource, builtinRules: boolean): CandidateScanner
208    /// ```
209    pub fn load(
210        rules: &RulesSource,
211        builtin_rules: bool,
212    ) -> Result<CandidateScanner, ScannerError> {
213        // What: `match` on the loader's `Result`; `Err(error)` binds the scanner's failure,
214        //       whose `{error}` text is already redacted.
215        // Why:  The failure type belongs to a crate this one does not depend on by name,
216        //       so it is turned into text here, at the one place it appears.
217        //
218        // In TS you'd write (pseudocode):
219        // ```ts
220        // try { return new CandidateScanner(Scanner.load(...)); } catch (error) { throw new ScannerError(...); }
221        // ```
222        match Scanner::load(rules.path.as_path(), builtin_rules, rules.explicit) {
223            // `Ok(...)` is the success variant.
224            Ok(scanner) => return Ok(CandidateScanner { scanner }),
225            // `Err(...)` is the failure variant.
226            Err(error) => {
227                return Err(ScannerError {
228                    failure: ScannerFailure::RulesNotLoaded,
229                    message: format!(
230                        "cli-git could not load the forbidden-strings rules, so no file was scanned: {error}."
231                    ),
232                });
233            }
234        }
235    }
236
237    /// What:
238    ///  The cache diagnostics of this load,
239    ///  as the scanner's own fixed-token values.
240    ///       `&[CacheWarning]` borrows the list read-only.
241    /// Why:
242    ///   A stale or missing rule cache is recovered by recompiling,
243    ///  which is slow;
244    ///       the caller reports that once per invocation.
245    ///
246    /// In TS you'd write (pseudocode):
247    /// ```ts
248    /// get cacheWarnings(): readonly CacheWarning[]
249    /// ```
250    pub fn cache_warnings(&self) -> &[CacheWarning] {
251        return self.scanner.cache_warnings();
252    }
253
254    /// What:
255    ///  Scan one candidate's pathname and exact bytes.
256    ///       `&[u8]` borrows the bytes;
257    ///  the result is the scanner's own `CandidateScan`.
258    /// Why:
259    ///   The scan's identity is the candidate's position in its version,
260    ///  so a
261    ///       finding is attributed without reading the pathname,
262    ///  which may be masked.
263    ///       Fail-closed findings (an engine error,
264    ///  a line break in a pathname) are
265    ///       returned like any other finding and are never filtered here.
266    ///
267    /// In TS you'd write (pseudocode):
268    /// ```ts
269    /// scan(candidate: Candidate, bytes: Uint8Array): CandidateScan
270    /// ```
271    pub fn scan(&self, candidate: &Candidate, bytes: &[u8]) -> Result<CandidateScan, ScannerError> {
272        // `let Some(..) = .. else { .. }` unwraps the native path or leaves with a failure.
273        let Some(path) = path_from_git_bytes(candidate.path.as_slice()) else {
274            return Err(ScannerError {
275                failure: ScannerFailure::PathnameUnrepresentable,
276                message: format!(
277                    "cli-git could not scan candidate {}: its pathname cannot be expressed as a native path on this platform.",
278                    candidate.identity.index
279                ),
280            });
281        };
282        let logical: &Path = path.as_path();
283        return Ok(self.scanner.scan(candidate.identity.index, logical, bytes));
284    }
285}
286
287/// Scanner controls stay out of the release executable.
288#[cfg(test)]
289#[path = "scanner_adapter_tests.rs"]
290mod tests;