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