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
//! Anchored-position verification for the shared-anchor phase-2 localizer.
//!
//! Split out of `phase2_anchor.rs` (Law 5, 500-LOC ceiling). That file owns
//! the `Phase2AnchorIndex` build + candidate-collection machinery; this is the
//! consumer side: `CompiledScanner::extract_anchored` replays the whole-chunk
//! `find_iter` walk for ONE eligible pattern at its candidate anchor positions,
//! emitting byte-identical matches via `process_match`. See `phase2_anchor.rs`
//! for the soundness argument (every match starts at a required-prefix literal,
//! so anchoring at every AC-reported position finds every whole-chunk match).
use super::CompiledScanner;
use crate::anchored_regex::AnchoredRegex;
use crate::types::*;
use keyhog_core::Chunk;
use std::cell::OnceCell;
impl CompiledScanner {
/// Verify one eligible phase-2 pattern at its candidate anchor positions
/// using the `\A`-anchored regex, emitting matches via `process_match`.
///
/// This reproduces the whole-chunk `find_iter` walk EXACTLY, non
/// -overlapping, leftmost, zero-width-skipping, so the produced match set
/// is byte-identical to `extract_matches` on the same pattern:
/// * `positions` are this pattern's candidate starts (sorted, ascending);
/// every real match starts at one of them (the anchor is required).
/// * `next_allowed` mirrors the whole-chunk cursor: after a match `[s,e)`
/// the next search resumes at `e` (or `s+1` for a zero-width match), so
/// candidate positions that fall inside an already-consumed match are
/// skipped (exactly as the cursor-advance loop skips them).
#[allow(clippy::too_many_arguments)]
pub(crate) fn extract_anchored(
&self,
entry: &CompiledPattern,
anchored_re: &AnchoredRegex,
positions: &[(u32, u32)],
preprocessed: &ScannerPreprocessedText<'_>,
line_index: &crate::context::LineContextIndex,
chunk: &Chunk,
scan_state: &mut ScanState,
deadline: Option<std::time::Instant>,
) {
let detector_plan = self.detector_plans.get(entry.detector_index);
let search_text: &str = &preprocessed.text;
let bytes_total = search_text.len();
// Per-pattern signal cache: constant across this pattern's matches but
// expensive (O(K x |chunk|) keyword scan + path AC). Computed at most
// once, on the first surviving match (same contract as extract.rs).
let signals = OnceCell::<(bool, bool)>::new();
// Build ONLY what a candidate position actually consults. A candidate at
// byte 0 reads the plain `\A` verifier; every other candidate reads the
// left-context variant, and a chunk almost never has a candidate at 0.
// Compiling the no-context verifier eagerly therefore built a whole
// second regex per eligible pattern that nothing read, and allocated its
// capture buffer again on every call. Both variants stay fail-closed:
// `AnchoredRegex::get*` PANICS on a build-invariant breach rather than
// returning `None`, so a skipped compile here is "no candidate needs it",
// never a swallowed failure (Law 10).
let mut needs_no_context = false;
let mut needs_left_context = false;
for &(_, pos) in positions {
if pos == 0 {
needs_no_context = true;
} else {
needs_left_context = true;
}
}
let group = entry.group;
// Capture slots are read only for a grouped pattern. A plain pattern
// takes the whole match, which `find` yields off the lazy DFA without
// running the capture engine or allocating a slot buffer at all.
let wants_captures = group.is_some();
let no_context_re = needs_no_context.then(|| anchored_re.get());
let left_context_re = needs_left_context.then(|| anchored_re.get_with_left_context());
let mut no_context_locs = no_context_re
.filter(|_| wants_captures)
.map(|re| re.capture_locations());
let mut left_context_locs = left_context_re
.filter(|_| wants_captures)
.map(|re| re.capture_locations());
// Mirror the whole-chunk cursor: next match must start at-or-after this.
let mut next_allowed: usize = 0;
// Same per-pattern hard cap + deadline cadence as extract.rs's inner
// loops so an adversarial chunk can't run unbounded under the anchored
// path either. Canonical cap lives in `engine::MAX_INNER_LOOP_ITERS`.
use super::MAX_INNER_LOOP_ITERS;
let loop_deadline = crate::deadline::LoopDeadline::from_deadline(deadline);
let mut iters: usize = 0;
for &(_, pos) in positions {
let pos = pos as usize;
if pos < next_allowed {
continue;
}
if iters >= MAX_INNER_LOOP_ITERS {
break;
}
if crate::deadline::loop_expired_on_cadence(
loop_deadline,
iters,
crate::deadline::HOT_LOOP_DEADLINE_CADENCE,
) {
break;
}
iters += 1;
if pos > bytes_total || !search_text.is_char_boundary(pos) {
continue;
}
let context_start = if pos == 0 {
0
} else {
super::floor_char_boundary(search_text, pos.saturating_sub(1))
};
let left_context_len = pos - context_start;
let use_left_context = left_context_len > 0;
let Some(re) = (if use_left_context {
left_context_re
} else {
no_context_re
}) else {
continue;
};
let slice = &search_text[context_start..];
// Grouped pattern: fill the slot buffer, the group branch below
// reads it. Plain pattern: `find` returns the identical leftmost
// whole match without the capture engine.
let whole = match if use_left_context {
left_context_locs.as_mut()
} else {
no_context_locs.as_mut()
} {
Some(locs) => {
let Some(whole) = re.captures_read(locs, slice) else {
continue;
};
whole
}
None => {
let Some(whole) = re.find(slice) else {
continue;
};
whole
}
};
// `\A` guarantees a hit starts at slice offset 0. For non-zero
// candidate positions the anchored regex consumes exactly one real
// preceding character before the detector pattern, so left-boundary
// constructs (`\b`, multiline `^`, etc.) see the same context as a
// whole-chunk regex walk instead of a fabricated haystack start.
if whole.start() != 0 {
continue;
}
let full_start = pos;
let full_end = context_start + whole.end();
// Zero-width match: skip emission (matches extract.rs) and advance
// one byte so an empty-shape pattern can't stall.
if full_end == full_start {
next_allowed = pos + 1;
continue;
}
next_allowed = full_end;
// Resolve the credential bytes. For grouped patterns, read the
// configured capture group (relative to `slice`), with the same
// variable-name fallback to a value-shaped sibling group as
// extract_grouped_matches; for plain patterns, the whole match.
let (credential, credential_start, credential_end): (&str, usize, usize) = match group {
Some(group) => {
let Some(locs) = (if use_left_context {
left_context_locs.as_ref()
} else {
no_context_locs.as_ref()
}) else {
continue;
};
let groups_total = locs.len();
let Some((mut cs, mut ce)) = locs.get(group) else {
continue;
};
// Group 0 belongs to the detector regex, not the synthetic
// left-context byte that lets boundary assertions see the
// real preceding character.
if use_left_context && group == 0 {
cs = left_context_len;
}
// Shared with `extract_grouped_matches`: a variable-name
// group falls back to a value-shaped sibling group.
(cs, ce) = super::scan_filters::resolve_value_shaped_group(
locs,
slice,
group,
groups_total,
(cs, ce),
);
let cred = &slice[cs..ce];
(cred, context_start + cs, context_start + ce)
}
None => (&slice[left_context_len..whole.end()], full_start, full_end),
};
let &(keyword_nearby, sensitive_file) = signals.get_or_init(|| {
super::scan_filters::compute_pattern_signals(
entry,
&detector_plan.execution,
chunk,
preprocessed,
)
});
self.process_match(
entry,
detector_plan,
search_text,
preprocessed,
line_index,
chunk,
scan_state,
credential,
credential_start,
credential_end,
keyword_nearby,
sensitive_file,
);
if crate::deadline::loop_expired(loop_deadline) {
break;
}
}
}
}