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;