Skip to main content

rust_sanitize/
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` (adjusted
33//!    upward if a match straddles the boundary).
34//! 5. Emit output for `window[..commit_point]` with replacements applied.
35//! 6. Set `carry = window[commit_point..]` for the next iteration.
36//!
37//! The `overlap_size` should be ≥ the maximum expected match length to
38//! guarantee no matches are missed at boundaries.
39//!
40//! # Thread Safety
41//!
42//! [`StreamScanner`] is `Send + Sync`. Multiple files can be scanned
43//! concurrently using a shared `Arc<StreamScanner>`, all backed by the
44//! same [`MappingStore`] for per-run dedup
45//! consistency.
46//!
47//! # Performance
48//!
49//! - **Chunk-based I/O**: only `chunk_size + overlap_size` bytes in
50//!   memory per active scan.
51//! - **Compiled regex**: patterns are compiled once at construction and
52//!   reused across all chunks and files.
53//! - **Lock-free reads**: the `DashMap` inside `MappingStore` provides
54//!   lock-free reads for already-seen values.
55//! - **File-level parallelism**: share `Arc<StreamScanner>` across
56//!   threads to scan multiple files concurrently.
57
58use crate::category::Category;
59use crate::error::{Result, SanitizeError};
60use crate::store::MappingStore;
61use aho_corasick::AhoCorasick;
62use regex::bytes::{Regex, RegexBuilder, RegexSet, RegexSetBuilder};
63use serde::Serialize;
64use std::collections::HashMap;
65use std::io::{self, Read, Write};
66use std::sync::Arc;
67
68// ---------------------------------------------------------------------------
69// Configuration
70// ---------------------------------------------------------------------------
71
72/// Default chunk size: 1 MiB.
73const DEFAULT_CHUNK_SIZE: usize = 1024 * 1024;
74
75/// Default overlap size: 4 KiB.
76const DEFAULT_OVERLAP_SIZE: usize = 4096;
77
78/// Maximum compiled regex automaton size (bytes). Prevents DoS via
79/// pathologically complex user-supplied patterns.
80const REGEX_SIZE_LIMIT: usize = 1 << 20; // 1 MiB
81
82/// Maximum DFA cache size (bytes) per regex.
83const REGEX_DFA_SIZE_LIMIT: usize = 1 << 20; // 1 MiB
84
85/// Hard ceiling on the combined RegexSet automaton budget.
86/// The per-pattern limit is multiplied by the pattern count so that a large
87/// pattern set can still compile, but without this cap a pathological secrets
88/// file with 10 000 patterns could claim up to ~20 GiB of automaton memory.
89const REGEX_SET_SIZE_CAP: usize = 256 * 1024 * 1024; // 256 MiB
90
91/// Maximum number of patterns allowed in a single scanner (F-05 fix).
92/// The `RegexSet` automaton memory scales linearly with pattern count.
93/// With 1 MiB size/DFA limits per pattern, 10 000 patterns could
94/// allocate up to ~20 GiB of automaton memory.  This cap prevents
95/// accidental resource exhaustion.  Override via
96/// [`StreamScanner::new_with_max_patterns`] if needed.
97const DEFAULT_MAX_PATTERNS: usize = 10_000;
98
99/// Label suffix that marks patterns as key-value-only.
100///
101/// Patterns whose label ends with this suffix are excluded from the streaming
102/// scanner pass (`for_structured_pass`) because the key-value processor
103/// resolves their values structurally and the scanner would produce spurious
104/// duplicate replacements on the surrounding syntax.
105pub const KV_LABEL_SUFFIX: &str = "_kv";
106
107/// Configuration for the streaming scanner.
108///
109/// # Tuning Guide
110///
111/// | Workload               | `chunk_size` | `overlap_size` |
112/// |------------------------|--------------|----------------|
113/// | Small files (< 10 MB)  | 256 KiB      | 1 KiB          |
114/// | General purpose        | 1 MiB        | 4 KiB          |
115/// | Large files (> 1 GB)   | 4–8 MiB      | 8 KiB          |
116/// | Memory-constrained     | 64 KiB       | 1 KiB          |
117///
118/// `overlap_size` should be ≥ the longest expected match. Most secret
119/// patterns (API keys, emails, SSNs) are well under 256 bytes, so the
120/// 4 KiB default provides ample margin.
121#[derive(Debug, Clone)]
122pub struct ScanConfig {
123    /// Size of each chunk read from the input (bytes).
124    ///
125    /// Larger chunks improve throughput (fewer syscalls) but use more
126    /// memory. Default: 1 MiB.
127    pub chunk_size: usize,
128
129    /// Overlap between consecutive chunks (bytes).
130    ///
131    /// Must be ≥ the maximum expected match length. Patterns whose
132    /// matches can exceed this length risk being missed at chunk
133    /// boundaries. Default: 4 KiB.
134    pub overlap_size: usize,
135}
136
137impl Default for ScanConfig {
138    fn default() -> Self {
139        Self {
140            chunk_size: DEFAULT_CHUNK_SIZE,
141            overlap_size: DEFAULT_OVERLAP_SIZE,
142        }
143    }
144}
145
146impl ScanConfig {
147    /// Create a new configuration with explicit values.
148    #[must_use]
149    pub fn new(chunk_size: usize, overlap_size: usize) -> Self {
150        Self {
151            chunk_size,
152            overlap_size,
153        }
154    }
155
156    /// Validate the configuration, returning an error if invalid.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`SanitizeError::InvalidConfig`] if `chunk_size` is zero
161    /// or `overlap_size >= chunk_size`.
162    pub fn validate(&self) -> Result<()> {
163        if self.chunk_size == 0 {
164            return Err(SanitizeError::InvalidConfig(
165                "chunk_size must be > 0".into(),
166            ));
167        }
168        if self.overlap_size >= self.chunk_size {
169            return Err(SanitizeError::InvalidConfig(
170                "overlap_size must be < chunk_size".into(),
171            ));
172        }
173        Ok(())
174    }
175}
176
177// ---------------------------------------------------------------------------
178// Internal helpers
179// ---------------------------------------------------------------------------
180
181/// Convert any compile-time pattern error into [`SanitizeError::PatternCompileError`].
182#[inline]
183fn compile_err(e: impl std::fmt::Display) -> SanitizeError {
184    SanitizeError::PatternCompileError(e.to_string())
185}
186
187// ---------------------------------------------------------------------------
188// Scan pattern
189// ---------------------------------------------------------------------------
190
191/// A pattern rule defining what to scan for and how to categorize matches.
192///
193/// Wraps a compiled [`regex::bytes::Regex`] with a [`Category`] for
194/// replacement lookups and a human-readable label for reporting.
195///
196/// Both regex and literal patterns are supported. Literal patterns keep
197/// their original text and are matched by the scanner's Aho-Corasick
198/// automaton for fast multi-literal scanning.
199pub struct ScanPattern {
200    /// Compiled regex matcher (used for non-literal patterns and as a
201    /// fallback; literal patterns are matched via Aho-Corasick instead).
202    regex: Regex,
203    /// Category for replacement lookups.
204    category: Category,
205    /// Human-readable label for reporting / stats.
206    label: String,
207    /// Original (unescaped) literal string when created via `from_literal`.
208    /// `None` for patterns created via `from_regex`.
209    /// Stored so `StreamScanner` can build an Aho-Corasick automaton for
210    /// fast SIMD literal matching instead of running the regex engine.
211    literal: Option<String>,
212    /// Minimum window size (bytes) required to produce a match.
213    /// For literal patterns this equals the byte length of the literal itself.
214    /// For regex patterns this is `0` (no guaranteed minimum).
215    /// Used to skip `captures_iter` when the window is provably too short.
216    pub min_length: usize,
217}
218
219impl std::fmt::Debug for ScanPattern {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct("ScanPattern")
222            .field("pattern", &self.regex.as_str())
223            .field("category", &self.category)
224            .field("label", &self.label)
225            .field("literal", &self.literal.as_deref())
226            .field("min_length", &self.min_length)
227            .finish()
228    }
229}
230
231impl Clone for ScanPattern {
232    fn clone(&self) -> Self {
233        Self {
234            regex: self.regex.clone(),
235            category: self.category.clone(),
236            label: self.label.clone(),
237            literal: self.literal.clone(),
238            min_length: self.min_length,
239        }
240    }
241}
242
243impl ScanPattern {
244    /// Create a pattern from a regex string.
245    ///
246    /// ## Capture group 1 — partial replacement
247    ///
248    /// If the regex contains a capture group 1 (`(...)`), only the bytes
249    /// matched by that group are replaced; the bytes before and after it
250    /// within the full match are emitted verbatim. This lets you write
251    /// context-anchored patterns without redacting the prefix/suffix:
252    ///
253    /// ```text
254    /// pattern: glpat-([A-Za-z0-9_-]{20})
255    ///           ^^^^^^ prefix preserved
256    ///                  ^^^^^^^^^^^^^^^^^^^^ group 1 → replaced
257    /// ```
258    ///
259    /// Patterns **without** a capture group replace the entire match.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`SanitizeError::PatternCompileError`] if the regex is invalid.
264    ///
265    /// # Examples
266    ///
267    /// ```
268    /// use rust_sanitize::scanner::ScanPattern;
269    /// use rust_sanitize::category::Category;
270    ///
271    /// // No capture group — full match replaced:
272    /// let email = ScanPattern::from_regex(
273    ///     r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
274    ///     Category::Email,
275    ///     "email_address",
276    /// ).unwrap();
277    ///
278    /// // Capture group 1 — prefix preserved, only the token value replaced:
279    /// let token = ScanPattern::from_regex(
280    ///     r"glpat-([A-Za-z0-9_-]{20})",
281    ///     Category::AuthToken,
282    ///     "gitlab_pat",
283    /// ).unwrap();
284    /// ```
285    pub fn from_regex(pattern: &str, category: Category, label: impl Into<String>) -> Result<Self> {
286        let regex = RegexBuilder::new(pattern)
287            .size_limit(REGEX_SIZE_LIMIT)
288            .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
289            .build()
290            .map_err(compile_err)?;
291        Ok(Self {
292            regex,
293            category,
294            label: label.into(),
295            literal: None,
296            min_length: 0,
297        })
298    }
299
300    /// Create a pattern from a literal string.
301    ///
302    /// The literal is escaped so that regex metacharacters are matched
303    /// verbatim.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`SanitizeError::PatternCompileError`] if regex compilation fails.
308    ///
309    /// # Examples
310    ///
311    /// ```
312    /// use rust_sanitize::scanner::ScanPattern;
313    /// use rust_sanitize::category::Category;
314    ///
315    /// let pat = ScanPattern::from_literal(
316    ///     "sk-proj-abc123secret",
317    ///     Category::Custom("api_key".into()),
318    ///     "openai_key",
319    /// ).unwrap();
320    /// ```
321    pub fn from_literal(
322        literal: &str,
323        category: Category,
324        label: impl Into<String>,
325    ) -> Result<Self> {
326        let escaped = regex::escape(literal);
327        let regex = RegexBuilder::new(&escaped)
328            .size_limit(REGEX_SIZE_LIMIT)
329            .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
330            .build()
331            .map_err(compile_err)?;
332        Ok(Self {
333            regex,
334            category,
335            label: label.into(),
336            min_length: literal.len(),
337            literal: Some(literal.to_owned()),
338        })
339    }
340
341    /// The category this pattern maps to.
342    #[must_use]
343    pub fn category(&self) -> &Category {
344        &self.category
345    }
346
347    /// The human-readable label.
348    #[must_use]
349    pub fn label(&self) -> &str {
350        &self.label
351    }
352
353    /// Return the raw regex pattern string for RegexSet construction.
354    #[must_use]
355    pub fn regex_pattern(&self) -> &str {
356        self.regex.as_str()
357    }
358}
359
360// ScanPattern is Send + Sync because:
361// - regex::bytes::Regex is Send + Sync
362// - Category is Send + Sync (it's an enum of primitives + CompactString)
363// - String is Send + Sync
364
365// ---------------------------------------------------------------------------
366// Internal: raw match descriptor
367// ---------------------------------------------------------------------------
368
369/// A single match found during scanning (internal).
370#[derive(Debug, Clone, Copy)]
371struct RawMatch {
372    /// Start byte offset within the scan window.
373    start: usize,
374    /// End byte offset (exclusive) within the scan window.
375    end: usize,
376    /// Index into the `StreamScanner::patterns` vector.
377    pattern_idx: usize,
378    /// Byte range of capture group 1 within the window, if the pattern has one.
379    /// When present, only this sub-range is replaced; the bytes between
380    /// `start..capture_start` and `capture_end..end` are emitted verbatim,
381    /// preserving surrounding context (delimiters, key names, prefixes).
382    capture: Option<(usize, usize)>,
383}
384
385// ---------------------------------------------------------------------------
386// Per-scan scratch buffers
387// ---------------------------------------------------------------------------
388
389/// Scratch buffers reused across chunks within a single scan call.
390///
391/// Allocating these once per `scan_reader_with_progress` invocation
392/// and reusing them each chunk eliminates the per-chunk heap pressure
393/// that would otherwise come from `Vec` allocations in `find_matches`
394/// and `apply_replacements`.
395struct ScanScratch {
396    /// Accumulates raw matches from all patterns before deduplication.
397    all_matches: Vec<RawMatch>,
398    /// Non-overlapping matches selected for the current window
399    /// (populated by `find_matches`, consumed by `apply_replacements`).
400    selected: Vec<RawMatch>,
401    /// Output bytes for the committed region, written by `apply_replacements`.
402    output: Vec<u8>,
403    /// Per-pattern match counts indexed by `pattern_idx`.
404    /// Reset to zero after each chunk's counts are folded into `ScanStats`.
405    pattern_counts: Vec<u64>,
406}
407
408impl ScanScratch {
409    fn new(pattern_count: usize, chunk_size: usize, overlap_size: usize) -> Self {
410        Self {
411            all_matches: Vec::with_capacity(64),
412            selected: Vec::with_capacity(64),
413            output: Vec::with_capacity(chunk_size + overlap_size),
414            pattern_counts: vec![0u64; pattern_count],
415        }
416    }
417}
418
419// ---------------------------------------------------------------------------
420// Scan statistics
421// ---------------------------------------------------------------------------
422
423/// The file-level position of a single scanner match.
424///
425/// Emitted via the `on_match` callback in
426/// [`StreamScanner::scan_reader_with_callbacks`]. Line numbers are
427/// 1-based and count `\n` bytes only (Unix line endings). For files with
428/// Windows line endings (`\r\n`), `line` is still correct because `\n` is
429/// the canonical line separator — `\r` bytes do not affect the count.
430///
431/// `byte_offset` is the absolute byte position of the first byte of the
432/// matched region within the file (0-based). Both fields refer to the
433/// *input* file, not the sanitized output.
434#[derive(Debug, Clone, Serialize)]
435pub struct MatchLocation {
436    /// 1-based line number of the match within the file.
437    pub line: u64,
438    /// 0-based byte offset of the match start within the file.
439    pub byte_offset: u64,
440    /// Pattern label that triggered this match.
441    pub pattern: String,
442}
443
444/// Statistics collected during a scan operation.
445///
446/// Returned by [`StreamScanner::scan_reader`] and
447/// [`StreamScanner::scan_bytes`] to provide visibility into what
448/// the scanner did.
449#[derive(Debug, Clone, Default, PartialEq)]
450pub struct ScanStats {
451    /// Total bytes read from the input.
452    pub bytes_processed: u64,
453    /// Total bytes written to the output.
454    ///
455    /// Always equals `bytes_processed` for this engine because all
456    /// replacements are length-preserving by design.
457    pub bytes_output: u64,
458    /// Total number of matches found across all patterns.
459    pub matches_found: u64,
460    /// Total number of replacements applied (always == `matches_found`
461    /// in one-way mode).
462    pub replacements_applied: u64,
463    /// Per-pattern match counts, keyed by pattern label.
464    pub pattern_counts: HashMap<String, u64>,
465}
466
467/// Progress snapshot emitted during streaming scans.
468#[derive(Debug, Clone, Default, Eq, PartialEq)]
469pub struct ScanProgress {
470    /// Total bytes read from the input so far.
471    pub bytes_processed: u64,
472    /// Total bytes written to the output so far.
473    pub bytes_output: u64,
474    /// Total input size when known.
475    pub total_bytes: Option<u64>,
476    /// Total number of matches found so far.
477    pub matches_found: u64,
478    /// Total replacements applied so far.
479    pub replacements_applied: u64,
480}
481
482// ---------------------------------------------------------------------------
483// StreamScanner
484// ---------------------------------------------------------------------------
485
486/// Streaming scanner that detects and replaces sensitive patterns.
487///
488/// Thread-safe: can be shared via `Arc<StreamScanner>` for concurrent
489/// scanning of multiple files. Each call to [`scan_reader`](Self::scan_reader)
490/// is independent and maintains its own chunking state.
491///
492/// # Usage
493///
494/// ```rust
495/// use rust_sanitize::scanner::{StreamScanner, ScanPattern, ScanConfig};
496/// use rust_sanitize::category::Category;
497/// use rust_sanitize::generator::HmacGenerator;
498/// use rust_sanitize::store::MappingStore;
499/// use std::sync::Arc;
500///
501/// // 1. Build the replacement store.
502/// let gen = Arc::new(HmacGenerator::new([42u8; 32]));
503/// let store = Arc::new(MappingStore::new(gen, None));
504///
505/// // 2. Define patterns.
506/// let patterns = vec![
507///     ScanPattern::from_regex(
508///         r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
509///         Category::Email,
510///         "email",
511///     ).unwrap(),
512/// ];
513///
514/// // 3. Create the scanner.
515/// let scanner = StreamScanner::new(patterns, store, ScanConfig::default()).unwrap();
516///
517/// // 4. Scan.
518/// let input = b"Contact alice@corp.com for details.";
519/// let (output, stats) = scanner.scan_bytes(input).unwrap();
520/// assert_eq!(stats.matches_found, 1);
521/// assert!(!output.windows(b"alice@corp.com".len())
522///     .any(|w| w == b"alice@corp.com"));
523/// ```
524pub struct StreamScanner {
525    /// Compiled scan patterns (both literal and regex).
526    patterns: Vec<ScanPattern>,
527    /// Pre-compiled set for fast multi-pattern pre-filtering of **regex**
528    /// (non-literal) patterns only.  `matches()` returns which regex-pattern
529    /// indices matched, avoiding running every individual regex on each chunk
530    /// (R-3 optimisation).
531    regex_set: RegexSet,
532    /// Maps a `RegexSet` index → index into `self.patterns`.
533    /// Only non-literal patterns are in the `RegexSet`.
534    regex_indices: Vec<usize>,
535    /// Aho-Corasick automaton for fast SIMD literal matching.
536    /// `None` when there are no literal patterns.
537    aho_corasick: Option<AhoCorasick>,
538    /// Maps an Aho-Corasick pattern index → index into `self.patterns`.
539    /// Only literal patterns appear here.
540    literal_indices: Vec<usize>,
541    /// Thread-safe dedup replacement store.
542    store: Arc<MappingStore>,
543    /// Scanner configuration.
544    config: ScanConfig,
545}
546
547/// Result of loading a secrets file into a [`StreamScanner`].
548///
549/// Returned by [`StreamScanner::from_encrypted_secrets`] and
550/// [`StreamScanner::from_plaintext_secrets`].
551///
552/// The `#[must_use]` attribute guards against silently discarding
553/// `allow_patterns`, which would cause values that should be suppressed
554/// to be sanitized instead.
555///
556/// # Example
557///
558/// ```rust,ignore
559/// let SecretsLoadResult { scanner, warnings, allow_patterns } =
560///     StreamScanner::from_plaintext_secrets(bytes, None, store, config, vec![])?;
561///
562/// for (idx, err) in &warnings {
563///     eprintln!("secrets entry {idx} failed to compile: {err}");
564/// }
565///
566/// let (allowlist, al_warnings) = AllowlistMatcher::new(allow_patterns).into_parts();
567/// let store = MappingStore::new_with_allowlist(gen, None, Arc::new(allowlist));
568/// ```
569#[must_use = "use allow_patterns to build an AllowlistMatcher; check warnings for skipped patterns"]
570pub struct SecretsLoadResult {
571    /// The compiled scanner, ready to use.
572    pub scanner: StreamScanner,
573    /// Secrets-file entries that failed pattern compilation:
574    /// `(index_in_file, error)`. A non-empty list means some patterns were
575    /// silently skipped and the scanner covers less than the full file.
576    pub warnings: Vec<(usize, SanitizeError)>,
577    /// Raw strings from `kind: allow` entries in the secrets file.
578    /// Pass these to [`crate::allowlist::AllowlistMatcher::new`] and attach
579    /// the resulting matcher to a [`crate::store::MappingStore`] via
580    /// [`crate::store::MappingStore::new_with_allowlist`].
581    pub allow_patterns: Vec<String>,
582}
583
584impl StreamScanner {
585    /// Create a new streaming scanner.
586    ///
587    /// # Arguments
588    ///
589    /// - `patterns` — the set of patterns to scan for.
590    /// - `store` — the mapping store for dedup-consistent replacements.
591    /// - `config` — chunking / overlap configuration.
592    ///
593    /// # Errors
594    ///
595    /// Returns [`SanitizeError::InvalidConfig`] if the configuration is
596    /// invalid (e.g. `chunk_size == 0` or `overlap_size >= chunk_size`).
597    pub fn new(
598        patterns: Vec<ScanPattern>,
599        store: Arc<MappingStore>,
600        config: ScanConfig,
601    ) -> Result<Self> {
602        Self::new_with_max_patterns(patterns, store, config, DEFAULT_MAX_PATTERNS)
603    }
604
605    /// Create a new streaming scanner with a custom pattern limit.
606    ///
607    /// This is identical to [`new`](Self::new) but allows overriding the
608    /// default pattern cap (10 000).  Use this
609    /// when you have a legitimate need for more patterns and have
610    /// verified that your system has enough memory for the resulting
611    /// `RegexSet`.
612    ///
613    /// # Errors
614    ///
615    /// Returns [`SanitizeError::InvalidConfig`] if the configuration is
616    /// invalid or the pattern count exceeds `max_patterns`.
617    pub fn new_with_max_patterns(
618        patterns: Vec<ScanPattern>,
619        store: Arc<MappingStore>,
620        config: ScanConfig,
621        max_patterns: usize,
622    ) -> Result<Self> {
623        config.validate()?;
624
625        // F-05 fix: enforce maximum pattern count to bound RegexSet memory.
626        if patterns.len() > max_patterns {
627            return Err(SanitizeError::InvalidConfig(format!(
628                "pattern count ({}) exceeds maximum allowed ({}) — \
629                 RegexSet memory scales linearly with pattern count",
630                patterns.len(),
631                max_patterns
632            )));
633        }
634
635        // Partition patterns into literal (Aho-Corasick) and regex (RegexSet)
636        // so each is matched by the most efficient engine.
637        let mut literal_bytes: Vec<Vec<u8>> = Vec::new();
638        let mut literal_indices: Vec<usize> = Vec::new();
639        let mut regex_strs: Vec<&str> = Vec::new();
640        let mut regex_indices: Vec<usize> = Vec::new();
641
642        for (i, pattern) in patterns.iter().enumerate() {
643            if let Some(lit) = &pattern.literal {
644                literal_bytes.push(lit.as_bytes().to_vec());
645                literal_indices.push(i);
646            } else {
647                regex_strs.push(pattern.regex_pattern());
648                regex_indices.push(i);
649            }
650        }
651
652        // Build Aho-Corasick automaton for literal patterns (SIMD-accelerated,
653        // single O(n) pass over the input per chunk).
654        let aho_corasick = if literal_bytes.is_empty() {
655            None
656        } else {
657            Some(AhoCorasick::new(&literal_bytes).map_err(compile_err)?)
658        };
659
660        // Build RegexSet from non-literal patterns only (R-3 pre-filter).
661        let regex_set = if regex_strs.is_empty() {
662            RegexSetBuilder::new(Vec::<&str>::new())
663                .size_limit(REGEX_SIZE_LIMIT)
664                .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
665                .build()
666                .map_err(compile_err)?
667        } else {
668            RegexSetBuilder::new(&regex_strs)
669                .size_limit((REGEX_SIZE_LIMIT * regex_strs.len().max(1)).min(REGEX_SET_SIZE_CAP))
670                .dfa_size_limit(
671                    (REGEX_DFA_SIZE_LIMIT * regex_strs.len().max(1)).min(REGEX_SET_SIZE_CAP),
672                )
673                .build()
674                .map_err(compile_err)?
675        };
676
677        Ok(Self {
678            patterns,
679            regex_set,
680            regex_indices,
681            aho_corasick,
682            literal_indices,
683            store,
684            config,
685        })
686    }
687
688    /// Create a copy of this scanner extended with additional literal patterns.
689    ///
690    /// Clones the existing pattern set and appends `extra`, then rebuilds
691    /// the internal Aho-Corasick and RegexSet automata. Used by the
692    /// format-preserving structured pass to scan original bytes with
693    /// discovered field-value literals added to the base pattern set.
694    ///
695    /// # Errors
696    ///
697    /// Returns [`SanitizeError`] if automaton construction fails or the
698    /// combined pattern count exceeds the default limit.
699    pub fn with_extra_literals(&self, extra: Vec<ScanPattern>) -> Result<Self> {
700        let mut patterns = self.patterns.clone();
701        patterns.extend(extra);
702        Self::new(patterns, Arc::clone(&self.store), self.config.clone())
703    }
704
705    /// Build a scanner suitable for format-preserving structured-file passes.
706    ///
707    /// Patterns whose labels end with `"_kv"` are excluded from the base set.
708    /// Those patterns match both a key name and its value (e.g. `password: s3cr3t`)
709    /// as a single unit; in a structured pass the key must survive untouched so
710    /// only the discovered field-value literals are safe to replace.
711    ///
712    /// `extra` (the profile-discovered literals) are always included.
713    ///
714    /// # Errors
715    ///
716    /// Returns [`SanitizeError`] if Aho-Corasick or RegexSet construction fails
717    /// or the combined pattern count exceeds the default limit.
718    pub fn for_structured_pass(&self, extra: Vec<ScanPattern>) -> Result<Self> {
719        let mut patterns: Vec<ScanPattern> = self
720            .patterns
721            .iter()
722            .filter(|p| !p.label.ends_with(KV_LABEL_SUFFIX))
723            .cloned()
724            .collect();
725        patterns.extend(extra);
726        Self::new(patterns, Arc::clone(&self.store), self.config.clone())
727    }
728
729    /// Scan a reader and write sanitized output to a writer.
730    ///
731    /// Processes the input in chunks of `config.chunk_size` bytes,
732    /// maintaining an overlap window of `config.overlap_size` bytes to
733    /// catch matches spanning chunk boundaries. All detected matches
734    /// are replaced one-way via the [`MappingStore`].
735    ///
736    /// # Arguments
737    ///
738    /// - `reader` — input source (file, network stream, `&[u8]`, …).
739    /// - `writer` — output sink (file, `Vec<u8>`, …).
740    ///
741    /// # Returns
742    ///
743    /// [`ScanStats`] with counters for bytes processed, matches found, etc.
744    ///
745    /// # Errors
746    ///
747    /// Returns [`SanitizeError`] on I/O failures or if a replacement
748    /// cannot be generated (e.g. store capacity exceeded).
749    pub fn scan_reader<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ScanStats> {
750        self.scan_reader_with_callbacks(reader, writer, None, |_| {}, |_| {})
751    }
752
753    /// Scan a reader and emit progress snapshots after each committed chunk.
754    ///
755    /// `total_bytes` should be provided when the caller knows the full input
756    /// size. When omitted, progress consumers should avoid percentages/ETA.
757    ///
758    /// This is a convenience wrapper around [`scan_reader_with_callbacks`](Self::scan_reader_with_callbacks)
759    /// that discards per-match location information. Use that method directly
760    /// when you need line numbers or byte offsets for individual matches.
761    ///
762    /// # Errors
763    ///
764    /// Returns [`SanitizeError`] on I/O failures or if a replacement
765    /// cannot be generated (e.g. store capacity exceeded).
766    pub fn scan_reader_with_progress<R: Read, W: Write, F>(
767        &self,
768        reader: R,
769        writer: W,
770        total_bytes: Option<u64>,
771        on_progress: F,
772    ) -> Result<ScanStats>
773    where
774        F: FnMut(&ScanProgress),
775    {
776        self.scan_reader_with_callbacks(reader, writer, total_bytes, on_progress, |_| {})
777    }
778
779    /// Scan a reader, emit progress snapshots, and call `on_match` for every
780    /// committed match with its 1-based line number and byte offset.
781    ///
782    /// `on_match` is called synchronously in the scanning thread, once per
783    /// committed match, in document order. The callback receives a
784    /// [`MatchLocation`] describing the pattern label, 1-based line number,
785    /// and 0-based byte offset within the input file. Callers that only need
786    /// aggregate counts (no per-match positions) should prefer
787    /// [`scan_reader_with_progress`](Self::scan_reader_with_progress), which
788    /// skips the per-byte newline counting entirely.
789    ///
790    /// # Performance note
791    ///
792    /// Enabling `on_match` adds an O(committed_bytes_between_matches)
793    /// newline-counting pass inside each chunk. For files with sparse matches
794    /// this overhead is proportional to file size; for dense matches (e.g. one
795    /// secret per line) it is negligible. On 10–15 GiB log files with typical
796    /// match densities the overhead is roughly 10–20 % of total scan time.
797    ///
798    /// # Errors
799    ///
800    /// Returns [`SanitizeError`] on I/O failures or if a replacement
801    /// cannot be generated (e.g. store capacity exceeded).
802    pub fn scan_reader_with_callbacks<R: Read, W: Write, F, M>(
803        &self,
804        mut reader: R,
805        mut writer: W,
806        total_bytes: Option<u64>,
807        mut on_progress: F,
808        mut on_match: M,
809    ) -> Result<ScanStats>
810    where
811        F: FnMut(&ScanProgress),
812        M: FnMut(MatchLocation),
813    {
814        let mut stats = ScanStats::default();
815
816        // Carry buffer: the tail of the previous window that needs
817        // to be re-scanned with the next chunk.
818        let mut carry: Vec<u8> = Vec::new();
819
820        // Read buffer (reused across iterations to avoid re-allocation).
821        let mut read_buf = vec![0u8; self.config.chunk_size];
822
823        // Scan window (reused across iterations — grows to peak size then
824        // stays there, avoiding per-chunk allocation).
825        let mut window: Vec<u8> =
826            Vec::with_capacity(self.config.chunk_size + self.config.overlap_size);
827
828        // Scratch buffers reused every chunk to eliminate per-chunk heap
829        // pressure from match collection, output building, and stats tracking.
830        let mut scratch = ScanScratch::new(
831            self.patterns.len(),
832            self.config.chunk_size,
833            self.config.overlap_size,
834        );
835
836        // Absolute file byte offset of window[0] for this iteration.
837        let mut window_file_offset: u64 = 0;
838        // Cumulative newline count in the file before window[0].
839        let mut newlines_before_window: u64 = 0;
840
841        loop {
842            // Read the next chunk.
843            let bytes_read = read_fully(&mut reader, &mut read_buf)?;
844            let is_eof = bytes_read < read_buf.len();
845
846            // Track only genuinely new bytes (carry was already counted).
847            stats.bytes_processed += bytes_read as u64;
848
849            if bytes_read == 0 && carry.is_empty() {
850                break;
851            }
852
853            // Build the scan window: carry ++ new_data.
854            // Reuse the window buffer to avoid per-chunk allocation.
855            window.clear();
856            window.extend_from_slice(&carry);
857            window.extend_from_slice(&read_buf[..bytes_read]);
858
859            if window.is_empty() {
860                break;
861            }
862
863            // Scan the window: find matches, determine commit point, apply
864            // replacements, and flush the committed region to the writer.
865            // Returns the commit_point so we can slice the carry for next iter.
866            let commit_point = self.process_committed_window(
867                &window,
868                is_eof,
869                &mut scratch,
870                &mut writer,
871                &mut stats,
872                window_file_offset,
873                newlines_before_window,
874                &mut on_match,
875            )?;
876
877            // Advance file-level position counters for the next iteration.
878            // window[commit_point] is where the next window's carry starts,
879            // so that byte is at file offset (window_file_offset + commit_point).
880            newlines_before_window += count_newlines(&window[..commit_point]);
881            window_file_offset += commit_point as u64;
882
883            // Fold per-chunk pattern hit counts into the cumulative stats map,
884            // then emit a progress snapshot to the caller.
885            self.fold_chunk_counts(&mut scratch.pattern_counts, &mut stats);
886            on_progress(&ScanProgress {
887                bytes_processed: stats.bytes_processed,
888                bytes_output: stats.bytes_output,
889                total_bytes,
890                matches_found: stats.matches_found,
891                replacements_applied: stats.replacements_applied,
892            });
893
894            // Update carry for next iteration.
895            if is_eof {
896                carry.clear();
897                break;
898            }
899            carry.clear();
900            carry.extend_from_slice(&window[commit_point..]);
901        }
902
903        Ok(stats)
904    }
905
906    /// Scan one window, apply replacements up to the commit point, and flush
907    /// the result to `writer`. Returns the commit point so the caller can
908    /// slice the carry for the next iteration.
909    #[allow(clippy::too_many_arguments)]
910    fn process_committed_window(
911        &self,
912        window: &[u8],
913        is_eof: bool,
914        scratch: &mut ScanScratch,
915        writer: &mut dyn io::Write,
916        stats: &mut ScanStats,
917        window_file_offset: u64,
918        newlines_before_window: u64,
919        on_match: &mut dyn FnMut(MatchLocation),
920    ) -> Result<usize> {
921        // Find all non-overlapping matches in the window.
922        self.find_matches(window, scratch);
923
924        // Determine how much of the window can be safely committed this iteration.
925        let base_commit = if is_eof {
926            window.len()
927        } else {
928            window.len().saturating_sub(self.config.overlap_size)
929        };
930        let commit_point =
931            self.adjusted_commit_point(&scratch.selected, base_commit, window.len(), is_eof);
932
933        // Build output for the committed region (fills scratch.output).
934        self.apply_replacements(
935            &window[..commit_point],
936            &scratch.selected,
937            stats,
938            &mut scratch.output,
939            &mut scratch.pattern_counts,
940            window_file_offset,
941            newlines_before_window,
942            on_match,
943        )?;
944
945        writer.write_all(&scratch.output)?;
946        stats.bytes_output += scratch.output.len() as u64;
947
948        Ok(commit_point)
949    }
950
951    /// Fold per-chunk pattern hit counts into the cumulative `stats.pattern_counts`
952    /// map, then reset `counts` to zero for the next chunk.
953    ///
954    /// `label.clone()` is called at most once per distinct pattern per chunk,
955    /// not once per match hit, which keeps cost proportional to pattern count.
956    fn fold_chunk_counts(&self, counts: &mut [u64], stats: &mut ScanStats) {
957        for (idx, count) in counts.iter_mut().enumerate() {
958            if *count > 0 {
959                *stats
960                    .pattern_counts
961                    .entry(self.patterns[idx].label.clone())
962                    .or_insert(0) += *count;
963                *count = 0;
964            }
965        }
966    }
967
968    /// Convenience: scan byte slice in-memory and return sanitized output.
969    ///
970    /// Equivalent to `scan_reader(input, Vec::new())` but returns the
971    /// output buffer directly.
972    ///
973    /// # Errors
974    ///
975    /// Returns [`SanitizeError`] if a replacement cannot be generated
976    /// (e.g. store capacity exceeded).
977    pub fn scan_bytes(&self, input: &[u8]) -> Result<(Vec<u8>, ScanStats)> {
978        self.scan_bytes_with_progress(input, |_| {})
979    }
980
981    /// Scan a byte slice in memory and emit progress snapshots.
982    ///
983    /// # Errors
984    ///
985    /// Returns [`SanitizeError`] if a replacement cannot be generated
986    /// (e.g. store capacity exceeded).
987    pub fn scan_bytes_with_progress<F>(
988        &self,
989        input: &[u8],
990        on_progress: F,
991    ) -> Result<(Vec<u8>, ScanStats)>
992    where
993        F: FnMut(&ScanProgress),
994    {
995        let mut output = Vec::with_capacity(input.len());
996        let stats = self.scan_reader_with_callbacks(
997            input,
998            &mut output,
999            Some(input.len() as u64),
1000            on_progress,
1001            |_| {},
1002        )?;
1003        Ok((output, stats))
1004    }
1005
1006    // ---- Accessors ----
1007
1008    /// Access the scanner's configuration.
1009    #[must_use]
1010    pub fn config(&self) -> &ScanConfig {
1011        &self.config
1012    }
1013
1014    /// Access the underlying mapping store.
1015    #[must_use]
1016    pub fn store(&self) -> &Arc<MappingStore> {
1017        &self.store
1018    }
1019
1020    /// Number of patterns registered in this scanner.
1021    #[must_use]
1022    pub fn pattern_count(&self) -> usize {
1023        self.patterns.len()
1024    }
1025
1026    /// Create a scanner from an encrypted secrets file.
1027    ///
1028    /// Decrypts the file in memory, parses the entries, compiles
1029    /// patterns, and returns the scanner ready to scan. Decrypted
1030    /// plaintext is scrubbed from memory after parsing.
1031    ///
1032    /// # Arguments
1033    ///
1034    /// - `encrypted_bytes` — raw bytes of the `.enc` file.
1035    /// - `password` — user password.
1036    /// - `format` — optional format override for the plaintext.
1037    /// - `store` — mapping store for dedup-consistent replacements.
1038    /// - `config` — chunking / overlap configuration.
1039    /// - `extra_patterns` — additional patterns to merge in.
1040    ///
1041    /// # Returns
1042    ///
1043    /// `(scanner, warnings, allow_patterns)` where `warnings` lists entries
1044    /// that failed to compile (index + error) and `allow_patterns` are the
1045    /// raw strings from `kind: allow` entries — pass these to
1046    /// [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher) to
1047    /// suppress replacements for known-safe values.
1048    ///
1049    /// # Errors
1050    ///
1051    /// Returns a secrets-related [`SanitizeError`] on decryption failure
1052    /// or [`SanitizeError::InvalidConfig`] on invalid scanner config.
1053    pub fn from_encrypted_secrets(
1054        encrypted_bytes: &[u8],
1055        password: &str,
1056        format: Option<crate::secrets::SecretsFormat>,
1057        store: Arc<MappingStore>,
1058        config: ScanConfig,
1059        extra_patterns: Vec<ScanPattern>,
1060    ) -> Result<SecretsLoadResult> {
1061        let ((mut patterns, warnings), allow_patterns) =
1062            crate::secrets::load_encrypted_secrets(encrypted_bytes, password, format)?;
1063        patterns.extend(extra_patterns);
1064        let scanner = Self::new(patterns, store, config)?;
1065        Ok(SecretsLoadResult {
1066            scanner,
1067            warnings,
1068            allow_patterns,
1069        })
1070    }
1071
1072    /// Create a scanner from a plaintext secrets file.
1073    ///
1074    /// Convenience for development / testing without encryption.
1075    ///
1076    /// # Returns
1077    ///
1078    /// `(scanner, warnings, allow_patterns)` where `allow_patterns` are the
1079    /// raw strings from `kind: allow` entries — pass these to
1080    /// [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher) to
1081    /// suppress replacements for known-safe values.
1082    ///
1083    /// # Errors
1084    ///
1085    /// Returns a secrets-related [`SanitizeError`] on parse failure
1086    /// or [`SanitizeError::InvalidConfig`] on invalid scanner config.
1087    pub fn from_plaintext_secrets(
1088        plaintext: &[u8],
1089        format: Option<crate::secrets::SecretsFormat>,
1090        store: Arc<MappingStore>,
1091        config: ScanConfig,
1092        extra_patterns: Vec<ScanPattern>,
1093    ) -> Result<SecretsLoadResult> {
1094        let ((mut patterns, warnings), allow_patterns) =
1095            crate::secrets::load_plaintext_secrets(plaintext, format)?;
1096        patterns.extend(extra_patterns);
1097        let scanner = Self::new(patterns, store, config)?;
1098        Ok(SecretsLoadResult {
1099            scanner,
1100            warnings,
1101            allow_patterns,
1102        })
1103    }
1104
1105    // ---- Internal helpers ----
1106
1107    /// Find all non-overlapping matches across all patterns.
1108    ///
1109    /// Fills `scratch.selected` with the winning non-overlapping matches
1110    /// for the given `window`.  All three scratch `Vec`s are cleared and
1111    /// repopulated on each call so callers can freely reuse the same
1112    /// `ScanScratch` instance across chunks.
1113    ///
1114    /// ## Strategy
1115    ///
1116    /// 1. **Aho-Corasick** (`aho_corasick`): single O(n) SIMD pass over the
1117    ///    window reporting every occurrence of every literal pattern,
1118    ///    including overlapping ones.  This replaces O(k·n) individual regex
1119    ///    scans for the literal subset.
1120    /// 2. **RegexSet pre-filter** (R-3 optimisation): fast check of which
1121    ///    *non-literal* regex patterns have any match in the window.
1122    /// 3. **Individual regex `find_iter`**: only for regex patterns flagged
1123    ///    by step 2.
1124    /// 4. **Sort + greedy dedup**: all raw matches are sorted by start
1125    ///    (ascending), then length (descending), and a single greedy pass
1126    ///    selects the final non-overlapping set.
1127    fn find_matches(&self, window: &[u8], scratch: &mut ScanScratch) {
1128        scratch.all_matches.clear();
1129        scratch.selected.clear();
1130
1131        // Step 1: Aho-Corasick overlapping scan for all literal patterns.
1132        // find_overlapping_iter reports every match position including
1133        // overlapping ones, so the sort+greedy step below correctly resolves
1134        // ambiguities between literals (e.g. "abc" vs "abcd" at same offset).
1135        // Literals never have capture groups — capture is always None.
1136        if let Some(ac) = &self.aho_corasick {
1137            for mat in ac.find_overlapping_iter(window) {
1138                scratch.all_matches.push(RawMatch {
1139                    start: mat.start(),
1140                    end: mat.end(),
1141                    pattern_idx: self.literal_indices[mat.pattern().as_usize()],
1142                    capture: None,
1143                });
1144            }
1145        }
1146
1147        // Steps 2+3: RegexSet pre-filter then individual scan for non-literal
1148        // patterns.  regex_set only contains non-literal pattern strings, so
1149        // literals are never scanned twice.
1150        // Use captures_iter so that patterns with a capture group 1 record
1151        // the sub-range to replace, while patterns without one fall back to
1152        // replacing the full match.
1153        for rs_idx in self.regex_set.matches(window) {
1154            let pattern_idx = self.regex_indices[rs_idx];
1155            if window.len() < self.patterns[pattern_idx].min_length {
1156                continue;
1157            }
1158            for cap in self.patterns[pattern_idx].regex.captures_iter(window) {
1159                let full = cap.get(0).expect("group 0 always exists");
1160                let capture = cap.get(1).map(|g| (g.start(), g.end()));
1161                scratch.all_matches.push(RawMatch {
1162                    start: full.start(),
1163                    end: full.end(),
1164                    pattern_idx,
1165                    capture,
1166                });
1167            }
1168        }
1169
1170        // Step 4: sort then greedy non-overlapping selection.
1171        // Skip entirely when no matches were found (the common case for
1172        // clean data), avoiding an unnecessary sort of an empty Vec.
1173        if scratch.all_matches.is_empty() {
1174            return;
1175        }
1176
1177        // Primary: start ascending. Secondary: length descending (longer
1178        // match wins when two matches begin at the same position).
1179        scratch.all_matches.sort_unstable_by(|a, b| {
1180            a.start
1181                .cmp(&b.start)
1182                .then_with(|| (b.end - b.start).cmp(&(a.end - a.start)))
1183        });
1184
1185        let mut last_end = 0;
1186        for m in scratch.all_matches.drain(..) {
1187            if m.start >= last_end {
1188                last_end = m.end;
1189                scratch.selected.push(m);
1190            }
1191        }
1192    }
1193
1194    /// Adjust the commit point to avoid splitting a match across the
1195    /// commit / carry boundary.
1196    ///
1197    /// If any match straddles `base_commit` (starts before, ends after),
1198    /// the commit point is moved to after that match so it is emitted
1199    /// in full this iteration.
1200    #[allow(clippy::unused_self)] // keep &self for API consistency with other scanner methods
1201    fn adjusted_commit_point(
1202        &self,
1203        matches: &[RawMatch],
1204        base_commit: usize,
1205        window_len: usize,
1206        is_eof: bool,
1207    ) -> usize {
1208        if is_eof {
1209            return window_len;
1210        }
1211
1212        let mut commit = base_commit;
1213
1214        for m in matches {
1215            if m.start < commit && m.end > commit {
1216                // Match straddles the boundary — extend commit to include it.
1217                commit = m.end;
1218            }
1219        }
1220
1221        // Never exceed window length.
1222        commit.min(window_len)
1223    }
1224
1225    /// Build the output for the committed region by splicing in replacements.
1226    ///
1227    /// Writes into `output_buf` (cleared on entry) and increments
1228    /// `stats.matches_found` / `stats.replacements_applied` for each applied
1229    /// replacement.  Per-pattern hit counts are written to `pattern_counts`
1230    /// (indexed by `pattern_idx`); the caller is responsible for folding
1231    /// these into `ScanStats::pattern_counts` and resetting them.
1232    ///
1233    /// `matches` is the full selected set for the window (may include matches
1234    /// in the carry region beyond `committed`).  Because `adjusted_commit_point`
1235    /// guarantees no match straddles the boundary, any match with
1236    /// `start < committed.len()` also has `end <= committed.len()`.  The
1237    /// loop breaks early once `m.start >= committed.len()` since matches are
1238    /// sorted by start.
1239    ///
1240    /// `window_file_offset` and `newlines_before_window` are used to compute
1241    /// the absolute byte offset and 1-based line number for each committed
1242    /// match, which are delivered to `on_match`. The newline scan is
1243    /// incremental: we scan only the bytes between consecutive matches, not
1244    /// the full committed region.
1245    ///
1246    /// # Note on `from_utf8_lossy`
1247    ///
1248    /// `String::from_utf8_lossy` returns `Cow::Borrowed(&str)` for valid
1249    /// UTF-8 input (the common case for ASCII secrets) — no heap allocation
1250    /// on the hot path.
1251    #[allow(clippy::too_many_arguments)]
1252    fn apply_replacements(
1253        &self,
1254        committed: &[u8],
1255        matches: &[RawMatch],
1256        stats: &mut ScanStats,
1257        output_buf: &mut Vec<u8>,
1258        pattern_counts: &mut [u64],
1259        window_file_offset: u64,
1260        newlines_before_window: u64,
1261        on_match: &mut dyn FnMut(MatchLocation),
1262    ) -> Result<()> {
1263        output_buf.clear();
1264
1265        let mut last_end = 0;
1266        // Running newline count within the committed region, advanced
1267        // incrementally so we only scan the bytes between matches.
1268        let mut newlines_in_committed: u64 = 0;
1269        let mut newline_scan_pos: usize = 0;
1270
1271        for &m in matches {
1272            // Matches are sorted by start; those at or beyond the committed
1273            // region belong to the carry window — stop here.
1274            if m.start >= committed.len() {
1275                break;
1276            }
1277
1278            // Emit bytes before this match verbatim.
1279            output_buf.extend_from_slice(&committed[last_end..m.start]);
1280
1281            // Advance newline counter from previous scan position to match start,
1282            // then emit the match location to the caller.
1283            newlines_in_committed += count_newlines(&committed[newline_scan_pos..m.start]);
1284            newline_scan_pos = m.start;
1285            on_match(MatchLocation {
1286                line: newlines_before_window + newlines_in_committed + 1,
1287                byte_offset: window_file_offset + m.start as u64,
1288                pattern: self.patterns[m.pattern_idx].label.clone(),
1289            });
1290
1291            let pattern = &self.patterns[m.pattern_idx];
1292
1293            if let Some((cap_start, cap_end)) = m.capture {
1294                // Pattern has a capture group: replace only the capture group,
1295                // emitting the surrounding context bytes of the full match verbatim.
1296                // This preserves delimiters, key names, and prefixes that the
1297                // pattern uses as anchors to reduce false positives.
1298                if cap_start < m.start || cap_end > m.end || cap_start > cap_end {
1299                    // Capture bounds outside match bounds — skip rather than panic.
1300                    // This should not happen with correct regex patterns; log it so it
1301                    // surfaces during testing without crashing production runs.
1302                    tracing::warn!(
1303                        pattern = %pattern.label,
1304                        m_start = m.start,
1305                        m_end = m.end,
1306                        cap_start,
1307                        cap_end,
1308                        "capture group bounds outside match bounds — emitting full match unreplaced"
1309                    );
1310                    output_buf.extend_from_slice(&committed[m.start..m.end]);
1311                    last_end = m.end;
1312                    continue;
1313                }
1314                output_buf.extend_from_slice(&committed[m.start..cap_start]);
1315                let secret = String::from_utf8_lossy(&committed[cap_start..cap_end]);
1316                let replacement = self.store.get_or_insert(&pattern.category, &secret)?;
1317                output_buf.extend_from_slice(replacement.as_bytes());
1318                output_buf.extend_from_slice(&committed[cap_end..m.end]);
1319            } else {
1320                // No capture group — replace the full match (e.g. token-prefix
1321                // patterns like `glpat-[...]` where the full match IS the secret).
1322                let matched_text = String::from_utf8_lossy(&committed[m.start..m.end]);
1323                let replacement = self.store.get_or_insert(&pattern.category, &matched_text)?;
1324                output_buf.extend_from_slice(replacement.as_bytes());
1325            }
1326
1327            last_end = m.end;
1328
1329            stats.matches_found += 1;
1330            stats.replacements_applied += 1;
1331            pattern_counts[m.pattern_idx] += 1;
1332        }
1333
1334        // Emit the trailing non-matching tail.
1335        output_buf.extend_from_slice(&committed[last_end..]);
1336
1337        Ok(())
1338    }
1339}
1340
1341// ---------------------------------------------------------------------------
1342// Send + Sync compile-time assertion
1343// ---------------------------------------------------------------------------
1344
1345const _: fn() = || {
1346    fn assert_send<T: Send>() {}
1347    fn assert_sync<T: Sync>() {}
1348    assert_send::<StreamScanner>();
1349    assert_sync::<StreamScanner>();
1350};
1351
1352// ---------------------------------------------------------------------------
1353// I/O helper
1354// ---------------------------------------------------------------------------
1355
1356/// Count the number of `\n` bytes in `data`.
1357///
1358/// Used to advance the cumulative newline counter between consecutive
1359/// match positions so we can compute 1-based line numbers without
1360/// pre-scanning the entire committed region.
1361#[inline]
1362fn count_newlines(data: &[u8]) -> u64 {
1363    bytecount::count(data, b'\n') as u64
1364}
1365
1366/// Read up to `buf.len()` bytes from `reader`, retrying on `Interrupted`.
1367///
1368/// Returns the number of bytes actually read (< `buf.len()` only at EOF).
1369fn read_fully<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
1370    let mut total = 0;
1371    while total < buf.len() {
1372        match reader.read(&mut buf[total..]) {
1373            Ok(0) => break, // EOF
1374            Ok(n) => total += n,
1375            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
1376            Err(e) => return Err(SanitizeError::from(e)),
1377        }
1378    }
1379    Ok(total)
1380}
1381
1382// ---------------------------------------------------------------------------
1383// Unit tests
1384// ---------------------------------------------------------------------------
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389    use crate::generator::HmacGenerator;
1390
1391    /// Helper: build a scanner with given patterns and small chunk config.
1392    fn test_scanner(patterns: Vec<ScanPattern>) -> StreamScanner {
1393        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1394        let store = Arc::new(MappingStore::new(gen, None));
1395        StreamScanner::new(
1396            patterns,
1397            store,
1398            ScanConfig {
1399                chunk_size: 64,
1400                overlap_size: 16,
1401            },
1402        )
1403        .unwrap()
1404    }
1405
1406    /// Helper: email pattern.
1407    fn email_pattern() -> ScanPattern {
1408        ScanPattern::from_regex(
1409            r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
1410            Category::Email,
1411            "email",
1412        )
1413        .unwrap()
1414    }
1415
1416    /// Helper: IPv4 pattern.
1417    fn ipv4_pattern() -> ScanPattern {
1418        ScanPattern::from_regex(
1419            r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
1420            Category::IpV4,
1421            "ipv4",
1422        )
1423        .unwrap()
1424    }
1425
1426    // ---- Construction ----
1427
1428    #[test]
1429    fn scanner_creation() {
1430        let scanner = test_scanner(vec![email_pattern()]);
1431        assert_eq!(scanner.pattern_count(), 1);
1432    }
1433
1434    #[test]
1435    fn invalid_config_zero_chunk() {
1436        let gen = Arc::new(HmacGenerator::new([0u8; 32]));
1437        let store = Arc::new(MappingStore::new(gen, None));
1438        let result = StreamScanner::new(vec![], store, ScanConfig::new(0, 0));
1439        assert!(result.is_err());
1440    }
1441
1442    #[test]
1443    fn invalid_config_overlap_ge_chunk() {
1444        let gen = Arc::new(HmacGenerator::new([0u8; 32]));
1445        let store = Arc::new(MappingStore::new(gen, None));
1446        let result = StreamScanner::new(vec![], store, ScanConfig::new(100, 100));
1447        assert!(result.is_err());
1448    }
1449
1450    // ---- Empty / no-match cases ----
1451
1452    #[test]
1453    fn empty_input() {
1454        let scanner = test_scanner(vec![email_pattern()]);
1455        let (output, stats) = scanner.scan_bytes(b"").unwrap();
1456        assert!(output.is_empty());
1457        assert_eq!(stats.matches_found, 0);
1458        assert_eq!(stats.bytes_processed, 0);
1459    }
1460
1461    #[test]
1462    fn no_matches() {
1463        let scanner = test_scanner(vec![email_pattern()]);
1464        let input = b"There are no email addresses here.";
1465        let (output, stats) = scanner.scan_bytes(input).unwrap();
1466        assert_eq!(output, input.as_slice());
1467        assert_eq!(stats.matches_found, 0);
1468    }
1469
1470    // ---- Single match ----
1471
1472    #[test]
1473    fn single_email_replaced() {
1474        let scanner = test_scanner(vec![email_pattern()]);
1475        let input = b"Contact alice@corp.com for help.";
1476        let (output, stats) = scanner.scan_bytes(input).unwrap();
1477        assert_eq!(stats.matches_found, 1);
1478        assert_eq!(stats.replacements_applied, 1);
1479        // Original must not appear in output.
1480        assert!(!output
1481            .windows(b"alice@corp.com".len())
1482            .any(|w| w == b"alice@corp.com"));
1483        // Replacement should contain the @ from the domain-preserving email.
1484        let output_str = String::from_utf8_lossy(&output);
1485        assert!(output_str.contains("@corp.com"));
1486        // Length preserved: output is same total length as input.
1487        assert_eq!(output.len(), input.len(), "length must be preserved");
1488        // Surrounding text preserved.
1489        assert!(output_str.starts_with("Contact "));
1490        assert!(output_str.ends_with(" for help."));
1491    }
1492
1493    // ---- Multiple matches ----
1494
1495    #[test]
1496    fn multiple_emails_replaced() {
1497        let scanner = test_scanner(vec![email_pattern()]);
1498        let input = b"From alice@corp.com to bob@corp.com cc admin@corp.com";
1499        let (output, stats) = scanner.scan_bytes(input).unwrap();
1500        assert_eq!(stats.matches_found, 3);
1501        let out_str = String::from_utf8_lossy(&output);
1502        assert!(!out_str.contains("alice@corp.com"));
1503        assert!(!out_str.contains("bob@corp.com"));
1504        assert!(!out_str.contains("admin@corp.com"));
1505    }
1506
1507    // ---- Same secret gets same replacement ----
1508
1509    #[test]
1510    fn same_secret_same_replacement() {
1511        let scanner = test_scanner(vec![email_pattern()]);
1512        let input = b"First alice@corp.com then alice@corp.com again.";
1513        let (output, stats) = scanner.scan_bytes(input).unwrap();
1514        assert_eq!(stats.matches_found, 2);
1515        let out_str = String::from_utf8_lossy(&output);
1516        // Both occurrences should be replaced with the same value.
1517        // With length-preserving replacements, look for the preserved domain.
1518        let parts: Vec<&str> = out_str.split("@corp.com").collect();
1519        // 3 parts = 2 occurrences of the replacement.
1520        assert_eq!(parts.len(), 3);
1521    }
1522
1523    // ---- Literal pattern ----
1524
1525    #[test]
1526    fn literal_pattern_matched() {
1527        let pat = ScanPattern::from_literal(
1528            "SECRET_API_KEY_12345",
1529            Category::Custom("api_key".into()),
1530            "api_key",
1531        )
1532        .unwrap();
1533        let scanner = test_scanner(vec![pat]);
1534        let input = b"key=SECRET_API_KEY_12345&foo=bar";
1535        let (output, stats) = scanner.scan_bytes(input).unwrap();
1536        assert_eq!(stats.matches_found, 1);
1537        assert!(!output
1538            .windows(b"SECRET_API_KEY_12345".len())
1539            .any(|w| w == b"SECRET_API_KEY_12345"));
1540    }
1541
1542    // ---- Multiple pattern types ----
1543
1544    #[test]
1545    fn multiple_pattern_types() {
1546        let scanner = test_scanner(vec![email_pattern(), ipv4_pattern()]);
1547        let input = b"Server 192.168.1.100 contact admin@server.com";
1548        let (output, stats) = scanner.scan_bytes(input).unwrap();
1549        assert_eq!(stats.matches_found, 2);
1550        let out_str = String::from_utf8_lossy(&output);
1551        assert!(!out_str.contains("192.168.1.100"));
1552        assert!(!out_str.contains("admin@server.com"));
1553        assert_eq!(*stats.pattern_counts.get("email").unwrap(), 1);
1554        assert_eq!(*stats.pattern_counts.get("ipv4").unwrap(), 1);
1555    }
1556
1557    // ---- Chunk boundary: match spans two chunks ----
1558
1559    #[test]
1560    fn match_at_chunk_boundary() {
1561        // Use a very small chunk size so the email straddles a boundary.
1562        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1563        let store = Arc::new(MappingStore::new(gen, None));
1564        let scanner = StreamScanner::new(
1565            vec![email_pattern()],
1566            store,
1567            ScanConfig {
1568                chunk_size: 20, // very small
1569                overlap_size: 16,
1570            },
1571        )
1572        .unwrap();
1573
1574        // Place an email address that will definitely straddle a boundary.
1575        let input = b"AAAAAAAAAAAAAAAA alice@corp.com BBBBBBBBBBBBB";
1576        let (output, stats) = scanner.scan_bytes(input).unwrap();
1577        assert_eq!(stats.matches_found, 1);
1578        let out_str = String::from_utf8_lossy(&output);
1579        assert!(!out_str.contains("alice@corp.com"));
1580        assert!(out_str.contains("@corp.com"), "domain must be preserved");
1581    }
1582
1583    // ---- Large input requiring many chunks ----
1584
1585    #[test]
1586    fn large_input_many_chunks() {
1587        let scanner = test_scanner(vec![email_pattern()]);
1588
1589        // Build a ~2 KiB input with emails sprinkled in.
1590        let mut input = Vec::new();
1591        let filler = b"Lorem ipsum dolor sit amet. ";
1592        for i in 0..20 {
1593            input.extend_from_slice(filler);
1594            let email = format!("user{}@example.com ", i);
1595            input.extend_from_slice(email.as_bytes());
1596        }
1597
1598        let (output, stats) = scanner.scan_bytes(&input).unwrap();
1599        assert_eq!(stats.matches_found, 20);
1600        let out_str = String::from_utf8_lossy(&output);
1601        for i in 0..20 {
1602            let email = format!("user{}@example.com", i);
1603            assert!(!out_str.contains(&email));
1604        }
1605    }
1606
1607    #[test]
1608    fn scan_bytes_with_progress_preserves_output_and_stats() {
1609        let scanner = test_scanner(vec![email_pattern()]);
1610        let input = b"Contact alice@corp.com and bob@corp.com for help.";
1611
1612        let (baseline_output, baseline_stats) = scanner.scan_bytes(input).unwrap();
1613
1614        let mut updates = Vec::new();
1615        let (progress_output, progress_stats) = scanner
1616            .scan_bytes_with_progress(input, |progress| updates.push(progress.clone()))
1617            .unwrap();
1618
1619        assert_eq!(progress_output, baseline_output);
1620        assert_eq!(
1621            progress_stats.bytes_processed,
1622            baseline_stats.bytes_processed
1623        );
1624        assert_eq!(progress_stats.bytes_output, baseline_stats.bytes_output);
1625        assert_eq!(progress_stats.matches_found, baseline_stats.matches_found);
1626        assert_eq!(
1627            progress_stats.replacements_applied,
1628            baseline_stats.replacements_applied
1629        );
1630        assert!(!updates.is_empty());
1631        assert_eq!(updates.last().unwrap().bytes_processed, input.len() as u64);
1632        assert_eq!(
1633            updates.last().unwrap().total_bytes,
1634            Some(input.len() as u64)
1635        );
1636        assert_eq!(updates.last().unwrap().matches_found, 2);
1637    }
1638
1639    #[test]
1640    fn scan_reader_with_progress_reports_multiple_updates_for_multi_chunk_input() {
1641        let scanner = test_scanner(vec![email_pattern()]);
1642        let mut input = Vec::new();
1643        for i in 0..8 {
1644            input.extend_from_slice(b"padding padding padding ");
1645            input.extend_from_slice(format!("user{i}@example.com ").as_bytes());
1646        }
1647
1648        let mut output = Vec::new();
1649        let mut updates = Vec::new();
1650        let stats = scanner
1651            .scan_reader_with_callbacks(
1652                &input[..],
1653                &mut output,
1654                Some(input.len() as u64),
1655                |progress| {
1656                    updates.push(progress.clone());
1657                },
1658                |_| {},
1659            )
1660            .unwrap();
1661
1662        assert!(updates.len() >= 2);
1663        assert_eq!(
1664            updates.last().unwrap().bytes_processed,
1665            stats.bytes_processed
1666        );
1667        assert_eq!(updates.last().unwrap().bytes_output, stats.bytes_output);
1668        assert_eq!(
1669            updates.last().unwrap().total_bytes,
1670            Some(input.len() as u64)
1671        );
1672    }
1673
1674    // ---- Scan via Read/Write interface ----
1675
1676    #[test]
1677    fn scan_reader_writer() {
1678        let scanner = test_scanner(vec![email_pattern()]);
1679        let input = b"hello alice@corp.com world";
1680        let mut output = Vec::new();
1681        let stats = scanner.scan_reader(&input[..], &mut output).unwrap();
1682        assert_eq!(stats.matches_found, 1);
1683        let out_str = String::from_utf8_lossy(&output);
1684        assert!(out_str.contains("@corp.com"), "domain must be preserved");
1685    }
1686
1687    // ---- Pattern compile error ----
1688
1689    #[test]
1690    fn invalid_regex_pattern() {
1691        let result = ScanPattern::from_regex("[invalid(", Category::Email, "bad");
1692        assert!(result.is_err());
1693    }
1694
1695    // ---- Default config ----
1696
1697    #[test]
1698    fn default_config_valid() {
1699        ScanConfig::default().validate().unwrap();
1700    }
1701
1702    // ---- Config edge cases ----
1703
1704    #[test]
1705    fn config_chunk_1_overlap_0() {
1706        // Extreme but valid: 1-byte chunks, no overlap.
1707        // Won't catch multi-byte patterns, but should not crash.
1708        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1709        let store = Arc::new(MappingStore::new(gen, None));
1710        let scanner = StreamScanner::new(vec![], store, ScanConfig::new(1, 0)).unwrap();
1711        let (output, _) = scanner.scan_bytes(b"hello").unwrap();
1712        assert_eq!(output, b"hello");
1713    }
1714
1715    // ---- ScanStats equality (exercises the PartialEq derive) ----
1716
1717    #[test]
1718    fn scan_stats_equality() {
1719        let scanner = test_scanner(vec![email_pattern()]);
1720        let input = b"hello alice@corp.com world";
1721        let (_, stats_a) = scanner.scan_bytes(input).unwrap();
1722        let (_, stats_b) = scanner.scan_bytes(input).unwrap();
1723        // Identical inputs produce identical stats.
1724        assert_eq!(
1725            stats_a, stats_b,
1726            "identical inputs must produce identical stats"
1727        );
1728        // Values are correct — not just equal to each other.
1729        assert_eq!(stats_a.matches_found, 1, "one email in input");
1730        assert_eq!(stats_a.replacements_applied, 1);
1731        assert_eq!(stats_a.bytes_processed, input.len() as u64);
1732        assert_eq!(*stats_a.pattern_counts.get("email").unwrap_or(&0), 1);
1733        // No-match run produces zeroed counters.
1734        let (_, stats_empty) = scanner.scan_bytes(b"no matches here").unwrap();
1735        assert_ne!(stats_a, stats_empty);
1736        assert_eq!(stats_empty.matches_found, 0);
1737        assert_eq!(stats_empty.replacements_applied, 0);
1738    }
1739
1740    // ---- on_match line number and byte offset accuracy ----
1741
1742    #[test]
1743    fn on_match_reports_correct_line_and_byte_offset() {
1744        // alice@corp.com starts after "line one\n" (9 bytes) → byte 9, line 2.
1745        // bob@corp.com starts after "line one\nalice@corp.com\nline three\n"
1746        //   = 9 + 14 + 1 + 10 + 1 = 35 bytes → byte 35, line 4.
1747        let scanner = test_scanner(vec![email_pattern()]);
1748        let input = b"line one\nalice@corp.com\nline three\nbob@corp.com\n";
1749        let mut locations = Vec::new();
1750        let mut output = Vec::new();
1751        scanner
1752            .scan_reader_with_callbacks(
1753                &input[..],
1754                &mut output,
1755                None,
1756                |_| {},
1757                |loc| locations.push(loc),
1758            )
1759            .unwrap();
1760        assert_eq!(locations.len(), 2);
1761        assert_eq!(locations[0].line, 2, "alice must be on line 2");
1762        assert_eq!(locations[0].byte_offset, 9, "alice must start at byte 9");
1763        assert_eq!(locations[1].line, 4, "bob must be on line 4");
1764        assert_eq!(locations[1].byte_offset, 35, "bob must start at byte 35");
1765    }
1766
1767    // ---- Cross-chunk newline accumulation ----
1768
1769    #[test]
1770    fn on_match_line_numbers_stable_across_chunk_sizes() {
1771        // alice@corp.com starts after "line one\n" (9 bytes) → byte 9, line 2.
1772        // bob@corp.com starts after "line one\nalice@corp.com\nline three\n"
1773        //   = 9 + 14 + 1 + 10 + 1 = 35 bytes → byte 35, line 4.
1774        // Running the same input through different chunk sizes exercises
1775        // newlines_before_window accumulation across chunk boundaries.
1776        let input = b"line one\nalice@corp.com\nline three\nbob@corp.com\n";
1777        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1778        let store = Arc::new(MappingStore::new(gen, None));
1779
1780        for chunk_size in [16usize, 20, 24, 32, 64] {
1781            let scanner = StreamScanner::new(
1782                vec![email_pattern()],
1783                Arc::clone(&store),
1784                ScanConfig::new(chunk_size, 14),
1785            )
1786            .unwrap();
1787
1788            let mut locations = Vec::new();
1789            let mut output = Vec::new();
1790            scanner
1791                .scan_reader_with_callbacks(
1792                    &input[..],
1793                    &mut output,
1794                    None,
1795                    |_| {},
1796                    |loc| locations.push(loc),
1797                )
1798                .unwrap();
1799
1800            assert_eq!(
1801                locations.len(),
1802                2,
1803                "chunk_size={chunk_size}: expected 2 matches"
1804            );
1805            assert_eq!(
1806                locations[0].line, 2,
1807                "chunk_size={chunk_size}: alice must be on line 2"
1808            );
1809            assert_eq!(
1810                locations[0].byte_offset, 9,
1811                "chunk_size={chunk_size}: alice must start at byte 9"
1812            );
1813            assert_eq!(
1814                locations[1].line, 4,
1815                "chunk_size={chunk_size}: bob must be on line 4"
1816            );
1817            assert_eq!(
1818                locations[1].byte_offset, 35,
1819                "chunk_size={chunk_size}: bob must start at byte 35"
1820            );
1821        }
1822    }
1823
1824    // ---- Bytes output tracking ----
1825
1826    #[test]
1827    fn bytes_output_preserved_on_replacement() {
1828        let scanner = test_scanner(vec![email_pattern()]);
1829        let input = b"a@b.cc"; // short email
1830        let (output, stats) = scanner.scan_bytes(input).unwrap();
1831        assert_eq!(stats.bytes_processed, input.len() as u64);
1832        assert_eq!(stats.bytes_output, output.len() as u64);
1833        // Length-preserving: output length matches input length.
1834        assert_eq!(output.len(), input.len());
1835    }
1836}