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;