scour_secrets/scanner.rs
1//! Streaming scanner for detecting and replacing sensitive data.
2//!
3//! # Architecture
4//!
5//! The streaming scanner processes input data in configurable chunks,
6//! detecting secret patterns (regex or literal) and applying one-way
7//! replacements via the [`MappingStore`].
8//! This design supports files of 20–100 GB+ without requiring the entire
9//! content to fit in memory.
10//!
11//! ```text
12//! ┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐
13//! │ Input (Read) │ ──▶ │ StreamScanner │ ──▶ │ Output (Write) │
14//! │ (chunked) │ │ (pattern match │ │ (sanitized) │
15//! └──────────────┘ │ + replace) │ └──────────────────┘
16//! └────────┬────────┘
17//! │
18//! ┌────────▼────────┐
19//! │ MappingStore │
20//! │ (dedup cache) │
21//! └─────────────────┘
22//! ```
23//!
24//! # Chunk Overlap Strategy
25//!
26//! To avoid missing matches that span chunk boundaries, the scanner
27//! maintains an overlap window between consecutive chunks:
28//!
29//! 1. Read `chunk_size` bytes of new data.
30//! 2. Prepend the `carry` buffer (tail of previous window).
31//! 3. Scan the combined `window` for all pattern matches.
32//! 4. Compute `commit_point = window.len() - overlap_size`, then adjust:
33//! a match fully inside the window that straddles the boundary moves the
34//! commit point *up* (so it is emitted whole); a match that runs to the
35//! right edge of the window may be truncated by the buffer, so the commit
36//! point is pulled *back* to its start and the whole match is carried.
37//! 5. Emit output for `window[..commit_point]` with replacements applied.
38//! 6. Set `carry = window[commit_point..]` for the next iteration.
39//!
40//! Matches up to `chunk_size` bytes are therefore always seen in full before
41//! being committed. A single match longer than `chunk_size` (a pathological
42//! unbroken token) can never be buffered; rather than leak its tail, the
43//! scanner fails closed and replaces the whole run with a fixed redaction
44//! marker (see `OVERLONG_MARKER`).
45//!
46//! # Thread Safety
47//!
48//! [`StreamScanner`] is `Send + Sync`. Multiple files can be scanned
49//! concurrently using a shared `Arc<StreamScanner>`, all backed by the
50//! same [`MappingStore`] for per-run dedup
51//! consistency.
52//!
53//! # Performance
54//!
55//! - **Chunk-based I/O**: only `chunk_size + overlap_size` bytes in
56//! memory per active scan.
57//! - **Compiled regex**: patterns are compiled once at construction and
58//! reused across all chunks and files.
59//! - **Lock-free reads**: the `DashMap` inside `MappingStore` provides
60//! lock-free reads for already-seen values.
61//! - **File-level parallelism**: share `Arc<StreamScanner>` across
62//! threads to scan multiple files concurrently.
63
64use crate::category::Category;
65use crate::error::{Result, SanitizeError};
66use crate::store::MappingStore;
67use aho_corasick::AhoCorasick;
68use regex::bytes::{Regex, RegexBuilder, RegexSet, RegexSetBuilder};
69use serde::Serialize;
70use std::collections::HashMap;
71use std::io::{self, Read, Write};
72use std::sync::Arc;
73
74// ---------------------------------------------------------------------------
75// Configuration
76// ---------------------------------------------------------------------------
77
78/// Default chunk size: 1 MiB.
79const DEFAULT_CHUNK_SIZE: usize = 1024 * 1024;
80
81/// Default overlap size: 4 KiB.
82const DEFAULT_OVERLAP_SIZE: usize = 4096;
83
84/// Maximum compiled regex automaton size (bytes). Prevents DoS via
85/// pathologically complex user-supplied patterns.
86const REGEX_SIZE_LIMIT: usize = 1 << 20; // 1 MiB
87
88/// Maximum DFA cache size (bytes) per regex.
89const REGEX_DFA_SIZE_LIMIT: usize = 1 << 20; // 1 MiB
90
91/// Hard ceiling on the combined RegexSet automaton budget.
92/// The per-pattern limit is multiplied by the pattern count so that a large
93/// pattern set can still compile, but without this cap a pathological secrets
94/// file with 10 000 patterns could claim up to ~20 GiB of automaton memory.
95const REGEX_SET_SIZE_CAP: usize = 256 * 1024 * 1024; // 256 MiB
96
97/// Maximum number of regex-bound patterns allowed in a single scanner
98/// (F-05 fix). The `RegexSet` automaton memory scales linearly with pattern
99/// count. With 1 MiB size/DFA limits per pattern, 10 000 patterns could
100/// allocate up to ~20 GiB of automaton memory. This cap prevents
101/// accidental resource exhaustion. Literal patterns are exempt: they are
102/// matched by Aho-Corasick, not the `RegexSet` (see
103/// [`MAX_LITERAL_PATTERNS`]). Override via
104/// [`StreamScanner::new_with_max_patterns`] if needed.
105const DEFAULT_MAX_PATTERNS: usize = 10_000;
106
107/// Maximum number of literal patterns allowed in a single scanner.
108///
109/// Literals compile into a single Aho-Corasick automaton whose memory scales
110/// with total literal bytes, not with a per-pattern automaton budget, so this
111/// cap is far looser than [`DEFAULT_MAX_PATTERNS`]. A profile pass over a
112/// large input (e.g. a full Dataiku DATA_DIR) legitimately discovers tens of
113/// thousands of field values; the cap only guards against a pathological
114/// input claiming unbounded automaton memory.
115const MAX_LITERAL_PATTERNS: usize = 500_000;
116
117/// Fixed marker emitted in place of a single match that is longer than the
118/// scanner is willing to buffer (see [`StreamScanner::scan_reader_with_callbacks`]).
119///
120/// Such a match cannot be length-preserved (we never hold all of it in memory),
121/// so the scanner fails closed: it redacts the entire over-long run with this
122/// marker rather than risk emitting any of its bytes verbatim.
123const OVERLONG_MARKER: &[u8] = b"__SANITIZED_OVERLONG__";
124
125/// Pattern label reported via `on_match` for an over-long redaction.
126const OVERLONG_LABEL: &str = "overlong-redacted";
127
128/// Label suffix that marks patterns as key-value-only.
129///
130/// Patterns whose label ends with this suffix are excluded from the streaming
131/// scanner pass (`for_structured_pass`) because the key-value processor
132/// resolves their values structurally and the scanner would produce spurious
133/// duplicate replacements on the surrounding syntax.
134pub const KV_LABEL_SUFFIX: &str = "_kv";
135
136/// Minimum length for a structured-field-discovered value to be reused as a
137/// scanner literal — both for in-run propagation (the augmented scanner and
138/// per-entry structured passes) and for persistence to a secrets file.
139///
140/// Values shorter than this (e.g. a `v`, `id`, or a bare digit from a job-args
141/// field) match too much unrelated text: as a scanner literal they corrupt the
142/// current run (timestamps, paths, protocol strings), and once written to the
143/// secrets file they poison *every* future run. Every path that turns store
144/// entries into literal patterns must share this threshold so a value is never
145/// persisted that the scanner itself would reject.
146pub const MIN_DISCOVERED_LITERAL_LEN: usize = 4;
147
148/// Configuration for the streaming scanner.
149///
150/// # Tuning Guide
151///
152/// | Workload | `chunk_size` | `overlap_size` |
153/// |------------------------|--------------|----------------|
154/// | Small files (< 10 MB) | 256 KiB | 1 KiB |
155/// | General purpose | 1 MiB | 4 KiB |
156/// | Large files (> 1 GB) | 4–8 MiB | 8 KiB |
157/// | Memory-constrained | 64 KiB | 1 KiB |
158///
159/// `overlap_size` should be ≥ the longest expected match. Most secret
160/// patterns (API keys, emails, SSNs) are well under 256 bytes, so the
161/// 4 KiB default provides ample margin.
162#[derive(Debug, Clone)]
163#[non_exhaustive]
164pub struct ScanConfig {
165 /// Size of each chunk read from the input (bytes).
166 ///
167 /// Larger chunks improve throughput (fewer syscalls) but use more
168 /// memory. Default: 1 MiB.
169 pub chunk_size: usize,
170
171 /// Overlap between consecutive chunks (bytes).
172 ///
173 /// Must be ≥ the maximum expected match length. Patterns whose
174 /// matches can exceed this length risk being missed at chunk
175 /// boundaries. Default: 4 KiB.
176 pub overlap_size: usize,
177}
178
179impl Default for ScanConfig {
180 fn default() -> Self {
181 Self {
182 chunk_size: DEFAULT_CHUNK_SIZE,
183 overlap_size: DEFAULT_OVERLAP_SIZE,
184 }
185 }
186}
187
188impl ScanConfig {
189 /// Create a new configuration with explicit values.
190 #[must_use]
191 pub fn new(chunk_size: usize, overlap_size: usize) -> Self {
192 Self {
193 chunk_size,
194 overlap_size,
195 }
196 }
197
198 /// Validate the configuration, returning an error if invalid.
199 ///
200 /// # Errors
201 ///
202 /// Returns [`SanitizeError::InvalidConfig`] if `chunk_size` is zero
203 /// or `overlap_size >= chunk_size`.
204 pub fn validate(&self) -> Result<()> {
205 if self.chunk_size == 0 {
206 return Err(SanitizeError::InvalidConfig(
207 "chunk_size must be > 0".into(),
208 ));
209 }
210 if self.overlap_size >= self.chunk_size {
211 return Err(SanitizeError::InvalidConfig(
212 "overlap_size must be < chunk_size".into(),
213 ));
214 }
215 Ok(())
216 }
217}
218
219// ---------------------------------------------------------------------------
220// Internal helpers
221// ---------------------------------------------------------------------------
222
223/// Convert any compile-time pattern error into [`SanitizeError::PatternCompileError`].
224#[inline]
225fn compile_err(e: impl std::fmt::Display) -> SanitizeError {
226 SanitizeError::PatternCompileError(e.to_string())
227}
228
229// ---------------------------------------------------------------------------
230// Scan pattern
231// ---------------------------------------------------------------------------
232
233/// A pattern rule defining what to scan for and how to categorize matches.
234///
235/// Wraps a compiled [`regex::bytes::Regex`] with a [`Category`] for
236/// replacement lookups and a human-readable label for reporting.
237///
238/// Both regex and literal patterns are supported. Literal patterns keep
239/// their original text and are matched by the scanner's Aho-Corasick
240/// automaton for fast multi-literal scanning.
241pub struct ScanPattern {
242 /// Compiled regex matcher (used for non-literal patterns and as a
243 /// fallback; literal patterns are matched via Aho-Corasick instead).
244 regex: Regex,
245 /// Category for replacement lookups.
246 category: Category,
247 /// Human-readable label for reporting / stats.
248 label: String,
249 /// Original (unescaped) literal string when created via `from_literal`.
250 /// `None` for patterns created via `from_regex`.
251 /// Stored so `StreamScanner` can build an Aho-Corasick automaton for
252 /// fast SIMD literal matching instead of running the regex engine.
253 literal: Option<String>,
254 /// Minimum match length (bytes). Matches shorter than this are discarded.
255 /// For literal patterns this defaults to the byte length of the literal
256 /// itself; for regex patterns it defaults to `0` (no minimum). Set from a
257 /// secrets entry's `min_length` to suppress short false positives.
258 pub min_length: usize,
259 /// Maximum match length (bytes). Matches longer than this are discarded.
260 /// Defaults to [`usize::MAX`] (no maximum). Set from a secrets entry's
261 /// `max_length` to bound a greedy pattern — this also caps how far an
262 /// unbounded pattern can run before the streaming scanner's over-long
263 /// redaction takes over.
264 pub max_length: usize,
265 /// Require word boundaries (non-`[A-Za-z0-9_]` neighbours) around a
266 /// literal match. Set for name-category literals: a seeded login like
267 /// `admin` must not rewrite `administrators`, `adminProperties`, or path
268 /// segments like `dssadmin` — matches inside larger words also bypass the
269 /// allowlist, which only sees the matched span. Tokens/passwords stay
270 /// substring-matched: a real secret embedded in a longer string must
271 /// still be caught.
272 word_boundary: bool,
273}
274
275impl std::fmt::Debug for ScanPattern {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 f.debug_struct("ScanPattern")
278 .field("pattern", &self.regex.as_str())
279 .field("category", &self.category)
280 .field("label", &self.label)
281 .field("literal", &self.literal.as_deref())
282 .field("min_length", &self.min_length)
283 .field("max_length", &self.max_length)
284 .field("word_boundary", &self.word_boundary)
285 .finish()
286 }
287}
288
289impl Clone for ScanPattern {
290 fn clone(&self) -> Self {
291 Self {
292 regex: self.regex.clone(),
293 category: self.category.clone(),
294 label: self.label.clone(),
295 literal: self.literal.clone(),
296 min_length: self.min_length,
297 max_length: self.max_length,
298 word_boundary: self.word_boundary,
299 }
300 }
301}
302
303impl ScanPattern {
304 /// Create a pattern from a regex string.
305 ///
306 /// ## Capture group 1 — partial replacement
307 ///
308 /// If the regex contains a capture group 1 (`(...)`), only the bytes
309 /// matched by that group are replaced; the bytes before and after it
310 /// within the full match are emitted verbatim. This lets you write
311 /// context-anchored patterns without redacting the prefix/suffix:
312 ///
313 /// ```text
314 /// pattern: glpat-([A-Za-z0-9_-]{20})
315 /// ^^^^^^ prefix preserved
316 /// ^^^^^^^^^^^^^^^^^^^^ group 1 → replaced
317 /// ```
318 ///
319 /// Patterns **without** a capture group replace the entire match.
320 ///
321 /// # Errors
322 ///
323 /// Returns [`SanitizeError::PatternCompileError`] if the regex is invalid.
324 ///
325 /// # Examples
326 ///
327 /// ```
328 /// use scour_secrets::scanner::ScanPattern;
329 /// use scour_secrets::category::Category;
330 ///
331 /// // No capture group — full match replaced:
332 /// let email = ScanPattern::from_regex(
333 /// r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
334 /// Category::Email,
335 /// "email_address",
336 /// ).unwrap();
337 ///
338 /// // Capture group 1 — prefix preserved, only the token value replaced:
339 /// let token = ScanPattern::from_regex(
340 /// r"glpat-([A-Za-z0-9_-]{20})",
341 /// Category::AuthToken,
342 /// "gitlab_pat",
343 /// ).unwrap();
344 /// ```
345 pub fn from_regex(pattern: &str, category: Category, label: impl Into<String>) -> Result<Self> {
346 let regex = RegexBuilder::new(pattern)
347 .size_limit(REGEX_SIZE_LIMIT)
348 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
349 .build()
350 .map_err(compile_err)?;
351 Ok(Self {
352 regex,
353 category,
354 label: label.into(),
355 literal: None,
356 min_length: 0,
357 max_length: usize::MAX,
358 // Regex authors control their own boundaries (`\b` in the pattern).
359 word_boundary: false,
360 })
361 }
362
363 /// Set the inclusive match-length bounds (bytes) for this pattern.
364 ///
365 /// Matches shorter than `min` or longer than `max` are discarded during
366 /// scanning. `min == 0` and `max == usize::MAX` impose no bound. Used to
367 /// plumb a secrets entry's `min_length` / `max_length` onto a compiled
368 /// pattern without changing the public constructor signatures.
369 #[must_use]
370 pub fn with_length_bounds(mut self, min: usize, max: usize) -> Self {
371 self.min_length = min;
372 self.max_length = max;
373 self
374 }
375
376 /// Create a pattern from a literal string.
377 ///
378 /// The literal is escaped so that regex metacharacters are matched
379 /// verbatim.
380 ///
381 /// # Errors
382 ///
383 /// Returns [`SanitizeError::PatternCompileError`] if regex compilation fails.
384 ///
385 /// # Examples
386 ///
387 /// ```
388 /// use scour_secrets::scanner::ScanPattern;
389 /// use scour_secrets::category::Category;
390 ///
391 /// let pat = ScanPattern::from_literal(
392 /// "sk-proj-abc123secret",
393 /// Category::Custom("api_key".into()),
394 /// "openai_key",
395 /// ).unwrap();
396 /// ```
397 pub fn from_literal(
398 literal: &str,
399 category: Category,
400 label: impl Into<String>,
401 ) -> Result<Self> {
402 let escaped = regex::escape(literal);
403 let regex = RegexBuilder::new(&escaped)
404 .size_limit(REGEX_SIZE_LIMIT)
405 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
406 .build()
407 .map_err(compile_err)?;
408 let word_boundary = matches!(category, Category::Name);
409 Ok(Self {
410 regex,
411 category,
412 label: label.into(),
413 min_length: literal.len(),
414 max_length: usize::MAX,
415 literal: Some(literal.to_owned()),
416 word_boundary,
417 })
418 }
419
420 /// The category this pattern maps to.
421 #[must_use]
422 pub fn category(&self) -> &Category {
423 &self.category
424 }
425
426 /// The human-readable label.
427 #[must_use]
428 pub fn label(&self) -> &str {
429 &self.label
430 }
431
432 /// Return the raw regex pattern string for RegexSet construction.
433 #[must_use]
434 pub fn regex_pattern(&self) -> &str {
435 self.regex.as_str()
436 }
437}
438
439// ScanPattern is Send + Sync because:
440// - regex::bytes::Regex is Send + Sync
441// - Category is Send + Sync (it's an enum of primitives + CompactString)
442// - String is Send + Sync
443
444/// Whether `window[start..end]` sits on word boundaries: the bytes adjacent
445/// to the span (when present) are not `[A-Za-z0-9_]`. Window edges count as
446/// boundaries — the chunk overlap re-scans boundary-adjacent spans with
447/// context, so a mid-stream edge is rare and errs toward redacting.
448#[inline]
449fn word_bounded(window: &[u8], start: usize, end: usize) -> bool {
450 let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
451 let before_ok = start == 0 || !is_word(window[start - 1]);
452 let after_ok = end >= window.len() || !is_word(window[end]);
453 before_ok && after_ok
454}
455
456// ---------------------------------------------------------------------------
457// Internal: raw match descriptor
458// ---------------------------------------------------------------------------
459
460/// A single match found during scanning (internal).
461#[derive(Debug, Clone, Copy)]
462struct RawMatch {
463 /// Start byte offset within the scan window.
464 start: usize,
465 /// End byte offset (exclusive) within the scan window.
466 end: usize,
467 /// Index into the `StreamScanner::patterns` vector.
468 pattern_idx: usize,
469 /// Byte range of capture group 1 within the window, if the pattern has one.
470 /// When present, only this sub-range is replaced; the bytes between
471 /// `start..capture_start` and `capture_end..end` are emitted verbatim,
472 /// preserving surrounding context (delimiters, key names, prefixes).
473 capture: Option<(usize, usize)>,
474}
475
476// ---------------------------------------------------------------------------
477// Per-scan scratch buffers
478// ---------------------------------------------------------------------------
479
480/// Scratch buffers reused across chunks within a single scan call.
481///
482/// Allocating these once per `scan_reader_with_progress` invocation
483/// and reusing them each chunk eliminates the per-chunk heap pressure
484/// that would otherwise come from `Vec` allocations in `find_matches`
485/// and `apply_replacements`.
486struct ScanScratch {
487 /// Accumulates raw matches from all patterns before deduplication.
488 all_matches: Vec<RawMatch>,
489 /// Non-overlapping matches selected for the current window
490 /// (populated by `find_matches`, consumed by `apply_replacements`).
491 selected: Vec<RawMatch>,
492 /// Output bytes for the committed region, written by `apply_replacements`.
493 output: Vec<u8>,
494 /// Per-pattern match counts indexed by `pattern_idx`.
495 /// Reset to zero after each chunk's counts are folded into `ScanStats`.
496 pattern_counts: Vec<u64>,
497}
498
499/// Result of processing one scan window.
500struct WindowOutcome {
501 /// How many bytes of the window were committed (emitted) this iteration.
502 /// The tail `window[commit_point..]` becomes the carry for the next chunk
503 /// (or, when `overlong` is set, is dropped as part of the redacted run).
504 commit_point: usize,
505 /// True when a single match was longer than the scanner will buffer and was
506 /// redacted with [`OVERLONG_MARKER`]. The caller must drop the rest of the
507 /// window and keep consuming the run until a whitespace boundary is reached.
508 overlong: bool,
509}
510
511impl ScanScratch {
512 fn new(pattern_count: usize, chunk_size: usize, overlap_size: usize) -> Self {
513 Self {
514 all_matches: Vec::with_capacity(64),
515 selected: Vec::with_capacity(64),
516 output: Vec::with_capacity(chunk_size + overlap_size),
517 pattern_counts: vec![0u64; pattern_count],
518 }
519 }
520}
521
522// ---------------------------------------------------------------------------
523// Scan statistics
524// ---------------------------------------------------------------------------
525
526/// The file-level position of a single scanner match.
527///
528/// Emitted via the `on_match` callback in
529/// [`StreamScanner::scan_reader_with_callbacks`]. Line numbers are
530/// 1-based and count `\n` bytes only (Unix line endings). For files with
531/// Windows line endings (`\r\n`), `line` is still correct because `\n` is
532/// the canonical line separator — `\r` bytes do not affect the count.
533///
534/// `byte_offset` is the absolute byte position of the first byte of the
535/// matched region within the file (0-based). Both fields refer to the
536/// *input* file, not the sanitized output.
537#[derive(Debug, Clone, Serialize)]
538#[non_exhaustive]
539pub struct MatchLocation {
540 /// 1-based line number of the match within the file.
541 pub line: u64,
542 /// 0-based byte offset of the match start within the file.
543 pub byte_offset: u64,
544 /// Pattern label that triggered this match.
545 pub pattern: String,
546}
547
548/// Statistics collected during a scan operation.
549///
550/// Returned by [`StreamScanner::scan_reader`] and
551/// [`StreamScanner::scan_bytes`] to provide visibility into what
552/// the scanner did.
553#[derive(Debug, Clone, Default, PartialEq)]
554#[non_exhaustive]
555pub struct ScanStats {
556 /// Total bytes read from the input.
557 pub bytes_processed: u64,
558 /// Total bytes written to the output.
559 ///
560 /// Equals `bytes_processed` under the default length-preserving policy
561 /// ([`LengthPolicy::Preserve`](crate::LengthPolicy)), because replacements
562 /// then match the original byte length. Under
563 /// [`LengthPolicy::Randomized`](crate::LengthPolicy) replacement lengths are
564 /// drawn independently of the original, so the two can differ.
565 pub bytes_output: u64,
566 /// Total number of matches found across all patterns.
567 pub matches_found: u64,
568 /// Total number of replacements applied (always == `matches_found`
569 /// in one-way mode).
570 pub replacements_applied: u64,
571 /// Per-pattern match counts, keyed by pattern label.
572 pub pattern_counts: HashMap<String, u64>,
573}
574
575/// Progress snapshot emitted during streaming scans.
576#[derive(Debug, Clone, Default, Eq, PartialEq)]
577#[non_exhaustive]
578pub struct ScanProgress {
579 /// Total bytes read from the input so far.
580 pub bytes_processed: u64,
581 /// Total bytes written to the output so far.
582 pub bytes_output: u64,
583 /// Total input size when known.
584 pub total_bytes: Option<u64>,
585 /// Total number of matches found so far.
586 pub matches_found: u64,
587 /// Total replacements applied so far.
588 pub replacements_applied: u64,
589}
590
591// ---------------------------------------------------------------------------
592// StreamScanner
593// ---------------------------------------------------------------------------
594
595/// Streaming scanner that detects and replaces sensitive patterns.
596///
597/// Thread-safe: can be shared via `Arc<StreamScanner>` for concurrent
598/// scanning of multiple files. Each call to [`scan_reader`](Self::scan_reader)
599/// is independent and maintains its own chunking state.
600///
601/// # Usage
602///
603/// ```rust
604/// use scour_secrets::scanner::{StreamScanner, ScanPattern, ScanConfig};
605/// use scour_secrets::category::Category;
606/// use scour_secrets::generator::HmacGenerator;
607/// use scour_secrets::store::MappingStore;
608/// use std::sync::Arc;
609///
610/// // 1. Build the replacement store.
611/// let gen = Arc::new(HmacGenerator::new([42u8; 32]));
612/// let store = Arc::new(MappingStore::new(gen, None));
613///
614/// // 2. Define patterns.
615/// let patterns = vec![
616/// ScanPattern::from_regex(
617/// r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
618/// Category::Email,
619/// "email",
620/// ).unwrap(),
621/// ];
622///
623/// // 3. Create the scanner.
624/// let scanner = StreamScanner::new(patterns, store, ScanConfig::default()).unwrap();
625///
626/// // 4. Scan.
627/// let input = b"Contact alice@corp.com for details.";
628/// let (output, stats) = scanner.scan_bytes(input).unwrap();
629/// assert_eq!(stats.matches_found, 1);
630/// assert!(!output.windows(b"alice@corp.com".len())
631/// .any(|w| w == b"alice@corp.com"));
632/// ```
633pub struct StreamScanner {
634 /// Compiled scan patterns (both literal and regex).
635 patterns: Vec<ScanPattern>,
636 /// Pre-compiled set for fast multi-pattern pre-filtering of **regex**
637 /// (non-literal) patterns only. `matches()` returns which regex-pattern
638 /// indices matched, avoiding running every individual regex on each chunk
639 /// (R-3 optimisation).
640 regex_set: RegexSet,
641 /// Maps a `RegexSet` index → index into `self.patterns`.
642 /// Only non-literal patterns are in the `RegexSet`.
643 regex_indices: Vec<usize>,
644 /// Aho-Corasick automaton for fast SIMD literal matching.
645 /// `None` when there are no literal patterns.
646 aho_corasick: Option<AhoCorasick>,
647 /// Maps an Aho-Corasick pattern index → index into `self.patterns`.
648 /// Only literal patterns appear here.
649 literal_indices: Vec<usize>,
650 /// Thread-safe dedup replacement store.
651 store: Arc<MappingStore>,
652 /// Scanner configuration.
653 config: ScanConfig,
654}
655
656/// Result of loading a secrets file into a [`StreamScanner`].
657///
658/// Returned by [`StreamScanner::from_encrypted_secrets`] and
659/// [`StreamScanner::from_plaintext_secrets`].
660///
661/// The `#[must_use]` attribute guards against silently discarding
662/// `allow_patterns`, which would cause values that should be suppressed
663/// to be sanitized instead.
664///
665/// # Example
666///
667/// ```rust,ignore
668/// let SecretsLoadResult { scanner, warnings, allow_patterns } =
669/// StreamScanner::from_plaintext_secrets(bytes, None, store, config, vec![])?;
670///
671/// for (idx, err) in &warnings {
672/// eprintln!("secrets entry {idx} failed to compile: {err}");
673/// }
674///
675/// let (allowlist, al_warnings) = AllowlistMatcher::new(allow_patterns).into_parts();
676/// let store = MappingStore::new_with_allowlist(gen, None, Arc::new(allowlist));
677/// ```
678#[must_use = "use allow_patterns to build an AllowlistMatcher; check warnings for skipped patterns"]
679#[non_exhaustive]
680pub struct SecretsLoadResult {
681 /// The compiled scanner, ready to use.
682 pub scanner: StreamScanner,
683 /// Secrets-file entries that failed pattern compilation:
684 /// `(index_in_file, error)`. A non-empty list means some patterns were
685 /// silently skipped and the scanner covers less than the full file.
686 pub warnings: Vec<(usize, SanitizeError)>,
687 /// Raw strings from `kind: allow` entries in the secrets file.
688 /// Pass these to [`crate::allowlist::AllowlistMatcher::new`] and attach
689 /// the resulting matcher to a [`crate::store::MappingStore`] via
690 /// [`crate::store::MappingStore::new_with_allowlist`].
691 pub allow_patterns: Vec<String>,
692}
693
694impl StreamScanner {
695 /// Create a new streaming scanner.
696 ///
697 /// # Arguments
698 ///
699 /// - `patterns` — the set of patterns to scan for.
700 /// - `store` — the mapping store for dedup-consistent replacements.
701 /// - `config` — chunking / overlap configuration.
702 ///
703 /// # Errors
704 ///
705 /// Returns [`SanitizeError::InvalidConfig`] if the configuration is
706 /// invalid (e.g. `chunk_size == 0` or `overlap_size >= chunk_size`).
707 pub fn new(
708 patterns: Vec<ScanPattern>,
709 store: Arc<MappingStore>,
710 config: ScanConfig,
711 ) -> Result<Self> {
712 Self::new_with_max_patterns(patterns, store, config, DEFAULT_MAX_PATTERNS)
713 }
714
715 /// Create a new streaming scanner with a custom pattern limit.
716 ///
717 /// This is identical to [`new`](Self::new) but allows overriding the
718 /// default regex pattern cap (10 000). Use this when you have a
719 /// legitimate need for more regex patterns and have verified that your
720 /// system has enough memory for the resulting `RegexSet`.
721 ///
722 /// `max_patterns` applies to regex-bound patterns only. Literal patterns
723 /// are matched by a shared Aho-Corasick automaton and are capped
724 /// separately at 500 000, independent of this parameter.
725 ///
726 /// # Errors
727 ///
728 /// Returns [`SanitizeError::InvalidConfig`] if the configuration is
729 /// invalid, the regex pattern count exceeds `max_patterns`, or the
730 /// literal pattern count exceeds the literal cap.
731 pub fn new_with_max_patterns(
732 patterns: Vec<ScanPattern>,
733 store: Arc<MappingStore>,
734 config: ScanConfig,
735 max_patterns: usize,
736 ) -> Result<Self> {
737 config.validate()?;
738
739 // Partition patterns into literal (Aho-Corasick) and regex (RegexSet)
740 // so each is matched by the most efficient engine.
741 let mut literal_bytes: Vec<Vec<u8>> = Vec::new();
742 let mut literal_indices: Vec<usize> = Vec::new();
743 let mut regex_strs: Vec<&str> = Vec::new();
744 let mut regex_indices: Vec<usize> = Vec::new();
745
746 for (i, pattern) in patterns.iter().enumerate() {
747 if let Some(lit) = &pattern.literal {
748 literal_bytes.push(lit.as_bytes().to_vec());
749 literal_indices.push(i);
750 } else {
751 regex_strs.push(pattern.regex_pattern());
752 regex_indices.push(i);
753 }
754 }
755
756 // F-05 fix: enforce maximum pattern count to bound automaton memory.
757 // Only regex-bound patterns count toward `max_patterns` — the
758 // RegexSet is what scales linearly per pattern. Literals go to a
759 // shared Aho-Corasick automaton and get their own, far looser cap:
760 // a profile pass over a large input routinely discovers tens of
761 // thousands of literal field values.
762 if regex_strs.len() > max_patterns {
763 return Err(SanitizeError::InvalidConfig(format!(
764 "regex pattern count ({}) exceeds maximum allowed ({}) — \
765 RegexSet memory scales linearly with pattern count",
766 regex_strs.len(),
767 max_patterns
768 )));
769 }
770 if literal_bytes.len() > MAX_LITERAL_PATTERNS {
771 return Err(SanitizeError::InvalidConfig(format!(
772 "literal pattern count ({}) exceeds maximum allowed ({})",
773 literal_bytes.len(),
774 MAX_LITERAL_PATTERNS
775 )));
776 }
777
778 // Build Aho-Corasick automaton for literal patterns (SIMD-accelerated,
779 // single O(n) pass over the input per chunk).
780 let aho_corasick = if literal_bytes.is_empty() {
781 None
782 } else {
783 Some(AhoCorasick::new(&literal_bytes).map_err(compile_err)?)
784 };
785
786 // Build RegexSet from non-literal patterns only (R-3 pre-filter).
787 let regex_set = if regex_strs.is_empty() {
788 RegexSetBuilder::new(Vec::<&str>::new())
789 .size_limit(REGEX_SIZE_LIMIT)
790 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
791 .build()
792 .map_err(compile_err)?
793 } else {
794 RegexSetBuilder::new(®ex_strs)
795 .size_limit((REGEX_SIZE_LIMIT * regex_strs.len().max(1)).min(REGEX_SET_SIZE_CAP))
796 .dfa_size_limit(
797 (REGEX_DFA_SIZE_LIMIT * regex_strs.len().max(1)).min(REGEX_SET_SIZE_CAP),
798 )
799 .build()
800 .map_err(compile_err)?
801 };
802
803 Ok(Self {
804 patterns,
805 regex_set,
806 regex_indices,
807 aho_corasick,
808 literal_indices,
809 store,
810 config,
811 })
812 }
813
814 /// Create a copy of this scanner extended with additional literal patterns.
815 ///
816 /// Clones the existing pattern set and appends `extra`, then rebuilds
817 /// the internal Aho-Corasick and RegexSet automata. Used by the
818 /// format-preserving structured pass to scan original bytes with
819 /// discovered field-value literals added to the base pattern set.
820 ///
821 /// # Errors
822 ///
823 /// Returns [`SanitizeError`] if automaton construction fails or the
824 /// combined pattern counts exceed the default regex/literal limits.
825 pub fn with_extra_literals(&self, extra: Vec<ScanPattern>) -> Result<Self> {
826 let mut patterns = self.patterns.clone();
827 patterns.extend(extra);
828 Self::new(patterns, Arc::clone(&self.store), self.config.clone())
829 }
830
831 /// Build a scanner suitable for format-preserving structured-file passes.
832 ///
833 /// Patterns whose labels end with `"_kv"` are excluded from the base set.
834 /// Those patterns match both a key name and its value (e.g. `password: s3cr3t`)
835 /// as a single unit; in a structured pass the key must survive untouched so
836 /// only the discovered field-value literals are safe to replace.
837 ///
838 /// `extra` (the profile-discovered literals) are always included.
839 ///
840 /// # Errors
841 ///
842 /// Returns [`SanitizeError`] if Aho-Corasick or RegexSet construction fails
843 /// or the combined pattern counts exceed the default regex/literal limits.
844 pub fn for_structured_pass(&self, extra: Vec<ScanPattern>) -> Result<Self> {
845 let mut patterns: Vec<ScanPattern> = self
846 .patterns
847 .iter()
848 .filter(|p| !p.label.ends_with(KV_LABEL_SUFFIX))
849 .cloned()
850 .collect();
851 patterns.extend(extra);
852 Self::new(patterns, Arc::clone(&self.store), self.config.clone())
853 }
854
855 /// Scan a reader and write sanitized output to a writer.
856 ///
857 /// Processes the input in chunks of `config.chunk_size` bytes,
858 /// maintaining an overlap window of `config.overlap_size` bytes to
859 /// catch matches spanning chunk boundaries. All detected matches
860 /// are replaced one-way via the [`MappingStore`].
861 ///
862 /// # Arguments
863 ///
864 /// - `reader` — input source (file, network stream, `&[u8]`, …).
865 /// - `writer` — output sink (file, `Vec<u8>`, …).
866 ///
867 /// # Returns
868 ///
869 /// [`ScanStats`] with counters for bytes processed, matches found, etc.
870 ///
871 /// # Errors
872 ///
873 /// Returns [`SanitizeError`] on I/O failures or if a replacement
874 /// cannot be generated (e.g. store capacity exceeded).
875 pub fn scan_reader<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ScanStats> {
876 self.scan_reader_with_callbacks(reader, writer, None, |_| {}, |_| {})
877 }
878
879 /// Scan a reader and emit progress snapshots after each committed chunk.
880 ///
881 /// `total_bytes` should be provided when the caller knows the full input
882 /// size. When omitted, progress consumers should avoid percentages/ETA.
883 ///
884 /// This is a convenience wrapper around [`scan_reader_with_callbacks`](Self::scan_reader_with_callbacks)
885 /// that discards per-match location information. Use that method directly
886 /// when you need line numbers or byte offsets for individual matches.
887 ///
888 /// # Errors
889 ///
890 /// Returns [`SanitizeError`] on I/O failures or if a replacement
891 /// cannot be generated (e.g. store capacity exceeded).
892 pub fn scan_reader_with_progress<R: Read, W: Write, F>(
893 &self,
894 reader: R,
895 writer: W,
896 total_bytes: Option<u64>,
897 on_progress: F,
898 ) -> Result<ScanStats>
899 where
900 F: FnMut(&ScanProgress),
901 {
902 self.scan_reader_with_callbacks(reader, writer, total_bytes, on_progress, |_| {})
903 }
904
905 /// Scan a reader, emit progress snapshots, and call `on_match` for every
906 /// committed match with its 1-based line number and byte offset.
907 ///
908 /// `on_match` is called synchronously in the scanning thread, once per
909 /// committed match, in document order. The callback receives a
910 /// [`MatchLocation`] describing the pattern label, 1-based line number,
911 /// and 0-based byte offset within the input file. Callers that only need
912 /// aggregate counts (no per-match positions) should prefer
913 /// [`scan_reader_with_progress`](Self::scan_reader_with_progress), which
914 /// skips the per-byte newline counting entirely.
915 ///
916 /// # Performance note
917 ///
918 /// Enabling `on_match` adds an O(committed_bytes_between_matches)
919 /// newline-counting pass inside each chunk. For files with sparse matches
920 /// this overhead is proportional to file size; for dense matches (e.g. one
921 /// secret per line) it is negligible. On 10–15 GiB log files with typical
922 /// match densities the overhead is roughly 10–20 % of total scan time.
923 ///
924 /// # Errors
925 ///
926 /// Returns [`SanitizeError`] on I/O failures or if a replacement
927 /// cannot be generated (e.g. store capacity exceeded).
928 pub fn scan_reader_with_callbacks<R: Read, W: Write, F, M>(
929 &self,
930 mut reader: R,
931 mut writer: W,
932 total_bytes: Option<u64>,
933 mut on_progress: F,
934 mut on_match: M,
935 ) -> Result<ScanStats>
936 where
937 F: FnMut(&ScanProgress),
938 M: FnMut(MatchLocation),
939 {
940 let mut stats = ScanStats::default();
941
942 // Carry buffer: the tail of the previous window that needs
943 // to be re-scanned with the next chunk.
944 let mut carry: Vec<u8> = Vec::new();
945
946 // Read buffer (reused across iterations to avoid re-allocation).
947 let mut read_buf = vec![0u8; self.config.chunk_size];
948
949 // Scan window (reused across iterations — grows to peak size then
950 // stays there, avoiding per-chunk allocation).
951 let mut window: Vec<u8> =
952 Vec::with_capacity(self.config.chunk_size + self.config.overlap_size);
953
954 // Scratch buffers reused every chunk to eliminate per-chunk heap
955 // pressure from match collection, output building, and stats tracking.
956 let mut scratch = ScanScratch::new(
957 self.patterns.len(),
958 self.config.chunk_size,
959 self.config.overlap_size,
960 );
961
962 // Absolute file byte offset of window[0] for this iteration.
963 let mut window_file_offset: u64 = 0;
964 // Cumulative newline count in the file before window[0].
965 let mut newlines_before_window: u64 = 0;
966
967 // True while dropping the tail of an over-long match that was already
968 // replaced with `OVERLONG_MARKER`. The run is consumed (emitting
969 // nothing) until the next whitespace byte ends it. See the over-long
970 // handling in `process_committed_window`.
971 let mut consuming_overlong = false;
972
973 loop {
974 // Read the next chunk.
975 let bytes_read = read_fully(&mut reader, &mut read_buf)?;
976 let is_eof = bytes_read < read_buf.len();
977
978 // Track only genuinely new bytes (carry was already counted).
979 stats.bytes_processed += bytes_read as u64;
980
981 if bytes_read == 0 && carry.is_empty() {
982 break;
983 }
984
985 // Build the scan window: carry ++ new_data.
986 // Reuse the window buffer to avoid per-chunk allocation.
987 window.clear();
988 window.extend_from_slice(&carry);
989 window.extend_from_slice(&read_buf[..bytes_read]);
990
991 if window.is_empty() {
992 break;
993 }
994
995 // If we are still consuming an over-long redacted run, drop its
996 // bytes until the first whitespace boundary, then resume normal
997 // scanning on whatever follows.
998 if consuming_overlong {
999 if let Some(pos) = window.iter().position(u8::is_ascii_whitespace) {
1000 // Run ends at the first whitespace. Drop the run bytes
1001 // (already represented by the marker) and continue from the
1002 // boundary byte. A non-whitespace run contains no newlines,
1003 // so the newline counter is unchanged.
1004 consuming_overlong = false;
1005 window_file_offset += pos as u64;
1006 window.drain(..pos);
1007 if window.is_empty() {
1008 if is_eof {
1009 break;
1010 }
1011 carry.clear();
1012 continue;
1013 }
1014 // Fall through to normal processing of the remainder.
1015 } else {
1016 // Whole window is still part of the run — drop it all.
1017 window_file_offset += window.len() as u64;
1018 carry.clear();
1019 if is_eof {
1020 break;
1021 }
1022 continue;
1023 }
1024 }
1025
1026 // Scan the window: find matches, determine commit point, apply
1027 // replacements, and flush the committed region to the writer.
1028 let outcome = self.process_committed_window(
1029 &window,
1030 is_eof,
1031 &mut scratch,
1032 &mut writer,
1033 &mut stats,
1034 window_file_offset,
1035 newlines_before_window,
1036 &mut on_match,
1037 )?;
1038 let commit_point = outcome.commit_point;
1039
1040 // Advance file-level position counters for the next iteration.
1041 // window[commit_point] is where the next window's carry starts,
1042 // so that byte is at file offset (window_file_offset + commit_point).
1043 newlines_before_window += count_newlines(&window[..commit_point]);
1044 window_file_offset += commit_point as u64;
1045
1046 // Fold per-chunk pattern hit counts into the cumulative stats map,
1047 // then emit a progress snapshot to the caller.
1048 self.fold_chunk_counts(&mut scratch.pattern_counts, &mut stats);
1049 on_progress(&ScanProgress {
1050 bytes_processed: stats.bytes_processed,
1051 bytes_output: stats.bytes_output,
1052 total_bytes,
1053 matches_found: stats.matches_found,
1054 replacements_applied: stats.replacements_applied,
1055 });
1056
1057 if outcome.overlong {
1058 // The marker was emitted for the run head; drop the rest of this
1059 // window and keep consuming the run in following windows until a
1060 // whitespace boundary is reached. (`overlong` implies `!is_eof`.)
1061 window_file_offset += (window.len() - commit_point) as u64;
1062 consuming_overlong = true;
1063 carry.clear();
1064 continue;
1065 }
1066
1067 // Update carry for next iteration.
1068 if is_eof {
1069 carry.clear();
1070 break;
1071 }
1072 carry.clear();
1073 carry.extend_from_slice(&window[commit_point..]);
1074 }
1075
1076 Ok(stats)
1077 }
1078
1079 /// Scan one window, apply replacements up to the commit point, and flush
1080 /// the result to `writer`. Returns the commit point so the caller can
1081 /// slice the carry for the next iteration.
1082 #[allow(clippy::too_many_arguments)]
1083 fn process_committed_window(
1084 &self,
1085 window: &[u8],
1086 is_eof: bool,
1087 scratch: &mut ScanScratch,
1088 writer: &mut dyn io::Write,
1089 stats: &mut ScanStats,
1090 window_file_offset: u64,
1091 newlines_before_window: u64,
1092 on_match: &mut dyn FnMut(MatchLocation),
1093 ) -> Result<WindowOutcome> {
1094 // Find all non-overlapping matches in the window.
1095 self.find_matches(window, scratch);
1096
1097 // Determine how much of the window can be safely committed this iteration.
1098 let base_commit = if is_eof {
1099 window.len()
1100 } else {
1101 window.len().saturating_sub(self.config.overlap_size)
1102 };
1103 let commit_point =
1104 self.adjusted_commit_point(&scratch.selected, base_commit, window.len(), is_eof);
1105
1106 // If `adjusted_commit_point` pulled the commit back to carry an
1107 // edge-touching match, the carry is `window[commit_point..]`. When that
1108 // carry would exceed `max_carry`, a single match is longer than we are
1109 // willing to hold in memory (a pathological unbroken token larger than a
1110 // chunk). We can neither length-preserve it nor safely commit it, so we
1111 // fail closed: redact the run with a fixed marker. `max_carry` is the
1112 // chunk size, so peak memory stays ≈ `2 * chunk_size + overlap`.
1113 let max_carry = self.config.chunk_size;
1114 let overlong = !is_eof && window.len().saturating_sub(commit_point) > max_carry;
1115
1116 // Build output for the committed region (fills scratch.output).
1117 self.apply_replacements(
1118 &window[..commit_point],
1119 &scratch.selected,
1120 stats,
1121 &mut scratch.output,
1122 &mut scratch.pattern_counts,
1123 window_file_offset,
1124 newlines_before_window,
1125 on_match,
1126 )?;
1127
1128 writer.write_all(&scratch.output)?;
1129 stats.bytes_output += scratch.output.len() as u64;
1130
1131 if overlong {
1132 // The over-long match starts exactly at `commit_point` (it is the
1133 // sole match occupying the window tail) and continues past the
1134 // window edge. Emit one redaction marker for it; the caller drops
1135 // the remaining run bytes across this and following windows.
1136 writer.write_all(OVERLONG_MARKER)?;
1137 stats.bytes_output += OVERLONG_MARKER.len() as u64;
1138 stats.matches_found += 1;
1139 stats.replacements_applied += 1;
1140 on_match(MatchLocation {
1141 line: newlines_before_window + count_newlines(&window[..commit_point]) + 1,
1142 byte_offset: window_file_offset + commit_point as u64,
1143 pattern: OVERLONG_LABEL.to_string(),
1144 });
1145 }
1146
1147 Ok(WindowOutcome {
1148 commit_point,
1149 overlong,
1150 })
1151 }
1152
1153 /// Fold per-chunk pattern hit counts into the cumulative `stats.pattern_counts`
1154 /// map, then reset `counts` to zero for the next chunk.
1155 ///
1156 /// `label.clone()` is called at most once per distinct pattern per chunk,
1157 /// not once per match hit, which keeps cost proportional to pattern count.
1158 fn fold_chunk_counts(&self, counts: &mut [u64], stats: &mut ScanStats) {
1159 for (idx, count) in counts.iter_mut().enumerate() {
1160 if *count > 0 {
1161 *stats
1162 .pattern_counts
1163 .entry(self.patterns[idx].label.clone())
1164 .or_insert(0) += *count;
1165 *count = 0;
1166 }
1167 }
1168 }
1169
1170 /// Convenience: scan byte slice in-memory and return sanitized output.
1171 ///
1172 /// Equivalent to `scan_reader(input, Vec::new())` but returns the
1173 /// output buffer directly.
1174 ///
1175 /// # Errors
1176 ///
1177 /// Returns [`SanitizeError`] if a replacement cannot be generated
1178 /// (e.g. store capacity exceeded).
1179 pub fn scan_bytes(&self, input: &[u8]) -> Result<(Vec<u8>, ScanStats)> {
1180 self.scan_bytes_with_progress(input, |_| {})
1181 }
1182
1183 /// Scan a byte slice in memory and emit progress snapshots.
1184 ///
1185 /// # Errors
1186 ///
1187 /// Returns [`SanitizeError`] if a replacement cannot be generated
1188 /// (e.g. store capacity exceeded).
1189 pub fn scan_bytes_with_progress<F>(
1190 &self,
1191 input: &[u8],
1192 on_progress: F,
1193 ) -> Result<(Vec<u8>, ScanStats)>
1194 where
1195 F: FnMut(&ScanProgress),
1196 {
1197 let mut output = Vec::with_capacity(input.len());
1198 let stats = self.scan_reader_with_callbacks(
1199 input,
1200 &mut output,
1201 Some(input.len() as u64),
1202 on_progress,
1203 |_| {},
1204 )?;
1205 Ok((output, stats))
1206 }
1207
1208 // ---- Accessors ----
1209
1210 /// Access the scanner's configuration.
1211 #[must_use]
1212 pub fn config(&self) -> &ScanConfig {
1213 &self.config
1214 }
1215
1216 /// Access the underlying mapping store.
1217 #[must_use]
1218 pub fn store(&self) -> &Arc<MappingStore> {
1219 &self.store
1220 }
1221
1222 /// Number of patterns registered in this scanner.
1223 #[must_use]
1224 pub fn pattern_count(&self) -> usize {
1225 self.patterns.len()
1226 }
1227
1228 /// Create a scanner from an encrypted secrets file.
1229 ///
1230 /// Decrypts the file in memory, parses the entries, compiles
1231 /// patterns, and returns the scanner ready to scan. Decrypted
1232 /// plaintext is scrubbed from memory after parsing.
1233 ///
1234 /// # Arguments
1235 ///
1236 /// - `encrypted_bytes` — raw bytes of the `.enc` file.
1237 /// - `password` — user password.
1238 /// - `format` — optional format override for the plaintext.
1239 /// - `store` — mapping store for dedup-consistent replacements.
1240 /// - `config` — chunking / overlap configuration.
1241 /// - `extra_patterns` — additional patterns to merge in.
1242 ///
1243 /// # Returns
1244 ///
1245 /// `(scanner, warnings, allow_patterns)` where `warnings` lists entries
1246 /// that failed to compile (index + error) and `allow_patterns` are the
1247 /// raw strings from `kind: allow` entries — pass these to
1248 /// [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher) to
1249 /// suppress replacements for known-safe values.
1250 ///
1251 /// # Errors
1252 ///
1253 /// Returns a secrets-related [`SanitizeError`] on decryption failure
1254 /// or [`SanitizeError::InvalidConfig`] on invalid scanner config.
1255 pub fn from_encrypted_secrets(
1256 encrypted_bytes: &[u8],
1257 password: &str,
1258 format: Option<crate::secrets::SecretsFormat>,
1259 store: Arc<MappingStore>,
1260 config: ScanConfig,
1261 extra_patterns: Vec<ScanPattern>,
1262 ) -> Result<SecretsLoadResult> {
1263 let ((mut patterns, warnings), allow_patterns) =
1264 crate::secrets::load_encrypted_secrets(encrypted_bytes, password, format)?;
1265 patterns.extend(extra_patterns);
1266 let scanner = Self::new(patterns, store, config)?;
1267 Ok(SecretsLoadResult {
1268 scanner,
1269 warnings,
1270 allow_patterns,
1271 })
1272 }
1273
1274 /// Create a scanner from a plaintext secrets file.
1275 ///
1276 /// Convenience for development / testing without encryption.
1277 ///
1278 /// # Returns
1279 ///
1280 /// `(scanner, warnings, allow_patterns)` where `allow_patterns` are the
1281 /// raw strings from `kind: allow` entries — pass these to
1282 /// [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher) to
1283 /// suppress replacements for known-safe values.
1284 ///
1285 /// # Errors
1286 ///
1287 /// Returns a secrets-related [`SanitizeError`] on parse failure
1288 /// or [`SanitizeError::InvalidConfig`] on invalid scanner config.
1289 pub fn from_plaintext_secrets(
1290 plaintext: &[u8],
1291 format: Option<crate::secrets::SecretsFormat>,
1292 store: Arc<MappingStore>,
1293 config: ScanConfig,
1294 extra_patterns: Vec<ScanPattern>,
1295 ) -> Result<SecretsLoadResult> {
1296 let ((mut patterns, warnings), allow_patterns) =
1297 crate::secrets::load_plaintext_secrets(plaintext, format)?;
1298 patterns.extend(extra_patterns);
1299 let scanner = Self::new(patterns, store, config)?;
1300 Ok(SecretsLoadResult {
1301 scanner,
1302 warnings,
1303 allow_patterns,
1304 })
1305 }
1306
1307 // ---- Internal helpers ----
1308
1309 /// Find all non-overlapping matches across all patterns.
1310 ///
1311 /// Fills `scratch.selected` with the winning non-overlapping matches
1312 /// for the given `window`. All three scratch `Vec`s are cleared and
1313 /// repopulated on each call so callers can freely reuse the same
1314 /// `ScanScratch` instance across chunks.
1315 ///
1316 /// ## Strategy
1317 ///
1318 /// 1. **Aho-Corasick** (`aho_corasick`): single O(n) SIMD pass over the
1319 /// window reporting every occurrence of every literal pattern,
1320 /// including overlapping ones. This replaces O(k·n) individual regex
1321 /// scans for the literal subset.
1322 /// 2. **RegexSet pre-filter** (R-3 optimisation): fast check of which
1323 /// *non-literal* regex patterns have any match in the window.
1324 /// 3. **Individual regex `find_iter`**: only for regex patterns flagged
1325 /// by step 2.
1326 /// 4. **Sort + greedy dedup**: all raw matches are sorted by start
1327 /// (ascending), then length (descending), and a single greedy pass
1328 /// selects the final non-overlapping set.
1329 fn find_matches(&self, window: &[u8], scratch: &mut ScanScratch) {
1330 scratch.all_matches.clear();
1331 scratch.selected.clear();
1332
1333 // Step 1: Aho-Corasick overlapping scan for all literal patterns.
1334 // find_overlapping_iter reports every match position including
1335 // overlapping ones, so the sort+greedy step below correctly resolves
1336 // ambiguities between literals (e.g. "abc" vs "abcd" at same offset).
1337 // Literals never have capture groups — capture is always None.
1338 if let Some(ac) = &self.aho_corasick {
1339 for mat in ac.find_overlapping_iter(window) {
1340 let pattern_idx = self.literal_indices[mat.pattern().as_usize()];
1341 if self.length_out_of_bounds(pattern_idx, mat.end() - mat.start()) {
1342 continue;
1343 }
1344 if self.patterns[pattern_idx].word_boundary
1345 && !word_bounded(window, mat.start(), mat.end())
1346 {
1347 continue;
1348 }
1349 scratch.all_matches.push(RawMatch {
1350 start: mat.start(),
1351 end: mat.end(),
1352 pattern_idx,
1353 capture: None,
1354 });
1355 }
1356 }
1357
1358 // Steps 2+3: RegexSet pre-filter then individual scan for non-literal
1359 // patterns. regex_set only contains non-literal pattern strings, so
1360 // literals are never scanned twice.
1361 // Use captures_iter so that patterns with a capture group 1 record
1362 // the sub-range to replace, while patterns without one fall back to
1363 // replacing the full match.
1364 for rs_idx in self.regex_set.matches(window) {
1365 let pattern_idx = self.regex_indices[rs_idx];
1366 for cap in self.patterns[pattern_idx].regex.captures_iter(window) {
1367 let full = cap.get(0).expect("group 0 always exists");
1368 // Drop matches outside the pattern's configured length bounds
1369 // (e.g. a `min_length`/`max_length` from the secrets file). The
1370 // check is on the *match* length, not the window length.
1371 if self.length_out_of_bounds(pattern_idx, full.end() - full.start()) {
1372 continue;
1373 }
1374 let capture = cap.get(1).map(|g| (g.start(), g.end()));
1375 scratch.all_matches.push(RawMatch {
1376 start: full.start(),
1377 end: full.end(),
1378 pattern_idx,
1379 capture,
1380 });
1381 }
1382 }
1383
1384 // Step 4: sort then greedy non-overlapping selection.
1385 // Skip entirely when no matches were found (the common case for
1386 // clean data), avoiding an unnecessary sort of an empty Vec.
1387 if scratch.all_matches.is_empty() {
1388 return;
1389 }
1390
1391 // Primary: start ascending. Secondary: length descending (longer
1392 // match wins when two matches begin at the same position).
1393 scratch.all_matches.sort_unstable_by(|a, b| {
1394 a.start
1395 .cmp(&b.start)
1396 .then_with(|| (b.end - b.start).cmp(&(a.end - a.start)))
1397 });
1398
1399 let mut last_end = 0;
1400 for m in scratch.all_matches.drain(..) {
1401 if m.start >= last_end {
1402 last_end = m.end;
1403 scratch.selected.push(m);
1404 }
1405 }
1406 }
1407
1408 /// Whether a match of `len` bytes falls outside pattern `idx`'s configured
1409 /// min/max length bounds and should be discarded.
1410 #[inline]
1411 fn length_out_of_bounds(&self, idx: usize, len: usize) -> bool {
1412 let p = &self.patterns[idx];
1413 len < p.min_length || len > p.max_length
1414 }
1415
1416 /// Adjust the commit point to avoid splitting a match across the
1417 /// commit / carry boundary.
1418 ///
1419 /// If any match straddles `base_commit` (starts before, ends after),
1420 /// the commit point is moved to after that match so it is emitted
1421 /// in full this iteration.
1422 #[allow(clippy::unused_self)] // keep &self for API consistency with other scanner methods
1423 fn adjusted_commit_point(
1424 &self,
1425 matches: &[RawMatch],
1426 base_commit: usize,
1427 window_len: usize,
1428 is_eof: bool,
1429 ) -> usize {
1430 if is_eof {
1431 return window_len;
1432 }
1433
1434 let mut commit = base_commit;
1435
1436 for m in matches {
1437 if m.start < commit && m.end > commit {
1438 // Match straddles the boundary.
1439 if m.end >= window_len {
1440 // The match runs to the right edge of the window, so it may
1441 // be truncated by the buffer — more of it could arrive in
1442 // the next chunk. Committing it now would emit a replacement
1443 // for a *partial* secret and leak the continuation verbatim
1444 // (the continuation no longer matches the pattern). Pull the
1445 // commit point back to the match start so the whole match is
1446 // carried into the next window and re-scanned with more
1447 // context. `matches` is sorted and non-overlapping, so no
1448 // earlier match ends after `m.start`; lowering `commit` to it
1449 // never strands a partial match in the committed region.
1450 commit = m.start;
1451 break;
1452 }
1453 // Fully contained — safe to commit in this iteration.
1454 commit = m.end;
1455 }
1456 }
1457
1458 // Never exceed window length.
1459 commit.min(window_len)
1460 }
1461
1462 /// Build the output for the committed region by splicing in replacements.
1463 ///
1464 /// Writes into `output_buf` (cleared on entry) and increments
1465 /// `stats.matches_found` / `stats.replacements_applied` for each applied
1466 /// replacement. Per-pattern hit counts are written to `pattern_counts`
1467 /// (indexed by `pattern_idx`); the caller is responsible for folding
1468 /// these into `ScanStats::pattern_counts` and resetting them.
1469 ///
1470 /// `matches` is the full selected set for the window (may include matches
1471 /// in the carry region beyond `committed`). Because `adjusted_commit_point`
1472 /// guarantees no match straddles the boundary, any match with
1473 /// `start < committed.len()` also has `end <= committed.len()`. The
1474 /// loop breaks early once `m.start >= committed.len()` since matches are
1475 /// sorted by start.
1476 ///
1477 /// `window_file_offset` and `newlines_before_window` are used to compute
1478 /// the absolute byte offset and 1-based line number for each committed
1479 /// match, which are delivered to `on_match`. The newline scan is
1480 /// incremental: we scan only the bytes between consecutive matches, not
1481 /// the full committed region.
1482 ///
1483 /// # Note on `from_utf8_lossy`
1484 ///
1485 /// `String::from_utf8_lossy` returns `Cow::Borrowed(&str)` for valid
1486 /// UTF-8 input (the common case for ASCII secrets) — no heap allocation
1487 /// on the hot path.
1488 #[allow(clippy::too_many_arguments)]
1489 fn apply_replacements(
1490 &self,
1491 committed: &[u8],
1492 matches: &[RawMatch],
1493 stats: &mut ScanStats,
1494 output_buf: &mut Vec<u8>,
1495 pattern_counts: &mut [u64],
1496 window_file_offset: u64,
1497 newlines_before_window: u64,
1498 on_match: &mut dyn FnMut(MatchLocation),
1499 ) -> Result<()> {
1500 output_buf.clear();
1501
1502 let mut last_end = 0;
1503 // Running newline count within the committed region, advanced
1504 // incrementally so we only scan the bytes between matches.
1505 let mut newlines_in_committed: u64 = 0;
1506 let mut newline_scan_pos: usize = 0;
1507
1508 for &m in matches {
1509 // Matches are sorted by start; those at or beyond the committed
1510 // region belong to the carry window — stop here.
1511 if m.start >= committed.len() {
1512 break;
1513 }
1514
1515 // Emit bytes before this match verbatim.
1516 output_buf.extend_from_slice(&committed[last_end..m.start]);
1517
1518 // Advance newline counter from previous scan position to match start,
1519 // then emit the match location to the caller.
1520 newlines_in_committed += count_newlines(&committed[newline_scan_pos..m.start]);
1521 newline_scan_pos = m.start;
1522 on_match(MatchLocation {
1523 line: newlines_before_window + newlines_in_committed + 1,
1524 byte_offset: window_file_offset + m.start as u64,
1525 pattern: self.patterns[m.pattern_idx].label.clone(),
1526 });
1527
1528 let pattern = &self.patterns[m.pattern_idx];
1529
1530 // Validate capture bounds before use. This should not happen with
1531 // correct regex patterns; if it does, fail closed by falling back
1532 // to full-match replacement below — never emit the match verbatim.
1533 let capture = m.capture.filter(|&(cap_start, cap_end)| {
1534 cap_start >= m.start && cap_end <= m.end && cap_start <= cap_end
1535 });
1536 if m.capture.is_some() && capture.is_none() {
1537 let (cap_start, cap_end) = m.capture.unwrap_or_default();
1538 tracing::warn!(
1539 pattern = %pattern.label,
1540 m_start = m.start,
1541 m_end = m.end,
1542 cap_start,
1543 cap_end,
1544 "capture group bounds outside match bounds — replacing full match"
1545 );
1546 }
1547
1548 if let Some((cap_start, cap_end)) = capture {
1549 // Pattern has a capture group: replace only the capture group,
1550 // emitting the surrounding context bytes of the full match verbatim.
1551 // This preserves delimiters, key names, and prefixes that the
1552 // pattern uses as anchors to reduce false positives.
1553 output_buf.extend_from_slice(&committed[m.start..cap_start]);
1554 let secret = String::from_utf8_lossy(&committed[cap_start..cap_end]);
1555 let replacement = self.store.get_or_insert(&pattern.category, &secret)?;
1556 output_buf.extend_from_slice(replacement.as_bytes());
1557 output_buf.extend_from_slice(&committed[cap_end..m.end]);
1558 } else {
1559 // No capture group (or invalid capture bounds, failed closed
1560 // above) — replace the full match (e.g. token-prefix patterns
1561 // like `glpat-[...]` where the full match IS the secret).
1562 let matched_text = String::from_utf8_lossy(&committed[m.start..m.end]);
1563 let replacement = self.store.get_or_insert(&pattern.category, &matched_text)?;
1564 output_buf.extend_from_slice(replacement.as_bytes());
1565 }
1566
1567 last_end = m.end;
1568
1569 stats.matches_found += 1;
1570 stats.replacements_applied += 1;
1571 pattern_counts[m.pattern_idx] += 1;
1572 }
1573
1574 // Emit the trailing non-matching tail.
1575 output_buf.extend_from_slice(&committed[last_end..]);
1576
1577 Ok(())
1578 }
1579}
1580
1581// ---------------------------------------------------------------------------
1582// Send + Sync compile-time assertion
1583// ---------------------------------------------------------------------------
1584
1585const _: fn() = || {
1586 fn assert_send<T: Send>() {}
1587 fn assert_sync<T: Sync>() {}
1588 assert_send::<StreamScanner>();
1589 assert_sync::<StreamScanner>();
1590};
1591
1592// ---------------------------------------------------------------------------
1593// I/O helper
1594// ---------------------------------------------------------------------------
1595
1596/// Count the number of `\n` bytes in `data`.
1597///
1598/// Used to advance the cumulative newline counter between consecutive
1599/// match positions so we can compute 1-based line numbers without
1600/// pre-scanning the entire committed region.
1601#[inline]
1602fn count_newlines(data: &[u8]) -> u64 {
1603 bytecount::count(data, b'\n') as u64
1604}
1605
1606/// Read up to `buf.len()` bytes from `reader`, retrying on `Interrupted`.
1607///
1608/// Returns the number of bytes actually read (< `buf.len()` only at EOF).
1609fn read_fully<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
1610 let mut total = 0;
1611 while total < buf.len() {
1612 match reader.read(&mut buf[total..]) {
1613 Ok(0) => break, // EOF
1614 Ok(n) => total += n,
1615 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
1616 Err(e) => return Err(SanitizeError::from(e)),
1617 }
1618 }
1619 Ok(total)
1620}
1621
1622// ---------------------------------------------------------------------------
1623// Unit tests
1624// ---------------------------------------------------------------------------
1625
1626#[cfg(test)]
1627mod tests {
1628 use super::*;
1629 use crate::generator::HmacGenerator;
1630
1631 /// Helper: build a scanner with given patterns and small chunk config.
1632 fn test_scanner(patterns: Vec<ScanPattern>) -> StreamScanner {
1633 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1634 let store = Arc::new(MappingStore::new(gen, None));
1635 StreamScanner::new(
1636 patterns,
1637 store,
1638 ScanConfig {
1639 chunk_size: 64,
1640 overlap_size: 16,
1641 },
1642 )
1643 .unwrap()
1644 }
1645
1646 /// Helper: email pattern.
1647 fn email_pattern() -> ScanPattern {
1648 ScanPattern::from_regex(
1649 r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
1650 Category::Email,
1651 "email",
1652 )
1653 .unwrap()
1654 }
1655
1656 /// Helper: IPv4 pattern.
1657 fn ipv4_pattern() -> ScanPattern {
1658 ScanPattern::from_regex(
1659 r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
1660 Category::IpV4,
1661 "ipv4",
1662 )
1663 .unwrap()
1664 }
1665
1666 // ---- Pattern-count caps (F-05) ----
1667
1668 #[test]
1669 fn pattern_cap_applies_to_regex_patterns_only() {
1670 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1671 let store = Arc::new(MappingStore::new(gen, None));
1672 let config = ScanConfig {
1673 chunk_size: 64,
1674 overlap_size: 16,
1675 };
1676
1677 // Literals far beyond `max_patterns` must compile: they are matched
1678 // by Aho-Corasick, not the RegexSet the cap protects. A profile pass
1679 // over a large input legitimately discovers tens of thousands.
1680 let mut patterns: Vec<ScanPattern> = (0..20)
1681 .map(|i| {
1682 ScanPattern::from_literal(
1683 &format!("discovered-value-{i}"),
1684 Category::AuthToken,
1685 "lit",
1686 )
1687 .unwrap()
1688 })
1689 .collect();
1690 patterns.push(email_pattern());
1691 assert!(StreamScanner::new_with_max_patterns(
1692 patterns,
1693 Arc::clone(&store),
1694 config.clone(),
1695 10
1696 )
1697 .is_ok());
1698
1699 // Regex-bound patterns above the cap are still rejected.
1700 let regexes: Vec<ScanPattern> = (0..11)
1701 .map(|i| {
1702 ScanPattern::from_regex(&format!(r"token{i}-\d+"), Category::AuthToken, "rx")
1703 .unwrap()
1704 })
1705 .collect();
1706 let err = StreamScanner::new_with_max_patterns(regexes, store, config, 10)
1707 .err()
1708 .expect("regex-bound patterns above the cap must be rejected");
1709 assert!(err.to_string().contains("regex pattern count"));
1710 }
1711
1712 // ---- Word boundaries for name-category literals ----
1713
1714 #[test]
1715 fn name_literal_requires_word_boundaries() {
1716 // A seeded login like `admin` must redact standalone occurrences but
1717 // never rewrite the inside of larger words, JSON keys, or path
1718 // segments (admin → Quinn turned `administrators` into
1719 // `Quinnistrators` and `dssadmin` into `dssQuinn`).
1720 let pat = ScanPattern::from_literal("admin", Category::Name, "login").unwrap();
1721 let scanner = test_scanner(vec![pat]);
1722
1723 let input = b"user=admin administrators adminProperties dssadmin (admin)";
1724 let (out, stats) = scanner.scan_bytes(input).unwrap();
1725 let out = String::from_utf8(out).unwrap();
1726
1727 assert_eq!(stats.matches_found, 2, "standalone occurrences only: {out}");
1728 assert!(
1729 !out.contains("user=admin"),
1730 "standalone value replaced: {out}"
1731 );
1732 assert!(
1733 out.contains("administrators") && out.contains("adminProperties"),
1734 "larger words must survive: {out}"
1735 );
1736 assert!(out.contains("dssadmin"), "path segment must survive: {out}");
1737 assert!(!out.contains("(admin)"), "delimited value replaced: {out}");
1738 }
1739
1740 #[test]
1741 fn token_literal_still_matches_inside_larger_strings() {
1742 // Word boundaries are a name-category rule only — a real secret
1743 // embedded in a URL or a longer token must still be caught.
1744 let pat = ScanPattern::from_literal("sekrit12345", Category::AuthToken, "token").unwrap();
1745 let scanner = test_scanner(vec![pat]);
1746 let (out, stats) = scanner
1747 .scan_bytes(b"https://api.example.com/?key=Xsekrit12345Y")
1748 .unwrap();
1749 assert_eq!(stats.matches_found, 1);
1750 assert!(!String::from_utf8(out).unwrap().contains("sekrit12345"));
1751 }
1752
1753 // ---- Fail-closed on invalid capture bounds ----
1754
1755 #[test]
1756 fn invalid_capture_bounds_fail_closed() {
1757 // Capture bounds outside the match bounds should be impossible with
1758 // the regex crate; if they ever occur, the match must be replaced in
1759 // full — never emitted verbatim.
1760 let scanner = test_scanner(vec![email_pattern()]);
1761 let committed = b"user alice@corp.com done";
1762 let m = RawMatch {
1763 start: 5,
1764 end: 19,
1765 pattern_idx: 0,
1766 capture: Some((3, 100)), // deliberately outside match bounds
1767 };
1768 let mut stats = ScanStats::default();
1769 let mut out = Vec::new();
1770 let mut counts = vec![0u64; 1];
1771 scanner
1772 .apply_replacements(
1773 committed,
1774 &[m],
1775 &mut stats,
1776 &mut out,
1777 &mut counts,
1778 0,
1779 0,
1780 &mut |_| {},
1781 )
1782 .unwrap();
1783 let text = String::from_utf8_lossy(&out).into_owned();
1784 assert!(
1785 !text.contains("alice@corp.com"),
1786 "match emitted unreplaced: {text}"
1787 );
1788 assert!(
1789 text.starts_with("user ") && text.ends_with(" done"),
1790 "{text}"
1791 );
1792 assert_eq!(stats.replacements_applied, 1);
1793 assert_eq!(counts[0], 1);
1794 }
1795
1796 // ---- Construction ----
1797
1798 #[test]
1799 fn scanner_creation() {
1800 let scanner = test_scanner(vec![email_pattern()]);
1801 assert_eq!(scanner.pattern_count(), 1);
1802 }
1803
1804 #[test]
1805 fn invalid_config_zero_chunk() {
1806 let gen = Arc::new(HmacGenerator::new([0u8; 32]));
1807 let store = Arc::new(MappingStore::new(gen, None));
1808 let result = StreamScanner::new(vec![], store, ScanConfig::new(0, 0));
1809 assert!(result.is_err());
1810 }
1811
1812 #[test]
1813 fn invalid_config_overlap_ge_chunk() {
1814 let gen = Arc::new(HmacGenerator::new([0u8; 32]));
1815 let store = Arc::new(MappingStore::new(gen, None));
1816 let result = StreamScanner::new(vec![], store, ScanConfig::new(100, 100));
1817 assert!(result.is_err());
1818 }
1819
1820 // ---- Empty / no-match cases ----
1821
1822 #[test]
1823 fn empty_input() {
1824 let scanner = test_scanner(vec![email_pattern()]);
1825 let (output, stats) = scanner.scan_bytes(b"").unwrap();
1826 assert!(output.is_empty());
1827 assert_eq!(stats.matches_found, 0);
1828 assert_eq!(stats.bytes_processed, 0);
1829 }
1830
1831 #[test]
1832 fn no_matches() {
1833 let scanner = test_scanner(vec![email_pattern()]);
1834 let input = b"There are no email addresses here.";
1835 let (output, stats) = scanner.scan_bytes(input).unwrap();
1836 assert_eq!(output, input.as_slice());
1837 assert_eq!(stats.matches_found, 0);
1838 }
1839
1840 // ---- Single match ----
1841
1842 #[test]
1843 fn single_email_replaced() {
1844 let scanner = test_scanner(vec![email_pattern()]);
1845 let input = b"Contact alice@corp.com for help.";
1846 let (output, stats) = scanner.scan_bytes(input).unwrap();
1847 assert_eq!(stats.matches_found, 1);
1848 assert_eq!(stats.replacements_applied, 1);
1849 // Original must not appear in output.
1850 assert!(!output
1851 .windows(b"alice@corp.com".len())
1852 .any(|w| w == b"alice@corp.com"));
1853 // Replacement should contain the @ from the domain-preserving email.
1854 let output_str = String::from_utf8_lossy(&output);
1855 assert!(output_str.contains("@corp.com"));
1856 // Length preserved: output is same total length as input.
1857 assert_eq!(output.len(), input.len(), "length must be preserved");
1858 // Surrounding text preserved.
1859 assert!(output_str.starts_with("Contact "));
1860 assert!(output_str.ends_with(" for help."));
1861 }
1862
1863 // ---- Multiple matches ----
1864
1865 #[test]
1866 fn multiple_emails_replaced() {
1867 let scanner = test_scanner(vec![email_pattern()]);
1868 let input = b"From alice@corp.com to bob@corp.com cc admin@corp.com";
1869 let (output, stats) = scanner.scan_bytes(input).unwrap();
1870 assert_eq!(stats.matches_found, 3);
1871 let out_str = String::from_utf8_lossy(&output);
1872 assert!(!out_str.contains("alice@corp.com"));
1873 assert!(!out_str.contains("bob@corp.com"));
1874 assert!(!out_str.contains("admin@corp.com"));
1875 }
1876
1877 // ---- Same secret gets same replacement ----
1878
1879 #[test]
1880 fn same_secret_same_replacement() {
1881 let scanner = test_scanner(vec![email_pattern()]);
1882 let input = b"First alice@corp.com then alice@corp.com again.";
1883 let (output, stats) = scanner.scan_bytes(input).unwrap();
1884 assert_eq!(stats.matches_found, 2);
1885 let out_str = String::from_utf8_lossy(&output);
1886 // Both occurrences should be replaced with the same value.
1887 // With length-preserving replacements, look for the preserved domain.
1888 let parts: Vec<&str> = out_str.split("@corp.com").collect();
1889 // 3 parts = 2 occurrences of the replacement.
1890 assert_eq!(parts.len(), 3);
1891 }
1892
1893 // ---- Literal pattern ----
1894
1895 #[test]
1896 fn literal_pattern_matched() {
1897 let pat = ScanPattern::from_literal(
1898 "SECRET_API_KEY_12345",
1899 Category::Custom("api_key".into()),
1900 "api_key",
1901 )
1902 .unwrap();
1903 let scanner = test_scanner(vec![pat]);
1904 let input = b"key=SECRET_API_KEY_12345&foo=bar";
1905 let (output, stats) = scanner.scan_bytes(input).unwrap();
1906 assert_eq!(stats.matches_found, 1);
1907 assert!(!output
1908 .windows(b"SECRET_API_KEY_12345".len())
1909 .any(|w| w == b"SECRET_API_KEY_12345"));
1910 }
1911
1912 // ---- Multiple pattern types ----
1913
1914 #[test]
1915 fn multiple_pattern_types() {
1916 let scanner = test_scanner(vec![email_pattern(), ipv4_pattern()]);
1917 let input = b"Server 192.168.1.100 contact admin@server.com";
1918 let (output, stats) = scanner.scan_bytes(input).unwrap();
1919 assert_eq!(stats.matches_found, 2);
1920 let out_str = String::from_utf8_lossy(&output);
1921 assert!(!out_str.contains("192.168.1.100"));
1922 assert!(!out_str.contains("admin@server.com"));
1923 assert_eq!(*stats.pattern_counts.get("email").unwrap(), 1);
1924 assert_eq!(*stats.pattern_counts.get("ipv4").unwrap(), 1);
1925 }
1926
1927 // ---- Chunk boundary: match spans two chunks ----
1928
1929 #[test]
1930 fn match_at_chunk_boundary() {
1931 // Use a very small chunk size so the email straddles a boundary.
1932 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1933 let store = Arc::new(MappingStore::new(gen, None));
1934 let scanner = StreamScanner::new(
1935 vec![email_pattern()],
1936 store,
1937 ScanConfig {
1938 chunk_size: 20, // very small
1939 overlap_size: 16,
1940 },
1941 )
1942 .unwrap();
1943
1944 // Place an email address that will definitely straddle a boundary.
1945 let input = b"AAAAAAAAAAAAAAAA alice@corp.com BBBBBBBBBBBBB";
1946 let (output, stats) = scanner.scan_bytes(input).unwrap();
1947 assert_eq!(stats.matches_found, 1);
1948 let out_str = String::from_utf8_lossy(&output);
1949 assert!(!out_str.contains("alice@corp.com"));
1950 assert!(out_str.contains("@corp.com"), "domain must be preserved");
1951 }
1952
1953 /// Helper: unbounded token pattern (no length cap), like a URL/key body.
1954 fn token_pattern() -> ScanPattern {
1955 ScanPattern::from_regex(r"TOK[A-Za-z0-9]+", Category::AuthToken, "tok").unwrap()
1956 }
1957
1958 // ---- Over-long match boundary leak (regression) ----
1959
1960 #[test]
1961 fn long_match_exceeding_overlap_is_fully_replaced() {
1962 // A token longer than `overlap` (16) but shorter than `chunk` (64) must
1963 // be carried whole across the boundary and replaced exactly once — no
1964 // raw bytes may survive and it must not be split into two mappings.
1965 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1966 let store = Arc::new(MappingStore::new(gen, None));
1967 let scanner = StreamScanner::new(
1968 vec![token_pattern()],
1969 Arc::clone(&store),
1970 ScanConfig {
1971 chunk_size: 64,
1972 overlap_size: 16,
1973 },
1974 )
1975 .unwrap();
1976
1977 let secret = format!("TOK{}", "A".repeat(40)); // 43 bytes > overlap, < chunk
1978 let input = format!("{} {} {}", "X".repeat(30), secret, "Y".repeat(30));
1979 let (output, stats) = scanner.scan_bytes(input.as_bytes()).unwrap();
1980 let out = String::from_utf8_lossy(&output);
1981
1982 assert!(!out.contains("AAAAAAAAAA"), "raw token bytes leaked: {out}");
1983 assert!(!out.contains(&secret), "full token survived: {out}");
1984 assert_eq!(stats.matches_found, 1, "token must match exactly once");
1985 assert_eq!(store.len(), 1, "token must map to a single replacement");
1986 assert_eq!(
1987 output.len(),
1988 input.len(),
1989 "length must be preserved for a non-overlong match"
1990 );
1991 }
1992
1993 #[test]
1994 fn overlong_match_is_redacted_not_leaked() {
1995 // A single token longer than `max_carry` (== chunk_size, 64) cannot be
1996 // buffered; it must be redacted with the marker and leak zero bytes.
1997 let scanner = test_scanner(vec![token_pattern()]); // chunk 64, overlap 16
1998 let secret = format!("TOK{}", "A".repeat(500)); // 503 bytes > chunk
1999 let input = format!("before {secret} after");
2000 let (output, _stats) = scanner.scan_bytes(input.as_bytes()).unwrap();
2001 let out = String::from_utf8_lossy(&output);
2002
2003 assert!(
2004 !out.contains("AAAAAAAAAA"),
2005 "overlong token leaked raw bytes: {out}"
2006 );
2007 assert!(
2008 out.contains(std::str::from_utf8(OVERLONG_MARKER).unwrap()),
2009 "overlong token must be redacted with the marker: {out}"
2010 );
2011 assert!(out.starts_with("before "), "prefix preserved: {out}");
2012 assert!(
2013 out.ends_with(" after"),
2014 "suffix after the run preserved: {out}"
2015 );
2016 }
2017
2018 #[test]
2019 fn overlong_match_running_to_eof_is_redacted() {
2020 // The over-long run never hits a whitespace boundary before EOF — every
2021 // remaining byte must be consumed, leaking nothing.
2022 let scanner = test_scanner(vec![token_pattern()]);
2023 let secret = format!("TOK{}", "A".repeat(500));
2024 let input = format!("before {secret}");
2025 let (output, _stats) = scanner.scan_bytes(input.as_bytes()).unwrap();
2026 let out = String::from_utf8_lossy(&output);
2027
2028 assert!(!out.contains("AAAAAAAAAA"), "leaked to EOF: {out}");
2029 assert!(
2030 out.contains(std::str::from_utf8(OVERLONG_MARKER).unwrap()),
2031 "must redact with marker: {out}"
2032 );
2033 assert!(out.starts_with("before "), "prefix preserved: {out}");
2034 }
2035
2036 #[test]
2037 fn length_bounds_filter_matches() {
2038 // A digit-run pattern bounded to 4..=8 bytes: shorter and longer runs
2039 // pass through untouched; in-range runs are replaced.
2040 let pat = ScanPattern::from_regex(r"[0-9]+", Category::Custom("num".into()), "num")
2041 .unwrap()
2042 .with_length_bounds(4, 8);
2043 let scanner = test_scanner(vec![pat]);
2044
2045 let input = b"a 12 b 1234 c 1234567890 d";
2046 let (output, stats) = scanner.scan_bytes(input).unwrap();
2047 let out = String::from_utf8_lossy(&output);
2048
2049 assert_eq!(stats.matches_found, 1, "only the 4-digit run is in range");
2050 assert!(out.contains("12 "), "short run preserved: {out}");
2051 assert!(out.contains("1234567890"), "long run preserved: {out}");
2052 assert!(!out.contains(" 1234 "), "in-range run replaced: {out}");
2053 assert_eq!(output.len(), input.len(), "length preserved");
2054 }
2055
2056 // ---- Large input requiring many chunks ----
2057
2058 #[test]
2059 fn large_input_many_chunks() {
2060 let scanner = test_scanner(vec![email_pattern()]);
2061
2062 // Build a ~2 KiB input with emails sprinkled in.
2063 let mut input = Vec::new();
2064 let filler = b"Lorem ipsum dolor sit amet. ";
2065 for i in 0..20 {
2066 input.extend_from_slice(filler);
2067 let email = format!("user{}@example.com ", i);
2068 input.extend_from_slice(email.as_bytes());
2069 }
2070
2071 let (output, stats) = scanner.scan_bytes(&input).unwrap();
2072 assert_eq!(stats.matches_found, 20);
2073 let out_str = String::from_utf8_lossy(&output);
2074 for i in 0..20 {
2075 let email = format!("user{}@example.com", i);
2076 assert!(!out_str.contains(&email));
2077 }
2078 }
2079
2080 #[test]
2081 fn scan_bytes_with_progress_preserves_output_and_stats() {
2082 let scanner = test_scanner(vec![email_pattern()]);
2083 let input = b"Contact alice@corp.com and bob@corp.com for help.";
2084
2085 let (baseline_output, baseline_stats) = scanner.scan_bytes(input).unwrap();
2086
2087 let mut updates = Vec::new();
2088 let (progress_output, progress_stats) = scanner
2089 .scan_bytes_with_progress(input, |progress| updates.push(progress.clone()))
2090 .unwrap();
2091
2092 assert_eq!(progress_output, baseline_output);
2093 assert_eq!(
2094 progress_stats.bytes_processed,
2095 baseline_stats.bytes_processed
2096 );
2097 assert_eq!(progress_stats.bytes_output, baseline_stats.bytes_output);
2098 assert_eq!(progress_stats.matches_found, baseline_stats.matches_found);
2099 assert_eq!(
2100 progress_stats.replacements_applied,
2101 baseline_stats.replacements_applied
2102 );
2103 assert!(!updates.is_empty());
2104 assert_eq!(updates.last().unwrap().bytes_processed, input.len() as u64);
2105 assert_eq!(
2106 updates.last().unwrap().total_bytes,
2107 Some(input.len() as u64)
2108 );
2109 assert_eq!(updates.last().unwrap().matches_found, 2);
2110 }
2111
2112 #[test]
2113 fn scan_reader_with_progress_reports_multiple_updates_for_multi_chunk_input() {
2114 let scanner = test_scanner(vec![email_pattern()]);
2115 let mut input = Vec::new();
2116 for i in 0..8 {
2117 input.extend_from_slice(b"padding padding padding ");
2118 input.extend_from_slice(format!("user{i}@example.com ").as_bytes());
2119 }
2120
2121 let mut output = Vec::new();
2122 let mut updates = Vec::new();
2123 let stats = scanner
2124 .scan_reader_with_callbacks(
2125 &input[..],
2126 &mut output,
2127 Some(input.len() as u64),
2128 |progress| {
2129 updates.push(progress.clone());
2130 },
2131 |_| {},
2132 )
2133 .unwrap();
2134
2135 assert!(updates.len() >= 2);
2136 assert_eq!(
2137 updates.last().unwrap().bytes_processed,
2138 stats.bytes_processed
2139 );
2140 assert_eq!(updates.last().unwrap().bytes_output, stats.bytes_output);
2141 assert_eq!(
2142 updates.last().unwrap().total_bytes,
2143 Some(input.len() as u64)
2144 );
2145 }
2146
2147 // ---- Scan via Read/Write interface ----
2148
2149 #[test]
2150 fn scan_reader_writer() {
2151 let scanner = test_scanner(vec![email_pattern()]);
2152 let input = b"hello alice@corp.com world";
2153 let mut output = Vec::new();
2154 let stats = scanner.scan_reader(&input[..], &mut output).unwrap();
2155 assert_eq!(stats.matches_found, 1);
2156 let out_str = String::from_utf8_lossy(&output);
2157 assert!(out_str.contains("@corp.com"), "domain must be preserved");
2158 }
2159
2160 // ---- Pattern compile error ----
2161
2162 #[test]
2163 fn invalid_regex_pattern() {
2164 let result = ScanPattern::from_regex("[invalid(", Category::Email, "bad");
2165 assert!(result.is_err());
2166 }
2167
2168 // ---- Default config ----
2169
2170 #[test]
2171 fn default_config_valid() {
2172 ScanConfig::default().validate().unwrap();
2173 }
2174
2175 // ---- Config edge cases ----
2176
2177 #[test]
2178 fn config_chunk_1_overlap_0() {
2179 // Extreme but valid: 1-byte chunks, no overlap.
2180 // Won't catch multi-byte patterns, but should not crash.
2181 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
2182 let store = Arc::new(MappingStore::new(gen, None));
2183 let scanner = StreamScanner::new(vec![], store, ScanConfig::new(1, 0)).unwrap();
2184 let (output, _) = scanner.scan_bytes(b"hello").unwrap();
2185 assert_eq!(output, b"hello");
2186 }
2187
2188 // ---- ScanStats equality (exercises the PartialEq derive) ----
2189
2190 #[test]
2191 fn scan_stats_equality() {
2192 let scanner = test_scanner(vec![email_pattern()]);
2193 let input = b"hello alice@corp.com world";
2194 let (_, stats_a) = scanner.scan_bytes(input).unwrap();
2195 let (_, stats_b) = scanner.scan_bytes(input).unwrap();
2196 // Identical inputs produce identical stats.
2197 assert_eq!(
2198 stats_a, stats_b,
2199 "identical inputs must produce identical stats"
2200 );
2201 // Values are correct — not just equal to each other.
2202 assert_eq!(stats_a.matches_found, 1, "one email in input");
2203 assert_eq!(stats_a.replacements_applied, 1);
2204 assert_eq!(stats_a.bytes_processed, input.len() as u64);
2205 assert_eq!(*stats_a.pattern_counts.get("email").unwrap_or(&0), 1);
2206 // No-match run produces zeroed counters.
2207 let (_, stats_empty) = scanner.scan_bytes(b"no matches here").unwrap();
2208 assert_ne!(stats_a, stats_empty);
2209 assert_eq!(stats_empty.matches_found, 0);
2210 assert_eq!(stats_empty.replacements_applied, 0);
2211 }
2212
2213 // ---- on_match line number and byte offset accuracy ----
2214
2215 #[test]
2216 fn on_match_reports_correct_line_and_byte_offset() {
2217 // alice@corp.com starts after "line one\n" (9 bytes) → byte 9, line 2.
2218 // bob@corp.com starts after "line one\nalice@corp.com\nline three\n"
2219 // = 9 + 14 + 1 + 10 + 1 = 35 bytes → byte 35, line 4.
2220 let scanner = test_scanner(vec![email_pattern()]);
2221 let input = b"line one\nalice@corp.com\nline three\nbob@corp.com\n";
2222 let mut locations = Vec::new();
2223 let mut output = Vec::new();
2224 scanner
2225 .scan_reader_with_callbacks(
2226 &input[..],
2227 &mut output,
2228 None,
2229 |_| {},
2230 |loc| locations.push(loc),
2231 )
2232 .unwrap();
2233 assert_eq!(locations.len(), 2);
2234 assert_eq!(locations[0].line, 2, "alice must be on line 2");
2235 assert_eq!(locations[0].byte_offset, 9, "alice must start at byte 9");
2236 assert_eq!(locations[1].line, 4, "bob must be on line 4");
2237 assert_eq!(locations[1].byte_offset, 35, "bob must start at byte 35");
2238 }
2239
2240 // ---- Cross-chunk newline accumulation ----
2241
2242 #[test]
2243 fn on_match_line_numbers_stable_across_chunk_sizes() {
2244 // alice@corp.com starts after "line one\n" (9 bytes) → byte 9, line 2.
2245 // bob@corp.com starts after "line one\nalice@corp.com\nline three\n"
2246 // = 9 + 14 + 1 + 10 + 1 = 35 bytes → byte 35, line 4.
2247 // Running the same input through different chunk sizes exercises
2248 // newlines_before_window accumulation across chunk boundaries.
2249 let input = b"line one\nalice@corp.com\nline three\nbob@corp.com\n";
2250 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
2251 let store = Arc::new(MappingStore::new(gen, None));
2252
2253 for chunk_size in [16usize, 20, 24, 32, 64] {
2254 let scanner = StreamScanner::new(
2255 vec![email_pattern()],
2256 Arc::clone(&store),
2257 ScanConfig::new(chunk_size, 14),
2258 )
2259 .unwrap();
2260
2261 let mut locations = Vec::new();
2262 let mut output = Vec::new();
2263 scanner
2264 .scan_reader_with_callbacks(
2265 &input[..],
2266 &mut output,
2267 None,
2268 |_| {},
2269 |loc| locations.push(loc),
2270 )
2271 .unwrap();
2272
2273 assert_eq!(
2274 locations.len(),
2275 2,
2276 "chunk_size={chunk_size}: expected 2 matches"
2277 );
2278 assert_eq!(
2279 locations[0].line, 2,
2280 "chunk_size={chunk_size}: alice must be on line 2"
2281 );
2282 assert_eq!(
2283 locations[0].byte_offset, 9,
2284 "chunk_size={chunk_size}: alice must start at byte 9"
2285 );
2286 assert_eq!(
2287 locations[1].line, 4,
2288 "chunk_size={chunk_size}: bob must be on line 4"
2289 );
2290 assert_eq!(
2291 locations[1].byte_offset, 35,
2292 "chunk_size={chunk_size}: bob must start at byte 35"
2293 );
2294 }
2295 }
2296
2297 // ---- Bytes output tracking ----
2298
2299 #[test]
2300 fn bytes_output_preserved_on_replacement() {
2301 let scanner = test_scanner(vec![email_pattern()]);
2302 let input = b"a@b.cc"; // short email
2303 let (output, stats) = scanner.scan_bytes(input).unwrap();
2304 assert_eq!(stats.bytes_processed, input.len() as u64);
2305 assert_eq!(stats.bytes_output, output.len() as u64);
2306 // Length-preserving: output length matches input length.
2307 assert_eq!(output.len(), input.len());
2308 }
2309
2310 #[test]
2311 fn randomized_length_decorrelates_numeric_output() {
2312 use crate::generator::{LengthPolicy, RandomGenerator};
2313 // A short numeric value under Randomized is replaced by a band-length
2314 // digit run (8..=18 digits), so the output no longer matches the input
2315 // length and bytes_output diverges from bytes_processed.
2316 let gen = Arc::new(RandomGenerator::new().with_length_policy(LengthPolicy::Randomized));
2317 let store = Arc::new(MappingStore::new(gen, None));
2318 let pat = ScanPattern::from_regex(r"\b\d{4,}\b", Category::Phone, "num").unwrap();
2319 let scanner = StreamScanner::new(
2320 vec![pat],
2321 store,
2322 ScanConfig {
2323 chunk_size: 64,
2324 overlap_size: 16,
2325 },
2326 )
2327 .unwrap();
2328 let input = b"id=123456 end"; // the value "123456" is 6 digits
2329 let (output, stats) = scanner.scan_bytes(input).unwrap();
2330 let out = String::from_utf8(output).unwrap();
2331 assert!(!out.contains("123456"), "value must be replaced: {out}");
2332 assert_eq!(stats.replacements_applied, 1);
2333 assert_eq!(stats.bytes_output, out.len() as u64);
2334 assert!(
2335 stats.bytes_output > stats.bytes_processed,
2336 "randomized replacement (>=8 digits) must lengthen a 6-digit value: \
2337 processed={} output={}",
2338 stats.bytes_processed,
2339 stats.bytes_output
2340 );
2341 }
2342}