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