Skip to content

Commit 69ff2b3

Browse files
committed
perf(services): RegexSet first-pass for secret scanning (F-022)
Replaces per-pattern `Regex::is_match` loops in the scanners with a single `RegexSet` traversal that returns the index of the lowest- indexed pattern that matched. The pattern slice is then consulted only to look up the descriptive name on a hit. Layout: - New `PatternSet` struct holds an owned `Vec<SecretPattern>` plus the derived `RegexSet`. `PatternSet::first_match` does the combined NFA traversal. - `BUILTIN_PATTERN_SET: LazyLock<PatternSet>` caches the set for the built-in patterns (built once on first access on top of the existing `BUILTIN_PATTERNS` cache from F-012). - `scan_for_secrets` and `scan_full_diff_for_secrets` (no-args) use the cached set directly. - `scan_for_secrets_with_patterns` / `scan_full_diff_with_patterns` preserve their `&[SecretPattern]` API by building a one-shot `PatternSet` and delegating to private `*_with_pattern_set` helpers, so external callers see no API change. - Behaviour preserved: at most one match per added line; pattern precedence is the lowest-index in the configured set, matching the previous "first hit, break" semantics. Closes #25.
1 parent a596642 commit 69ff2b3

1 file changed

Lines changed: 88 additions & 22 deletions

File tree

‎src/services/safety.rs‎

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use std::borrow::Cow;
66
use std::sync::LazyLock;
77

8-
use regex::Regex;
8+
use regex::{Regex, RegexSet};
99

1010
use crate::domain::StagedChanges;
1111

@@ -28,6 +28,55 @@ pub struct SecretPattern {
2828
pub description: Cow<'static, str>,
2929
}
3030

