Skip to main content

scour_secrets/
scanner.rs

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