Skip to main content

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` (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;
71pub(crate) use gpu_forced::require_selected_gpu_stack;
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")]
83mod gpu_resident_evidence;
84#[cfg(feature = "gpu")]
85pub(crate) use gpu_resident_evidence::GpuResidentLiteralSlot;
86mod gpu_stack;
87mod hot_patterns;
88pub(crate) mod phase2;
89pub(crate) mod phase2_anchor;
90#[cfg(test)]
91pub(crate) use phase2_anchor::required_prefix_literals as phase2_required_prefix_literals_for_test;
92pub(crate) use phase2_anchor::Phase2AnchorIndex;
93// Always-on re-export (NOT cfg(test)) so `crate::testing`: which is compiled
94// even when the crate is linked as a dependency of the integration-test binary,
95// where `cfg(test)` is false for this crate, can classify confirmed patterns by
96// the SAME required-prefix predicate `ConfirmedAnchorIndex` uses (backlog 4786
97// localization-ceiling analysis).
98pub(crate) use phase2_anchor::{
99    required_prefix_literals_with_cap, CONFIRMED_MAX_LITERALS_PER_PATTERN,
100};
101mod phase1_admission;
102mod phase2_anchor_scan;
103mod phase2_compiled;
104mod phase2_compiled_anchored;
105pub(crate) mod phase2_entropy;
106#[path = "phase2/first_bigram.rs"]
107mod phase2_first_bigram;
108pub(crate) mod phase2_generic;
109#[cfg(feature = "gpu")]
110mod phase2_gpu_dfa;
111#[cfg(feature = "gpu")]
112pub(crate) use phase2_gpu_dfa::Phase2GpuDfaCatalogCache;
113#[cfg(feature = "simd")]
114mod phase2_hs;
115#[cfg(feature = "gpu")]
116pub(crate) use crate::gpu_input_budget;
117#[cfg(all(test, feature = "simd"))]
118pub(crate) use phase2_hs::hs_prefilter_requires_host_regex as hs_prefilter_requires_host_regex_for_test;
119#[cfg(all(test, feature = "simd"))]
120pub(crate) use phase2_hs::Phase2HsEngine;
121mod phase2_prefilter;
122pub(crate) use crate::phase2_truncate;
123mod process;
124pub(crate) use crate::scan_profile as profile;
125mod recovery;
126pub use recovery::{BackendRecoveryReceipt, CoalescedScanOutcome, RecoveredInputRange};
127mod scan;
128mod scan_coalesced;
129pub(crate) mod scan_filters;
130pub(crate) mod scan_inner_profile;
131pub(crate) mod scan_postprocess;
132pub(crate) use scan_postprocess::{
133    build_confirmed_suffix_gate, confirmed_anchor::ConfirmedAnchorIndex,
134};
135#[path = "scan_postprocess/confirmed_extract.rs"]
136mod scan_postprocess_confirmed_extract;
137#[path = "scan_postprocess/fragments.rs"]
138mod scan_postprocess_fragments;
139#[cfg(feature = "ml")]
140#[path = "scan_postprocess/ml.rs"]
141mod scan_postprocess_ml;
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;
215pub use phase1_admission::{Phase1AdmissionPlan, Phase1AdmissionSummary};
216
217pub struct CompiledScanner {
218    /// Versioned projection of the canonical validated scan-execution hash.
219    /// Autoroute and runtime receipts consume this stored identity so every
220    /// execution-affecting detector policy change invalidates stale evidence.
221    pub(crate) detector_digest: u64,
222    pub(crate) fragment_cache: crate::fragment_cache::FragmentCache,
223    pub(crate) ac: Option<AhoCorasick>,
224    pub(crate) gpu_backends: GpuBackendPeers,
225    pub(crate) gpu_acquisition_failures: Vec<GpuBackendAcquisitionFailure>,
226    pub(crate) gpu_literals: Option<Arc<Vec<Vec<u8>>>>,
227    #[cfg(feature = "gpu")]
228    pub(crate) gpu_max_literal_len: usize,
229    pub(crate) gpu_matcher: OnceLock<Option<vyre_libs::scan::GpuLiteralSet>>,
230    #[cfg(feature = "gpu")]
231    pub(crate) gpu_resident_literal_cuda:
232        std::sync::Mutex<gpu_resident_evidence::GpuResidentLiteralSlot>,
233    #[cfg(feature = "gpu")]
234    pub(crate) gpu_resident_literal_wgpu:
235        std::sync::Mutex<gpu_resident_evidence::GpuResidentLiteralSlot>,
236    pub(crate) gpu_last_degrade_reason: std::sync::Mutex<Option<String>>,
237    pub(crate) gpu_degrade_count: std::sync::atomic::AtomicU64,
238    /// One-time backend-neutral GPU literal-program preparation measured by
239    /// the canonical autoroute sweep. The sweep reuses that immutable program
240    /// but adds this cost to every GPU one-shot observation.
241    pub(crate) autoroute_gpu_shared_cold_ns: std::sync::atomic::AtomicU64,
242    pub(crate) static_intern: Arc<crate::static_intern::StaticInterner>,
243    /// One detector-indexed runtime owner for interned identity, execution,
244    /// entropy, key material, suppression, shape, companion, weak-anchor, and
245    /// ML policy compiled from the detector TOMLs. Global matchers still span
246    /// detectors, but candidate execution reaches detector-local behavior only
247    /// through this plan.
248    pub(crate) detector_plans: crate::detector_plan::CompiledDetectorPlans,
249    /// Lazily compiled union of Tier-A and detector-owned generic assignment
250    /// keywords, shared by entropy and multiline admission. The cache is keyed
251    /// by exact lists because `config` remains publicly mutable.
252    pub(crate) assignment_keyword_matcher:
253        std::sync::Mutex<crate::assignment_keyword_matcher::AssignmentKeywordMatcherCache>,
254    /// Per-`ac_map` regex byte upper bound for GPU hit-local validation. `None`
255    /// means the detector regex is unbounded or unparsable by the AST bounder,
256    /// so GPU validation must keep the full prepared-chunk oracle.
257    #[cfg(feature = "gpu")]
258    pub(crate) ac_match_upper_bounds: Vec<Option<usize>>,
259    pub(crate) ac_map: Vec<CompiledPattern>,
260    /// Confirmed pattern indices whose exact capture proves a structural password
261    /// slot, partitioned by detector for bounded generic-bridge lookup.
262    pub(crate) structural_confirmed_patterns: Vec<Vec<usize>>,
263    pub(crate) pattern_boundary_context: boundary::BoundaryContextBytes,
264    /// Confirmed-pass suffix gate: AC over ac_map patterns' required suffix
265    /// literals (every match ends with one). `ac_suffix_gate[i]` are pattern
266    /// i's literal ids; a triggered pattern whose suffix literals are all absent
267    /// from the chunk cannot match and is skipped (see `extract_confirmed_patterns`).
268    pub(crate) suffix_gate_ac: Option<AhoCorasick>,
269    pub(crate) ac_suffix_gate: Vec<Vec<u32>>,
270    /// Per-`ac_map` bit for confirmed regexes whose detector-owned
271    /// `simdsieve_prefixes` can already emit the same candidate directly.
272    pub(crate) hot_confirmed_by_pattern: Vec<bool>,
273    /// Shared-anchor localization index over the confirmed `ac_map`. Eligible
274    /// triggered patterns are verified at required-prefix candidate positions
275    /// instead of each walking the whole scan window; non-eligible patterns keep
276    /// the whole-chunk path.
277    pub(crate) confirmed_anchor_index:
278        Option<scan_postprocess::confirmed_anchor::ConfirmedAnchorIndex>,
279    pub(crate) prefix_propagation: CsrU32,
280    pub(crate) phase2_patterns: Vec<(CompiledPattern, Vec<String>)>,
281    /// Phase-2 pattern indices whose exact capture proves a structural password
282    /// slot, partitioned by detector for bounded generic-bridge lookup.
283    pub(crate) structural_phase2_patterns: Vec<Vec<usize>>,
284    pub(crate) same_prefix_patterns: CsrU32,
285    pub(crate) phase2_keyword_ac: Option<AhoCorasick>,
286    pub(crate) phase2_keyword_to_patterns: CsrU32,
287    pub(crate) phase2_keyword_count: usize,
288    /// GPU region-presence literal rows appended after detector literals and
289    /// phase-2 keyword rows. These are the literals backing the always-active
290    /// phase-2 anchor AC; presence proves admission and positioned receipts
291    /// replace the host AC walk when the selected route needs only this segment.
292    pub(crate) phase2_always_anchor_literal_count: usize,
293    /// Confirmed shared-anchor rows appended to the fused GPU literal matcher.
294    /// Their positioned matches replace the CPU anchor-index text walk.
295    #[cfg(feature = "gpu")]
296    pub(crate) confirmed_anchor_literal_count: usize,
297    /// Generic assignment prefilter stems appended after confirmed anchors in
298    /// the fused GPU matcher. Their positions replace the CPU stem text walk.
299    #[cfg(feature = "gpu")]
300    pub(crate) generic_keyword_literal_count: usize,
301    pub(crate) phase2_always_active_indices: Vec<usize>,
302    /// Always-active prefilter with full, anchor-residual, and
303    /// anchor-plus-plain-residual scopes. Each scope has lazy Hyperscan and
304    /// portable engines so extraction never scans a pattern already owned by
305    /// an active localizer.
306    pub(crate) phase2_always_active_prefilter: Option<phase2::Phase2AlwaysActivePrefilter>,
307    /// Shared-anchor localization index over the phase-2 set. When present,
308    /// eligible phase-2 patterns are verified anchored at candidate positions
309    /// from one shared Aho-Corasick pass instead of each walking the whole
310    /// chunk; non-eligible patterns keep the whole-chunk path. `None` when no
311    /// pattern is anchor-eligible. Recall-identical (see `phase2_anchor`).
312    pub(crate) phase2_anchor_index: Option<phase2_anchor::Phase2AnchorIndex>,
313    /// Backend-shaped GPU regex-DFA admission catalogs for prefixless
314    /// always-active phase-2 patterns. Used only by the coalesced GPU route: a
315    /// hit admits the chunk to the shared phase-2 tail, while misses/errors
316    /// continue through CPU admission so uncovered patterns cannot be silently
317    /// skipped.
318    #[cfg(feature = "gpu")]
319    pub(crate) phase2_gpu_dfa: phase2_gpu_dfa::Phase2GpuDfaCatalogCache,
320    /// Per-scanner performance route tuning (HS vs RegexSet, anchor
321    /// localization, prefilter truncation, decode focus, confirmed-suffix gate,
322    /// …). Resolved from compiled defaults plus explicit per-scanner config;
323    /// differential parity tests override one route on THIS scanner via
324    /// [`CompiledScanner::tuning`] without touching any global state. See
325    /// [`phase2::ScannerTuning`].
326    pub(crate) tuning: phase2::ScannerTuning,
327    #[cfg(feature = "simd")]
328    pub(crate) simd_candidate_available: bool,
329    #[cfg(feature = "simd")]
330    pub(crate) simd_compile_plan: std::sync::Mutex<Option<SimdPhase1CompilePlan>>,
331    #[cfg(feature = "simd")]
332    pub(crate) simd_prefilter: OnceLock<std::result::Result<SimdPhase1Prefilter, String>>,
333    #[cfg(feature = "simd")]
334    pub(crate) simd_initialization_ns: std::sync::atomic::AtomicU64,
335    /// Resolved detector-owned hot-pattern slots. Each row bundles the prefix, precise
336    /// validator AND its canonical `ac_map` delegate together, so a slot's
337    /// validation target and emission target can never be indexed apart and so
338    /// can never drift, they were two parallel `Vec`s read by the same
339    /// `pattern_idx` before, an unauditable coupling. The hot fast-path runs each
340    /// literal-prefix candidate through `slot.validator` before emitting (so it
341    /// can never surface a token the detector's own regex rejects, the length
342    /// floor alone let `ghp_…_…`/`xoxp-123-456-789-abc` through) and delegates
343    /// the survivor to `ac_map[slot.ac_map_index]` via `process_match`. A slot's
344    /// Built once by `compiled_scanner::compile_helpers::build_hot_pattern_slots`.
345    #[cfg(feature = "simdsieve")]
346    pub(crate) hot_pattern_slots: Vec<crate::simdsieve_prefilter::HotPatternSlot>,
347    /// Detector-indexed entropy identities declared by the active TOML corpus.
348    /// This keeps every active generic owner on its own identity without a
349    /// scanner-global class table or detector-ID branch. A missing entry is a
350    /// compile-time corpus error and is never replaced with a guessed label.
351    pub config: ScannerConfig,
352    pub(crate) alphabet_screen: Option<crate::alphabet_filter::AlphabetScreen>,
353    pub(crate) bigram_bloom: crate::bigram_bloom::BigramBloom,
354}
355
356const _: () = {
357    const fn assert_send_sync<T: Send + Sync>() {}
358    let _ = assert_send_sync::<CompiledScanner>; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
359};
360
361#[cfg(test)]
362mod max_inner_loop_iters_tests {
363    use super::MAX_INNER_LOOP_ITERS;
364    use crate::deadline::HOT_LOOP_DEADLINE_CADENCE;
365
366    /// The canonical per-pattern hard cap is exactly the value the three engine
367    /// walk sites (`extract.rs` ×2, `phase2_anchor_scan.rs`) used to each hardcode.
368    /// If this drifts, an adversarial chunk's per-pattern iteration budget changes
369    /// silently for every walk at once (pin the concrete value).
370    #[test]
371    fn canonical_cap_is_one_million() {
372        assert_eq!(MAX_INNER_LOOP_ITERS, 1_000_000);
373    }
374
375    /// The wall-clock deadline is re-checked once every `HOT_LOOP_DEADLINE_CADENCE`
376    /// iterations, so a walk that runs to the hard cap performs exactly
377    /// `MAX_INNER_LOOP_ITERS / HOT_LOOP_DEADLINE_CADENCE` deadline checks. The cap
378    /// must be an exact whole multiple of the cadence (last check lands on the cap)
379    /// and yield the concrete 15625 checks, proving the deadline path can still
380    /// abort well before the hard cap is reached.
381    #[test]
382    fn cap_is_whole_multiple_of_deadline_cadence() {
383        assert_eq!(HOT_LOOP_DEADLINE_CADENCE, 64);
384        assert_eq!(MAX_INNER_LOOP_ITERS % HOT_LOOP_DEADLINE_CADENCE, 0);
385        assert_eq!(MAX_INNER_LOOP_ITERS / HOT_LOOP_DEADLINE_CADENCE, 15_625);
386    }
387
388    /// The bigram-bloom admission threshold shared by the coalesced producer and
389    /// the single-chunk entry is exactly the bare `64` those two sites used to
390    /// hardcode. Pin the concrete value: if it drifts, both admission gates
391    /// change their short-chunk skip boundary at once and a silent recall shift
392    /// would be invisible without this lock.
393    #[test]
394    fn bigram_bloom_min_chunk_bytes_is_sixty_four() {
395        assert_eq!(super::BIGRAM_BLOOM_MIN_CHUNK_BYTES, 64);
396    }
397
398    /// The unbounded/entropy cross-seam reassembly cap replaced a `usize::MAX`
399    /// full-chunk splice (O(pairs x chunk_bytes) rescan). It is pinned to the
400    /// FilesystemSource window overlap so the seam covers exactly the straddle
401    /// range the overlap design assumes catchable; drifting it silently changes
402    /// boundary recall AND the per-pair reassembly cost.
403    #[test]
404    fn boundary_seam_cap_matches_window_overlap() {
405        assert_eq!(
406            super::boundary::MAX_BOUNDARY_SEAM_BYTES,
407            crate::types::WINDOW_OVERLAP_BYTES
408        );
409        assert_eq!(super::boundary::MAX_BOUNDARY_SEAM_BYTES, 128 * 1024);
410    }
411}