keyhog_scanner/engine/mod.rs
1//! Core scanning engine.
2//!
3//! # The one flow
4//!
5//! Every scan is the same pipeline. The ONLY thing that varies is *phase 1*
6//! (which detectors could fire where), produced on the CPU by Hyperscan or on
7//! the GPU by VYRE's fused literal-evidence backend. Everything downstream is
8//! shared:
9//!
10//! ```text
11//! files ─▶ phase 1: trigger production (swappable backend)
12//! ├─ CPU: compute_coalesced_triggers (Hyperscan prefilter) scan_coalesced.rs
13//! └─ GPU: scan_coalesced_gpu_region_presence (fused presence + positions) gpu_region_dispatch.rs
14//! │ one bitmap per chunk plus optional localization evidence
15//! ▼
16//! phase 2: scan_coalesced_phase2 (THE shared tail) scan_coalesced.rs
17//! • windowing (scan_windowed / triggered windows) windowed.rs
18//! • per-chunk extraction (scan_prepared_with_triggered) backend_triggered.rs
19//! confirmed → phase2 capture → generic → entropy → ML
20//! • post-process: suppression, dedup, confidence, decode/ML scan_postprocess.rs
21//! • cross-chunk boundary reassembly (scan_chunk_boundaries) boundary.rs
22//! ```
23//!
24//! There is exactly ONE production on-GPU literal producer: the fused resident
25//! dispatch in [`gpu_region_dispatch`]. Selecting an exact GPU backend
26//! (`--backend gpu-cuda` or `--backend gpu-wgpu`)
27//! routes the batch path through it. The no-backend library API is the portable
28//! CPU reference; the CLI passes its persisted fastest-correct route explicitly.
29//! A requested GPU path never turns failure into an empty successful result.
30//!
31//! # Where each method lives
32//!
33//! `CompiledScanner` construction and public lifecycle methods live under
34//! `compiled_scanner/`. Execution methods live here, split by responsibility.
35//! To find a method, look here first:
36//!
37//! - `scan` / `scan_with_backend` / `scan_with_deadline*` .... compiled_scanner/runtime.rs
38//! - `scan_inner` ................................................................................ scan.rs
39//! - `scan_coalesced` / `compute_coalesced_triggers` / `scan_coalesced_phase2` .................. scan_coalesced.rs
40//! - `scan_chunks_with_backend_internal_admission_and_route` (CPU-vs-GPU batch routing) .. backend_dispatch.rs
41//! - `scan_coalesced_gpu_region_presence` (GPU trigger production) ... gpu_region_dispatch.rs
42//! - GPU region reporting/throughput helpers ................. gpu_region_dispatch_helpers.rs
43//! - `scan_prepared_with_triggered` / `collect_triggered_patterns_*` . backend_triggered.rs
44//! - `scan_windowed*` (the windowing contract) .............. windowed.rs
45//! - confirmed-pattern extraction ................................... extract.rs
46//! - phase-2 prefilter + keyword/anchor/generic/entropy passes ...... phase2*.rs
47//! - hot-pattern fast path (simdsieve) ............................. hot_patterns.rs
48//! - match confidence policy ...................................... confidence::policy
49//! - post-process (suppression, dedup, confidence, decode/ML) ...... scan_postprocess.rs, scan_postprocess/*
50//! - cross-chunk seam reassembly ................................... boundary.rs
51//! - loud GPU-degrade / fail-closed helpers ....................... gpu_forced.rs
52//! - compile (build the scanner, acquire backends) .... compiled_scanner/compile.rs
53
54mod backend;
55mod backend_dispatch;
56mod backend_prepared;
57mod backend_triggered;
58mod boundary;
59pub(crate) use boundary::derive_pattern_boundary_context;
60#[cfg(feature = "gpu")]
61pub(crate) use boundary::regex_match_byte_upper_bound;
62#[cfg(test)]
63pub(crate) use boundary::scan_chunk_boundaries as scan_chunk_boundaries_for_test;
64mod csr;
65pub(crate) use csr::CsrU32;
66mod extract;
67pub(crate) use crate::gpu_matcher_cache as gpu_cache;
68#[cfg(all(test, feature = "gpu"))]
69pub(crate) use gpu_cache::gpu_matcher_cache_dir_from_base;
70mod gpu_forced;
71#[cfg(any(feature = "gpu", test))]
72mod gpu_forced_helpers;
73mod gpu_lazy;
74mod gpu_lazy_helpers;
75mod gpu_literal_scratch;
76#[cfg(feature = "gpu")]
77pub(crate) mod gpu_region_batch;
78#[cfg(feature = "gpu")]
79mod gpu_region_dispatch;
80#[cfg(feature = "gpu")]
81mod gpu_region_dispatch_helpers;
82#[cfg(feature = "gpu")]
83pub(crate) use crate::gpu::GpuResidentLiteralSlot;
84mod gpu_stack;
85mod hot_patterns;
86pub(crate) mod phase2;
87pub(crate) mod phase2_anchor;
88#[cfg(test)]
89pub(crate) use phase2_anchor::required_prefix_literals as phase2_required_prefix_literals_for_test;
90pub(crate) use phase2_anchor::Phase2AnchorIndex;
91// Always-on re-export (NOT cfg(test)) so `crate::testing`: which is compiled
92// even when the crate is linked as a dependency of the integration-test binary,
93// where `cfg(test)` is false for this crate, can classify confirmed patterns by
94// the SAME required-prefix predicate `ConfirmedAnchorIndex` uses (backlog 4786
95// localization-ceiling analysis).
96pub(crate) use phase2_anchor::{
97 required_prefix_literals_with_cap, CONFIRMED_MAX_LITERALS_PER_PATTERN,
98};
99mod phase1_admission;
100mod phase2_anchor_scan;
101mod phase2_compiled;
102mod phase2_compiled_anchored;
103pub(crate) mod phase2_entropy;
104#[path = "phase2/first_bigram.rs"]
105mod phase2_first_bigram;
106pub(crate) mod phase2_generic;
107#[cfg(feature = "gpu")]
108mod phase2_gpu_dfa;
109#[cfg(feature = "gpu")]
110pub(crate) use phase2_gpu_dfa::Phase2GpuDfaCatalogCache;
111#[cfg(feature = "simd")]
112mod phase2_hs;
113#[cfg(feature = "gpu")]
114pub(crate) use crate::gpu_input_budget;
115#[cfg(all(test, feature = "simd"))]
116pub(crate) use phase2_hs::hs_prefilter_requires_host_regex as hs_prefilter_requires_host_regex_for_test;
117#[cfg(all(test, feature = "simd"))]
118pub(crate) use phase2_hs::Phase2HsEngine;
119mod phase2_prefilter;
120pub(crate) use crate::phase2_truncate;
121mod process;
122pub(crate) use crate::scan_profile as profile;
123mod recovery;
124pub use recovery::{BackendRecoveryReceipt, CoalescedScanOutcome, RecoveredInputRange};
125mod scan;
126mod scan_coalesced;
127pub(crate) mod scan_filters;
128pub(crate) mod scan_inner_profile;
129pub(crate) mod scan_postprocess;
130pub(crate) use scan_postprocess::{
131 build_confirmed_suffix_gate, confirmed_anchor::ConfirmedAnchorIndex,
132};
133#[path = "scan_postprocess/confirmed_extract.rs"]
134mod scan_postprocess_confirmed_extract;
135#[path = "scan_postprocess/fragments.rs"]
136mod scan_postprocess_fragments;
137#[cfg(feature = "ml")]
138#[path = "scan_postprocess/ml.rs"]
139mod scan_postprocess_ml;
140#[cfg(all(test, feature = "ml"))]
141pub(crate) use scan_postprocess_ml::finalize_pending_match_for_test;
142#[path = "scan_postprocess/profile.rs"]
143mod scan_postprocess_profile;
144#[path = "scan_postprocess/suffix_gate.rs"]
145mod scan_postprocess_suffix_gate;
146pub(crate) mod trigger_bitmap;
147mod windowed;
148mod windowed_support;
149
150// The SIMD compile plan only exists under the `simd` (Hyperscan) feature; its
151// sole call site in `compiled_scanner/compile.rs` is `#[cfg(feature = "simd")]`
152// too. Gate the
153// import to match, or non-simd builds (the `portable` feature used for the
154// macOS/Windows/musl release assets) fail with E0432.
155pub(crate) use backend_prepared::code_lines_from_offsets;
156pub(crate) use backend_prepared::PreparedChunk;
157#[cfg(feature = "simd")]
158pub(crate) use backend_prepared::{
159 build_simd_compile_plan, SimdPhase1CompilePlan, SimdPhase1Prefilter,
160};
161#[cfg(test)]
162pub(crate) use boundary::scan_chunk_boundaries;
163#[cfg(test)]
164pub(crate) use gpu_forced_helpers::gpu_forced_unavailable_message;
165#[cfg(test)]
166pub(crate) use phase2::{phase2_gate_stats_dump, phase2_mark_stats, phase2_mark_stats_reset};
167#[cfg(test)]
168pub(crate) use scan_inner_profile::scan_inner_profile_dump;
169#[cfg(test)]
170pub(crate) use scan_postprocess::decode_profile_dump;
171pub(crate) use scan_postprocess_suffix_gate::suffix_gate_literals;
172pub(crate) use windowed::{reject_oversized_window_chunk, MAX_WINDOW_CHUNK_BYTES};
173pub(crate) use windowed_support::{absolute_line, absolute_offset, ceil_char_boundary};
174pub use windowed_support::{
175 floor_char_boundary, line_number_for_offset, next_window_offset, record_window_match,
176 window_chunk, window_end_offset, window_ranges,
177};
178
179use crate::compiled_scanner::{GpuBackendAcquisitionFailure, GpuBackendPeers};
180use crate::pipeline::*;
181use crate::types::*;
182use aho_corasick::AhoCorasick;
183use keyhog_core::{Chunk, RawMatch};
184use std::sync::Arc;
185use std::sync::OnceLock;
186
187/// Per-pattern hard iteration cap shared by every inner match-walk loop in the
188/// engine (`extract.rs`'s confirmed/anchored extractors and
189/// `phase2_anchor_scan.rs`'s anchored phase-2 walk).
190///
191/// The deadline path (`LoopDeadline` + `loop_expired_on_cadence`) is the
192/// operator's wall-clock defense; this cap is the per-pattern budget that fires
193/// even when `--timeout` is unset (`deadline == None`). Without it a single
194/// regex matching every byte on a 64 MiB chunk (false-prefix storm, catastrophic
195/// backtracking) would loop ~64M times. 1M iterations per pattern is ~6 orders of
196/// magnitude above any legitimate detector's per-chunk match count, so a real
197/// scan never reaches it. Defined once here so the three walk sites can never
198/// drift apart (each used to carry its own byte-identical copy).
199pub(crate) const MAX_INNER_LOOP_ITERS: usize = 1_000_000;
200
201/// Minimum chunk length (bytes) at or above which the bigram-bloom prefilter is
202/// consulted to skip a chunk. Below this length the bloom is bypassed and the
203/// chunk always advances to scanning: short chunks are too cheap to scan for the
204/// prefilter to earn its keep, and dropping one on a bloom miss risks a
205/// false-negative for negligible speed gain.
206///
207/// Defined once here so the two admission sites that gate on it, the coalesced
208/// phase-1 producer ([`scan_coalesced`]) and the single-chunk entry
209/// ([`crate::compiled_scanner`]), can never carry divergent copies of the
210/// threshold (each
211/// used to hardcode a bare `64`).
212pub(crate) const BIGRAM_BLOOM_MIN_CHUNK_BYTES: usize = 64;
213
214pub(crate) use phase1_admission::{Phase1Admission, Phase1AdmissionPlanIdentityError};
215pub use phase1_admission::{
216 Phase1AdmissionPlan, Phase1AdmissionSummary, Phase2KeywordTriggerSummary,
217};
218
219pub struct CompiledScanner {
220 /// Versioned projection of the canonical validated scan-execution hash.
221 /// Autoroute and runtime receipts consume this stored identity so every
222 /// execution-affecting detector policy change invalidates stale evidence.
223 pub(crate) detector_digest: u64,
224 pub(crate) fragment_cache: crate::fragment_cache::FragmentCache,
225 pub(crate) ac: Option<AhoCorasick>,
226 pub(crate) gpu_backends: GpuBackendPeers,
227 pub(crate) gpu_acquisition_failures: Vec<GpuBackendAcquisitionFailure>,
228 pub(crate) gpu_literals: Option<Arc<Vec<Vec<u8>>>>,
229 #[cfg(feature = "gpu")]
230 pub(crate) gpu_max_literal_len: usize,
231 pub(crate) gpu_matcher: OnceLock<Option<vyre_libs::scan::GpuLiteralSet>>,
232 #[cfg(feature = "gpu")]
233 pub(crate) gpu_resident_literal_cuda: std::sync::Mutex<GpuResidentLiteralSlot>,
234 #[cfg(feature = "gpu")]
235 pub(crate) gpu_resident_literal_metal: std::sync::Mutex<GpuResidentLiteralSlot>,
236 #[cfg(feature = "gpu")]
237 pub(crate) gpu_resident_literal_wgpu: std::sync::Mutex<GpuResidentLiteralSlot>,
238 pub(crate) gpu_last_degrade_reason: std::sync::Mutex<Option<String>>,
239 pub(crate) gpu_degrade_count: std::sync::atomic::AtomicU64,
240 /// One-time backend-neutral GPU literal-program preparation measured by
241 /// the canonical autoroute sweep. The sweep reuses that immutable program
242 /// but adds this cost to every GPU one-shot observation.
243 pub(crate) autoroute_gpu_shared_cold_ns: std::sync::atomic::AtomicU64,
244 pub(crate) static_intern: Arc<crate::static_intern::StaticInterner>,
245 /// One detector-indexed runtime owner for interned identity, execution,
246 /// entropy, key material, suppression, shape, companion, weak-anchor, and
247 /// ML policy compiled from the detector TOMLs. Global matchers still span
248 /// detectors, but candidate execution reaches detector-local behavior only
249 /// through this plan.
250 pub(crate) detector_plans: crate::detector_plan::CompiledDetectorPlans,
251 /// Lazily compiled union of Tier-A and detector-owned generic assignment
252 /// keywords, shared by entropy and multiline admission. The cache is keyed
253 /// by exact lists because `config` remains publicly mutable.
254 pub(crate) assignment_keyword_matcher:
255 std::sync::Mutex<crate::assignment_keyword_matcher::AssignmentKeywordMatcherCache>,
256 /// Per-`ac_map` regex byte upper bound for GPU hit-local validation. `None`
257 /// means the detector regex is unbounded or unparsable by the AST bounder,
258 /// so GPU validation must keep the full prepared-chunk oracle.
259 #[cfg(feature = "gpu")]
260 pub(crate) ac_match_upper_bounds: Vec<Option<usize>>,
261 pub(crate) ac_map: Vec<CompiledPattern>,
262 /// Confirmed pattern indices whose exact capture proves a structural password
263 /// slot, partitioned by detector for bounded generic-bridge lookup.
264 pub(crate) structural_confirmed_patterns: Vec<Vec<usize>>,
265 pub(crate) pattern_boundary_context: boundary::BoundaryContextBytes,
266 /// Confirmed-pass suffix gate: AC over ac_map patterns' required suffix
267 /// literals (every match ends with one). `ac_suffix_gate[i]` are pattern
268 /// i's literal ids; a triggered pattern whose suffix literals are all absent
269 /// from the chunk cannot match and is skipped (see `extract_confirmed_patterns`).
270 pub(crate) suffix_gate_ac: Option<AhoCorasick>,
271 pub(crate) ac_suffix_gate: Vec<Vec<u32>>,
272 /// Per-`ac_map` bit for confirmed regexes whose detector-owned
273 /// `simdsieve_prefixes` can already emit the same candidate directly.
274 pub(crate) hot_confirmed_by_pattern: Vec<bool>,
275 /// Shared-anchor localization index over the confirmed `ac_map`. Eligible
276 /// triggered patterns are verified at required-prefix candidate positions
277 /// instead of each walking the whole scan window; non-eligible patterns keep
278 /// the whole-chunk path.
279 pub(crate) confirmed_anchor_index:
280 Option<scan_postprocess::confirmed_anchor::ConfirmedAnchorIndex>,
281 pub(crate) prefix_propagation: CsrU32,
282 pub(crate) phase2_patterns: Vec<(CompiledPattern, Vec<String>)>,
283 /// Phase-2 pattern indices whose exact capture proves a structural password
284 /// slot, partitioned by detector for bounded generic-bridge lookup.
285 pub(crate) structural_phase2_patterns: Vec<Vec<usize>>,
286 pub(crate) same_prefix_patterns: CsrU32,
287 pub(crate) phase2_keyword_ac: Option<AhoCorasick>,
288 pub(crate) phase2_keyword_to_patterns: CsrU32,
289 pub(crate) phase2_keyword_count: usize,
290 /// GPU region-presence literal rows appended after detector literals and
291 /// phase-2 keyword rows. These are the literals backing the always-active
292 /// phase-2 anchor AC; presence proves admission and positioned receipts
293 /// replace the host AC walk when the selected route needs only this segment.
294 pub(crate) phase2_always_anchor_literal_count: usize,
295 /// Confirmed shared-anchor rows appended to the fused GPU literal matcher.
296 /// Their positioned matches replace the CPU anchor-index text walk.
297 #[cfg(feature = "gpu")]
298 pub(crate) confirmed_anchor_literal_count: usize,
299 /// Generic assignment prefilter stems appended after confirmed anchors in
300 /// the fused GPU matcher. Their positions replace the CPU stem text walk.
301 #[cfg(feature = "gpu")]
302 pub(crate) generic_keyword_literal_count: usize,
303 pub(crate) phase2_always_active_indices: Vec<usize>,
304 /// Always-active prefilter with full, anchor-residual, and
305 /// anchor-plus-plain-residual scopes. Each scope has lazy Hyperscan and
306 /// portable engines so extraction never scans a pattern already owned by
307 /// an active localizer.
308 pub(crate) phase2_always_active_prefilter: Option<phase2::Phase2AlwaysActivePrefilter>,
309 /// Shared-anchor localization index over the phase-2 set. When present,
310 /// eligible phase-2 patterns are verified anchored at candidate positions
311 /// from one shared Aho-Corasick pass instead of each walking the whole
312 /// chunk; non-eligible patterns keep the whole-chunk path. `None` when no
313 /// pattern is anchor-eligible. Recall-identical (see `phase2_anchor`).
314 pub(crate) phase2_anchor_index: Option<phase2_anchor::Phase2AnchorIndex>,
315 /// Backend-shaped GPU regex-DFA admission catalogs for prefixless
316 /// always-active phase-2 patterns. Used only by the coalesced GPU route: a
317 /// hit admits the chunk to the shared phase-2 tail, while misses/errors
318 /// continue through CPU admission so uncovered patterns cannot be silently
319 /// skipped.
320 #[cfg(feature = "gpu")]
321 pub(crate) phase2_gpu_dfa: phase2_gpu_dfa::Phase2GpuDfaCatalogCache,
322 /// Per-scanner performance route tuning (HS vs RegexSet, anchor
323 /// localization, prefilter truncation, decode focus, confirmed-suffix gate,
324 /// …). Resolved from compiled defaults plus explicit per-scanner config;
325 /// differential parity tests override one route on THIS scanner via
326 /// [`CompiledScanner::tuning`] without touching any global state. See
327 /// [`phase2::ScannerTuning`].
328 pub(crate) tuning: phase2::ScannerTuning,
329 #[cfg(feature = "simd")]
330 pub(crate) simd_candidate_available: bool,
331 #[cfg(feature = "simd")]
332 pub(crate) simd_compile_plan: std::sync::Mutex<Option<SimdPhase1CompilePlan>>,
333 #[cfg(feature = "simd")]
334 pub(crate) simd_prefilter: OnceLock<std::result::Result<SimdPhase1Prefilter, String>>,
335 #[cfg(feature = "simd")]
336 pub(crate) simd_initialization_ns: std::sync::atomic::AtomicU64,
337 /// Resolved detector-owned hot-pattern slots. Each row bundles the prefix, precise
338 /// validator AND its canonical `ac_map` delegate together, so a slot's
339 /// validation target and emission target can never be indexed apart and so
340 /// can never drift, they were two parallel `Vec`s read by the same
341 /// `pattern_idx` before, an unauditable coupling. The hot fast-path runs each
342 /// literal-prefix candidate through `slot.validator` before emitting (so it
343 /// can never surface a token the detector's own regex rejects, the length
344 /// floor alone let `ghp_…_…`/`xoxp-123-456-789-abc` through) and delegates
345 /// the survivor to `ac_map[slot.ac_map_index]` via `process_match`. A slot's
346 /// Built once by `compiled_scanner::compile_helpers::build_hot_pattern_slots`.
347 #[cfg(feature = "simdsieve")]
348 pub(crate) hot_pattern_slots: Vec<crate::simdsieve_prefilter::HotPatternSlot>,
349 /// Detector-indexed entropy identities declared by the active TOML corpus.
350 /// This keeps every active generic owner on its own identity without a
351 /// scanner-global class table or detector-ID branch. A missing entry is a
352 /// compile-time corpus error and is never replaced with a guessed label.
353 pub config: ScannerConfig,
354 pub(crate) alphabet_screen: Option<crate::alphabet_filter::AlphabetScreen>,
355 pub(crate) bigram_bloom: crate::bigram_bloom::BigramBloom,
356}
357
358const _: () = {
359 const fn assert_send_sync<T: Send + Sync>() {}
360 let _ = assert_send_sync::<CompiledScanner>; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
361};
362
363#[cfg(test)]
364mod max_inner_loop_iters_tests {
365 use super::MAX_INNER_LOOP_ITERS;
366 use crate::deadline::HOT_LOOP_DEADLINE_CADENCE;
367
368 /// The canonical per-pattern hard cap is exactly the value the three engine
369 /// walk sites (`extract.rs` ×2, `phase2_anchor_scan.rs`) used to each hardcode.
370 /// If this drifts, an adversarial chunk's per-pattern iteration budget changes
371 /// silently for every walk at once (pin the concrete value).
372 #[test]
373 fn canonical_cap_is_one_million() {
374 assert_eq!(MAX_INNER_LOOP_ITERS, 1_000_000);
375 }
376
377 /// The wall-clock deadline is re-checked once every `HOT_LOOP_DEADLINE_CADENCE`
378 /// iterations, so a walk that runs to the hard cap performs exactly
379 /// `MAX_INNER_LOOP_ITERS / HOT_LOOP_DEADLINE_CADENCE` deadline checks. The cap
380 /// must be an exact whole multiple of the cadence (last check lands on the cap)
381 /// and yield the concrete 15625 checks, proving the deadline path can still
382 /// abort well before the hard cap is reached.
383 #[test]
384 fn cap_is_whole_multiple_of_deadline_cadence() {
385 assert_eq!(HOT_LOOP_DEADLINE_CADENCE, 64);
386 assert_eq!(MAX_INNER_LOOP_ITERS % HOT_LOOP_DEADLINE_CADENCE, 0);
387 assert_eq!(MAX_INNER_LOOP_ITERS / HOT_LOOP_DEADLINE_CADENCE, 15_625);
388 }
389
390 /// The bigram-bloom admission threshold shared by the coalesced producer and
391 /// the single-chunk entry is exactly the bare `64` those two sites used to
392 /// hardcode. Pin the concrete value: if it drifts, both admission gates
393 /// change their short-chunk skip boundary at once and a silent recall shift
394 /// would be invisible without this lock.
395 #[test]
396 fn bigram_bloom_min_chunk_bytes_is_sixty_four() {
397 assert_eq!(super::BIGRAM_BLOOM_MIN_CHUNK_BYTES, 64);
398 }
399
400 /// The unbounded/entropy cross-seam reassembly cap replaced a `usize::MAX`
401 /// full-chunk splice (O(pairs x chunk_bytes) rescan). It is pinned to the
402 /// FilesystemSource window overlap so the seam covers exactly the straddle
403 /// range the overlap design assumes catchable; drifting it silently changes
404 /// boundary recall AND the per-pair reassembly cost.
405 #[test]
406 fn boundary_seam_cap_matches_window_overlap() {
407 assert_eq!(
408 super::boundary::MAX_BOUNDARY_SEAM_BYTES,
409 crate::types::WINDOW_OVERLAP_BYTES
410 );
411 assert_eq!(super::boundary::MAX_BOUNDARY_SEAM_BYTES, 128 * 1024);
412 }
413}