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
use super::*;
use crate::hw_probe::ScanBackend;
use keyhog_core::Chunk;
impl CompiledScanner {
pub(crate) fn scan_chunks_with_backend_internal_admission_and_route(
&self,
chunks: &[Chunk],
backend: ScanBackend,
admission_plan: Option<&Phase1AdmissionPlan>,
route: crate::ScanExecutionRoute,
) -> crate::error::Result<Vec<Vec<RawMatch>>> {
if chunks.iter().all(|chunk| chunk.data.is_empty()) {
for _ in chunks {
crate::telemetry::record_file_scanned(0);
}
return Ok(vec![Vec::new(); chunks.len()]);
}
// Non-GPU backends (and empty batches) run the parallel CPU path. rayon's
// global pool is configured by the CLI orchestrator (--threads /
// [scan].threads / physical cores); Hyperscan + AC scans are CPU-bound
// and independent per-chunk, so par_iter() saturates cores. The
// `scan_chunk_boundaries` pass reassembles secrets straddling the seam
// between adjacent gapless chunks of the same file (a per-chunk scan sees
// each half too short to match) (load-bearing recall, not optional).
let gpu_path = backend.is_gpu();
if !gpu_path || chunks.is_empty() {
return self.scan_chunks_cpu_parallel(chunks, backend, admission_plan, route);
}
// The batched region-presence literal set is the SINGLE on-GPU trigger
// producer. Dispatch failures remain structured and never substitute
// CPU/SIMD for the selected route.
#[cfg(feature = "gpu")]
{
self.scan_coalesced_gpu_region_presence(chunks, backend, route)
.map_err(|error| {
self.record_gpu_runtime_fault(error.reason());
crate::error::ScanError::Gpu(error.to_string())
})
}
#[cfg(not(feature = "gpu"))]
{
let _ = (chunks, admission_plan, route); // LAW10: parameters are consumed only to keep the unsupported cfg branch warning-free before returning its structured error.
Err(crate::error::ScanError::Gpu(format!(
"{} selected but this scanner build has no GPU support",
backend.label()
)))
}
}
/// Parallel per-chunk CPU scan + cross-chunk boundary reassembly. The single
/// owner of this path: it is taken only for non-GPU routes. GPU routes never
/// enter it; a GPU route compiled without GPU support returns a structured
/// [`crate::error::ScanError::Gpu`] from the caller instead of substituting a
/// CPU scan or taking process-exit ownership.
fn scan_chunks_cpu_parallel(
&self,
chunks: &[Chunk],
backend: ScanBackend,
admission_plan: Option<&Phase1AdmissionPlan>,
route: crate::ScanExecutionRoute,
) -> crate::error::Result<Vec<Vec<RawMatch>>> {
use rayon::prelude::*;
let telemetry = crate::telemetry::capture_scan_telemetry();
let recovery_receipts = crate::gpu::capture_recovery_receipts();
let mut results: Vec<Vec<RawMatch>> = chunks
.par_iter()
.enumerate()
.map(|(index, chunk)| {
crate::gpu::with_captured_recovery_receipts(recovery_receipts.as_ref(), || {
crate::telemetry::with_captured_scan_telemetry(telemetry.as_ref(), || {
let admission = admission_plan.and_then(|plan| plan.admission_for(index));
self.scan_with_deadline_and_backend_admission_and_route(
chunk,
self.config.per_chunk_deadline(),
backend,
admission,
route,
)
})
})
})
.collect::<crate::error::Result<Vec<_>>>()?;
super::boundary::scan_chunk_boundaries_with_route(self, chunks, &mut results, route)?;
Ok(results)
}
pub(crate) fn prepare_chunk<'a>(&self, chunk: &'a Chunk) -> PreparedChunk<'a> {
let _g = super::profile::span(super::profile::P::Preprocess);
// Note: non-ASCII normalization used to swap `chunk` to an
// owned `Chunk` via `normalize_scannable_chunk`. That path
// is rarely-hit (most source code is pure ASCII) and the
// returned Chunk was immediately consumed via clone into the
// owned PreparedChunk anyway, so the borrow design works:
// for non-ASCII inputs we still feed the normalization
// through `unicode_hardening::normalize_homoglyphs` Cow
// below, which lands the normalized text in
// `preprocessed.text`. The raw `chunk.data` borrow remains
// intact for the few downstream consumers that read it
// (extract_confirmed_patterns uses preprocessed.text by
// default; raw `chunk.data` only via the drift fallback).
// Homoglyph normalization: zero-allocation Cow fast path. Pure-ASCII
// and evasion-free inputs (the 99% case) borrow `chunk.data` directly.
// Only inputs containing actual homoglyphs/zero-width/RTL allocate.
//
// The Cow MUST borrow `chunk.data` (lifetime `'a`) on the no-op path,
// not a local, so the borrowed passthrough text below can outlive this
// call inside `PreparedChunk<'a>`. We therefore chain the two
// normalization stages explicitly: a stage that rewrites bytes yields
// `Cow::Owned`; a no-op stage preserves the `&'a chunk.data` borrow.
let data_to_pp: std::borrow::Cow<'a, str> = if self.config.unicode_normalization {
match crate::unicode_hardening::normalize_homoglyphs(&chunk.data) {
// Homoglyph stage rewrote the bytes: the owned String is the
// canonical text. The interior-control strip then operates on
// that owned buffer; either outcome stays owned.
std::borrow::Cow::Owned(normalized) => {
match crate::unicode_hardening::strip_interior_evasion_controls(&normalized) {
std::borrow::Cow::Owned(stripped) => std::borrow::Cow::Owned(stripped),
std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Owned(normalized),
}
}
// Homoglyph stage was a no-op: bytes are still `chunk.data`.
// Run the interior-control strip against `chunk.data` itself so
// a no-op there preserves the `'a` borrow on the chunk.
std::borrow::Cow::Borrowed(_) => {
crate::unicode_hardening::strip_interior_evasion_controls(&chunk.data)
}
}
} else {
std::borrow::Cow::Borrowed(&chunk.data)
};
// For the structured / multiline-join paths the preprocessed text is
// freshly synthesized (owned regardless of `data_to_pp`), so they read
// it through a plain `&str`. The passthrough path, by contrast, is
// byte-identical to `data_to_pp` and carries the Cow through unchanged
// so a borrowed chunk stays borrowed (no full-body copy).
// A chunk the decode-through pipeline produced carries `decoded_span`;
// on such a derived buffer a structured-format parse failure is expected
// and loses nothing (the encoded surface was already decoded + scanned),
// so it must not be counted/announced as a lost decode surface.
let decode_derived = chunk.metadata.decoded_span.is_some();
let preprocessed = if let Some(pp) = crate::structured::preprocess(
&data_to_pp,
chunk.metadata.path.as_deref(),
decode_derived,
) {
pp
} else {
#[cfg(feature = "multiline")]
{
let has_multiline_candidate =
crate::multiline::config::has_concatenation_indicators_with_keyword_gate(
&data_to_pp,
|bytes| {
let matcher = self
.assignment_keyword_matcher
.lock()
// LAW10: recall-preserving; Mutex poison does not invalidate the matcher cache value, so resolution continues with the complete cached matcher.
.unwrap_or_else(|poisoned| poisoned.into_inner())
.resolve(
&self.config.secret_keywords,
self.detector_plans.generic_ownership().policy_keywords(),
);
matcher.matches(bytes)
},
);
if has_multiline_candidate {
crate::multiline::preprocess_multiline_admitted(
data_to_pp,
&self.config.multiline,
&self.fragment_cache,
)
} else {
ScannerPreprocessedText::passthrough(data_to_pp)
}
}
#[cfg(not(feature = "multiline"))]
ScannerPreprocessedText::passthrough(data_to_pp)
};
PreparedChunk {
chunk,
preprocessed,
line_offsets: std::sync::OnceLock::new(),
}
}
}