31+
/// Bundle of secret patterns plus a derived `RegexSet` for batched first-pass
32+
/// matching.
33+
///
34+
/// `RegexSet` compiles all patterns into a single combined NFA, so checking
35+
/// a line against the whole set is dramatically cheaper than running each
36+
/// `Regex::is_match` independently. On a hit the indices returned by
37+
/// `RegexSet::matches` map back into `patterns` for the descriptive name.
38+
pub struct PatternSet {
39+
pub patterns: Vec<SecretPattern>,
40+
pub set: RegexSet,
41+
}
42+
43+
impl PatternSet {
44+
/// Build a `PatternSet` from an owned `Vec<SecretPattern>`.
45+
///
46+
/// The `RegexSet` is built once at construction time. If any individual
47+
/// pattern would fail to compile in the combined set (it should not — the
48+
/// patterns already passed `Regex::new`) we fall back to an empty
49+
/// `RegexSet`, in which case `first_match` simply returns `None` for
50+
/// every input. The per-pattern `Regex::is_match` fallback below then
51+
/// preserves correctness.
52+
pub fn new(patterns: Vec<SecretPattern>) -> Self {
53+
let sources: Vec<&str> = patterns.iter().map(|p| p.regex.as_str()).collect();
54+
let set = RegexSet::new(&sources).unwrap_or_else(|_| RegexSet::empty());
55+
Self { patterns, set }
56+
}
57+
58+
/// First-pass match. Returns the index of the *lowest-index* pattern that
59+
/// matches `line`, or `None` if no pattern matches. Uses the combined
60+
/// `RegexSet` (one NFA traversal) instead of N independent `Regex::is_match`
61+
/// calls.
62+
fn first_match(&self, line: &str) -> Option<usize> {
63+
// `matches()` returns a SetMatches; iterating it yields hit indices in
64+
// ascending order, so `next()` is the smallest index.
65+
self.set.matches(line).into_iter().next()
66+
}
67+
}
68+
69+
/// Convenience: build a `PatternSet` from a borrowed slice. Used by callers
70+
/// that hold an owned `Vec<SecretPattern>` and want first-pass acceleration.
71+
fn pattern_set_from_slice(patterns: &[SecretPattern]) -> PatternSet {
72+
PatternSet::new(patterns.to_vec())
73+
}
74+
75+
/// Cached `PatternSet` over the built-in patterns. The pattern vector and the
76+
/// `RegexSet` are both compiled exactly once per process.
77+
static BUILTIN_PATTERN_SET: LazyLock<PatternSet> =
78+
LazyLock::new(|| PatternSet::new(BUILTIN_PATTERNS.clone()));
79+
3180
/// Build the full set of secret patterns, applying custom additions and disabled names.
3281
///
3382
/// Custom patterns are compiled from user-provided regex strings. Invalid regexes
@@ -229,14 +278,28 @@ static BUILTIN_PATTERNS: LazyLock<Vec<SecretPattern>> = LazyLock::new(|| {
229278
/// for library consumers who only have `StagedChanges`.
230279
#[allow(dead_code)]
231280
pub fn scan_for_secrets(changes: &StagedChanges) -> Vec<SecretMatch> {
232-
scan_for_secrets_with_patterns(changes, builtin_patterns())
281+
scan_with_pattern_set(changes, &BUILTIN_PATTERN_SET)
233282
}
234283

235-
/// Scan per-file truncated diffs for secrets using the given pattern set.
284+
/// Scan per-file truncated diffs for secrets using the given pattern slice.
285+
///
286+
/// One-shot callers pay the cost of building a `RegexSet` here; repeated
287+
/// callers should construct a `PatternSet` once and call
288+
/// [`scan_with_pattern_set`] directly.
236289
pub fn scan_for_secrets_with_patterns(
237290
changes: &StagedChanges,
238291
patterns: &[SecretPattern],
239292
) -> Vec<SecretMatch> {
293+
let set = pattern_set_from_slice(patterns);
294+
scan_with_pattern_set(changes, &set)
295+
}
296+
297+
/// Scan per-file truncated diffs for secrets using a pre-built `PatternSet`.
298+
///
299+
/// Each added line is first matched against the combined `RegexSet`; only on
300+
/// a hit do we look up the individual pattern's name. This avoids running
301+
/// every `Regex::is_match` independently per line.
302+
fn scan_with_pattern_set(changes: &StagedChanges, pset: &PatternSet) -> Vec<SecretMatch> {
240303
let mut found = Vec::new();
241304

242305
for file in &changes.files {
@@ -253,15 +316,13 @@ pub fn scan_for_secrets_with_patterns(
253316
continue;
254317
}
255318

256-
for pat in patterns {
257-
if pat.regex.is_match(line) {
258-
found.push(SecretMatch {
259-
pattern_name: pat.name.to_string(),
260-
file: file.path.display().to_string(),
261-
line: Some(line_num),
262-
});
263-
break; // One match per line is enough
264-
}
319+
if let Some(idx) = pset.first_match(line) {
320+
let pat = &pset.patterns[idx];
321+
found.push(SecretMatch {
322+
pattern_name: pat.name.to_string(),
323+
file: file.path.display().to_string(),
324+
line: Some(line_num),
325+
});
265326
}
266327
}
267328
}
@@ -272,7 +333,7 @@ pub fn scan_for_secrets_with_patterns(
272333
/// Scan the full unified diff for secrets using default patterns.
273334
#[allow(dead_code)]
274335
pub fn scan_full_diff_for_secrets(full_diff: &str) -> Vec<SecretMatch> {
275-
scan_full_diff_with_patterns(full_diff, builtin_patterns())
336+
scan_full_diff_with_pattern_set(full_diff, &BUILTIN_PATTERN_SET)
276337
}
277338

278339
static HUNK_HEADER: LazyLock<Regex> =
@@ -288,6 +349,13 @@ pub fn scan_full_diff_with_patterns(
288349
full_diff: &str,
289350
patterns: &[SecretPattern],
290351
) -> Vec<SecretMatch> {
352+
let pset = pattern_set_from_slice(patterns);
353+
scan_full_diff_with_pattern_set(full_diff, &pset)
354+
}
355+
356+
/// Same as `scan_full_diff_with_patterns` but takes a pre-built `PatternSet`,
357+
/// avoiding the per-call `RegexSet` construction.
358+
fn scan_full_diff_with_pattern_set(full_diff: &str, pset: &PatternSet) -> Vec<SecretMatch> {
291359
let mut found = Vec::new();
292360
let mut current_file = String::new();
293361
let mut current_line: Option<usize> = None;
@@ -342,15 +410,13 @@ pub fn scan_full_diff_with_patterns(
342410
// guard, which would also drop legitimate added lines whose content
343411
// begins with `++`.
344412
if let Some(content) = line.strip_prefix('+') {
345-
for pat in patterns {
346-
if pat.regex.is_match(content) {
347-
found.push(SecretMatch {
348-
pattern_name: pat.name.to_string(),
349-
file: current_file.clone(),
350-
line: Some(*line_num),
351-
});
352-
break;
353-
}
413+
if let Some(idx) = pset.first_match(content) {
414+
let pat = &pset.patterns[idx];
415+
found.push(SecretMatch {
416+
pattern_name: pat.name.to_string(),
417+
file: current_file.clone(),
418+
line: Some(*line_num),
419+
});
354420
}
355421
*line_num += 1;
356422
} else if line.starts_with(' ') {

0 commit comments

Comments
 (0)