Skip to main content

scour_secrets/processor/
archive.rs

1//! Archive processor for sanitizing files inside `.zip`, `.tar`, and `.tar.gz` archives.
2//!
3//! # Architecture
4//!
5//! ```text
6//! ┌───────────────────────┐
7//! │  Archive (zip/tar/gz) │
8//! └────────┬──────────────┘
9//!          │  for each entry
10//!          ▼
11//! ┌─────────────────────────────────────────────┐
12//! │  1. Match entry filename → FileTypeProfile  │
13//! │  2. Try ProcessorRegistry (structured)      │
14//! │  3. Fallback: StreamScanner (streaming)     │
15//! └────────┬────────────────────────────────────┘
16//!          │  sanitized bytes
17//!          ▼
18//! ┌───────────────────────┐
19//! │  Rebuilt archive       │
20//! │  (same format, meta   │
21//! │   preserved)          │
22//! └───────────────────────┘
23//! ```
24//!
25//! # Memory Efficiency
26//!
27//! Archives are processed **entry-by-entry**. Each entry is piped
28//! through either a structured processor (which must buffer the full
29//! entry) or the [`StreamScanner`]
30//! (which processes in configurable chunks). This means the maximum
31//! memory footprint is proportional to the largest *single entry*
32//! that uses a structured processor. Files without a profile match
33//! are streamed through the scanner without buffering the whole entry.
34//!
35//! For very large individual files inside archives, the streaming
36//! scanner path keeps only `chunk_size + overlap_size` bytes in memory.
37//!
38//! # Thread Safety
39//!
40//! [`ArchiveProcessor`] is `Send + Sync`. The underlying
41//! [`MappingStore`] provides lock-free
42//! reads for dedup consistency.
43//!
44//! # Metadata Preservation
45//!
46//! - **Tar**: modification time, permissions (mode), uid/gid, and
47//!   username/groupname are copied from the source entry.
48//! - **Zip**: modification time, compression method, and unix
49//!   permissions are preserved.
50//! - Symlinks, directories, and other non-regular entries are passed
51//!   through with their content unchanged; entry names are sanitized
52//!   against path traversal like file entries, and names/link targets
53//!   longer than the 100-byte ustar field are preserved via GNU
54//!   long-name extensions.
55
56use crate::error::{Result, SanitizeError};
57use crate::processor::profile::FileTypeProfile;
58use crate::processor::registry::ProcessorRegistry;
59use crate::scanner::{ScanPattern, ScanStats, StreamScanner};
60use crate::store::MappingStore;
61
62/// Strip path traversal components from an archive entry path before writing output.
63///
64/// Removes: leading `/`, `./`, `../`, and Windows drive-letter prefixes (`C:`).
65/// The result is always a relative path with no upward traversal. An empty
66/// result is replaced with `"_"` to avoid writing an entry with a blank name.
67/// Backslashes are normalised to forward slashes (handles Windows-style zip entries).
68fn sanitize_archive_entry_name(name: &str) -> String {
69    let name = name.replace('\\', "/");
70    let name = name.trim_start_matches('/');
71    let safe: Vec<&str> = name
72        .split('/')
73        .filter(|s| {
74            if s.is_empty() || *s == "." || *s == ".." {
75                return false;
76            }
77            // Strip Windows drive-letter prefixes ("C:", "D:", etc.) to prevent
78            // zip-slip path-traversal when the output is extracted on Windows.
79            if s.len() == 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
80                return false;
81            }
82            true
83        })
84        .collect();
85    let result = safe.join("/");
86    if result.is_empty() {
87        "_".to_string()
88    } else {
89        result
90    }
91}
92
93#[inline]
94fn sanitize_zip_entry_name(name: &str) -> String {
95    sanitize_archive_entry_name(name)
96}
97
98#[inline]
99fn sanitize_tar_entry_name(name: &str) -> String {
100    sanitize_archive_entry_name(name)
101}
102
103use glob::MatchOptions;
104use rayon::prelude::*;
105use std::collections::HashMap;
106use std::io::{self, Read, Seek, Write};
107use std::path::{Path, PathBuf};
108use std::sync::{Arc, OnceLock};
109
110/// Append one entry to a tar builder, preserving names longer than the
111/// 100-byte ustar header field.
112///
113/// `Header::set_path` fails (or a cloned header silently keeps a truncated
114/// name) for long paths; `append_data`/`append_link` emit GNU long-name
115/// extension records instead. The entry path is sanitized against traversal;
116/// a trailing `/` (directory convention) is preserved.
117fn append_tar_entry<W: Write>(
118    builder: &mut tar::Builder<W>,
119    mut header: tar::Header,
120    path: &str,
121    link_target: Option<&Path>,
122    data: &[u8],
123) -> Result<()> {
124    let mut safe_path = sanitize_tar_entry_name(path);
125    if path.ends_with('/') && !safe_path.ends_with('/') {
126        safe_path.push('/');
127    }
128    let entry_type = header.entry_type();
129    if entry_type.is_symlink() || entry_type.is_hard_link() {
130        if let Some(target) = link_target {
131            return builder
132                .append_link(&mut header, &safe_path, target)
133                .map_err(|e| {
134                    SanitizeError::ArchiveError(format!("append link '{safe_path}': {e}"))
135                });
136        }
137    }
138    header.set_size(data.len() as u64);
139    builder
140        .append_data(&mut header, &safe_path, data)
141        .map_err(|e| SanitizeError::ArchiveError(format!("append '{safe_path}': {e}")))
142}
143
144use crate::entropy::{entropy_scan_bytes, merge_entropy_counts, EntropyConfig};
145use crate::processor::limits::{
146    DEFAULT_ARCHIVE_DEPTH, MAX_ARCHIVE_DEPTH, PARALLEL_ENTRY_THRESHOLD, PARALLEL_TAR_DATA_SIZE,
147    PARALLEL_ZIP_DATA_SIZE, STRUCTURED_ENTRY_SIZE,
148};
149
150/// Gzip stream magic bytes (RFC 1952).
151const GZIP_MAGIC: &[u8] = &[0x1f, 0x8b];
152
153/// Inner filename for a single-file gzip entry: `logs/app.log.1.gz` →
154/// `logs/app.log.1`. Returns `None` for non-`.gz` names and for `.tar.gz` /
155/// `.tgz`, which are whole archives handled by the nested-archive path.
156fn strip_gz_suffix(filename: &str) -> Option<&str> {
157    let lower = filename.to_ascii_lowercase();
158    if lower.strip_suffix(".tar.gz").is_some() || lower.strip_suffix(".tgz").is_some() {
159        return None;
160    }
161    let stem_len = lower.strip_suffix(".gz")?.len();
162    (stem_len > 0).then(|| &filename[..stem_len])
163}
164
165/// Archive formats that route to `sanitize_nested_archive`. Single-file gzip
166/// (`ArchiveFormat::Gz`) is excluded: the dedicated branch in `sanitize_entry`
167/// decompresses it under its inner name and honors `--force-text`.
168fn nested_archive_format(filename: &str) -> Option<ArchiveFormat> {
169    ArchiveFormat::from_path(filename).filter(|f| *f != ArchiveFormat::Gz)
170}
171
172/// Read up to `limit` bytes from `reader` into a `Vec<u8>`.
173///
174/// Returns an error if the reader yields more than `limit` bytes, preventing
175/// unbounded heap growth from crafted archive entries.
176fn read_bounded(reader: &mut dyn Read, limit: u64, label: &str) -> Result<Vec<u8>> {
177    let mut content = Vec::new();
178    // Read one byte beyond the limit so we can detect over-sized entries.
179    reader
180        .take(limit + 1)
181        .read_to_end(&mut content)
182        .map_err(|e| SanitizeError::ArchiveError(format!("read '{label}': {e}")))?;
183    if content.len() as u64 > limit {
184        return Err(SanitizeError::ArchiveError(format!(
185            "entry '{label}' exceeds the {limit}-byte size limit",
186        )));
187    }
188    Ok(content)
189}
190
191// ---------------------------------------------------------------------------
192// Archive format enum
193// ---------------------------------------------------------------------------
194
195/// Per-entry result from parallel archive processing: `(source_index, sanitized_bytes_and_stats)`.
196type ParEntryResult = (usize, Result<(Vec<u8>, ArchiveStats)>);
197
198/// Callback invoked with `(entry_name, sanitized_bytes)` after each file entry
199/// inside an archive is processed. Used by callers that need to inspect the
200/// sanitized content without buffering the entire archive (e.g. log context
201/// extraction).
202pub type EntryCallback = Arc<dyn Fn(&str, &[u8]) + Send + Sync>;
203
204// ---------------------------------------------------------------------------
205// ArchiveFilter
206// ---------------------------------------------------------------------------
207
208/// A compiled glob-based entry filter for archive processing.
209///
210/// Patterns are compiled once at construction time. At processing time
211/// `passes()` is called for each file entry path inside the archive.
212///
213/// ## Pattern semantics
214///
215/// - `*` matches any sequence of characters that does **not** contain `/`.
216/// - `**` matches any sequence of characters including `/`.
217/// - `?` matches any single character except `/`.
218/// - `[abc]` matches one of the listed characters.
219/// - A pattern ending with `/` is a *directory prefix* — it matches
220///   the directory itself and any path underneath it.
221///
222/// ## Filter logic
223///
224/// 1. If `--only` patterns are present: the entry path must match at
225///    least one pattern, otherwise it is dropped.
226/// 2. If `--exclude` patterns are present: if the entry path matches
227///    any pattern, it is dropped.
228/// 3. Only file entries are filtered; directory / symlink entries
229///    always pass through to preserve archive structure.
230#[derive(Default, Clone)]
231pub struct ArchiveFilter {
232    only: Vec<CompiledPattern>,
233    exclude: Vec<CompiledPattern>,
234}
235
236#[derive(Clone)]
237enum CompiledPattern {
238    /// Pattern that ended with `/` — matches the prefix directory and
239    /// everything inside it.
240    DirPrefix(String),
241    /// General glob pattern compiled with `require_literal_separator`.
242    Glob(glob::Pattern),
243}
244
245const GLOB_OPTS: MatchOptions = MatchOptions {
246    case_sensitive: true,
247    require_literal_separator: true,
248    require_literal_leading_dot: false,
249};
250
251impl CompiledPattern {
252    fn compile(raw: &str) -> std::result::Result<Self, String> {
253        if raw.ends_with('/') {
254            // Strip trailing slash; matching is done manually in `matches`.
255            Ok(CompiledPattern::DirPrefix(
256                raw.trim_end_matches('/').to_string(),
257            ))
258        } else {
259            glob::Pattern::new(raw)
260                .map(CompiledPattern::Glob)
261                .map_err(|e| format!("invalid glob pattern '{raw}': {e}"))
262        }
263    }
264
265    fn matches(&self, path: &str) -> bool {
266        match self {
267            CompiledPattern::DirPrefix(prefix) => {
268                path == prefix || path.starts_with(&format!("{prefix}/"))
269            }
270            CompiledPattern::Glob(pat) => pat.matches_with(path, GLOB_OPTS),
271        }
272    }
273}
274
275impl ArchiveFilter {
276    /// Compile `only` and `exclude` pattern lists into an `ArchiveFilter`.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if any pattern contains invalid glob syntax.
281    pub fn new(only: Vec<String>, exclude: Vec<String>) -> std::result::Result<Self, String> {
282        let only = only
283            .into_iter()
284            .map(|p| CompiledPattern::compile(&p))
285            .collect::<std::result::Result<Vec<_>, _>>()?;
286        let exclude = exclude
287            .into_iter()
288            .map(|p| CompiledPattern::compile(&p))
289            .collect::<std::result::Result<Vec<_>, _>>()?;
290        Ok(Self { only, exclude })
291    }
292
293    /// Returns `true` when neither `--only` nor `--exclude` patterns are set.
294    pub fn is_empty(&self) -> bool {
295        self.only.is_empty() && self.exclude.is_empty()
296    }
297
298    /// Returns `true` if `path` should be included in the output archive.
299    ///
300    /// Only applies to file entries; directory entries bypass this check.
301    pub fn passes(&self, path: &str) -> bool {
302        if !self.only.is_empty() && !self.only.iter().any(|p| p.matches(path)) {
303            return false;
304        }
305        if self.exclude.iter().any(|p| p.matches(path)) {
306            return false;
307        }
308        true
309    }
310}
311
312// ---------------------------------------------------------------------------
313// Archive format enum
314// ---------------------------------------------------------------------------
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316#[non_exhaustive]
317pub enum ArchiveFormat {
318    /// `.zip` archive.
319    Zip,
320    /// Uncompressed `.tar` archive.
321    Tar,
322    /// Gzip-compressed `.tar.gz` / `.tgz` archive.
323    TarGz,
324    /// Standalone single-file gzip stream (`.gz` that is not `.tar.gz` / `.tgz`).
325    Gz,
326}
327
328impl ArchiveFormat {
329    /// Detect archive format from a file path / extension.
330    ///
331    /// Returns `None` for unrecognised extensions.
332    pub fn from_path(path: &str) -> Option<Self> {
333        let lower = path.to_ascii_lowercase();
334        if lower.ends_with(".tar.gz")
335            || std::path::Path::new(&lower)
336                .extension()
337                .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
338        {
339            Some(Self::TarGz)
340        } else if std::path::Path::new(&lower)
341            .extension()
342            .is_some_and(|ext| ext.eq_ignore_ascii_case("tar"))
343        {
344            Some(Self::Tar)
345        } else if std::path::Path::new(&lower)
346            .extension()
347            .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
348        {
349            Some(Self::Zip)
350        } else if strip_gz_suffix(&lower).is_some() {
351            Some(Self::Gz)
352        } else {
353            None
354        }
355    }
356}
357
358// ---------------------------------------------------------------------------
359// Archive statistics
360// ---------------------------------------------------------------------------
361
362/// Statistics collected while processing an archive.
363#[derive(Debug, Clone, Default)]
364#[non_exhaustive]
365pub struct ArchiveStats {
366    /// Number of file entries processed (excludes dirs/symlinks).
367    pub files_processed: u64,
368    /// Number of entries passed through unchanged (dirs, symlinks, etc.).
369    pub entries_skipped: u64,
370    /// Number of files handled by a structured processor.
371    pub structured_hits: u64,
372    /// Number of files handled by the streaming scanner fallback.
373    pub scanner_fallback: u64,
374    /// Number of entries that were themselves archives and processed
375    /// recursively.
376    pub nested_archives: u64,
377    /// Total input bytes across all file entries.
378    pub total_input_bytes: u64,
379    /// Total output bytes across all file entries.
380    pub total_output_bytes: u64,
381    /// Per-file processing method: filename → `"structured:<proc>"`, `"scanner"`,
382    /// or `"nested:<format>"`.
383    pub file_methods: HashMap<String, String>,
384    /// Per-file scan statistics (matches, replacements, bytes, pattern counts).
385    pub file_scan_stats: HashMap<String, ScanStats>,
386    /// Number of file entries removed by the [`ArchiveFilter`].
387    pub entries_filtered: u64,
388}
389
390/// Progress snapshot emitted while processing archive entries.
391#[derive(Debug, Clone, Eq, PartialEq)]
392#[non_exhaustive]
393pub struct ArchiveProgress {
394    /// Entries seen so far, including skipped entries.
395    pub entries_seen: u64,
396    /// Regular file entries processed so far.
397    pub files_processed: u64,
398    /// Non-file entries skipped so far.
399    pub entries_skipped: u64,
400    /// Total entries when cheaply known.
401    pub total_entries: Option<u64>,
402    /// Path of the current entry.
403    pub current_entry: String,
404}
405
406type ArchiveProgressCallback = Arc<dyn Fn(&ArchiveProgress) + Send + Sync>;
407
408impl ArchiveStats {
409    /// Merge statistics from a nested archive into this parent.
410    fn merge(&mut self, child: &ArchiveStats) {
411        self.files_processed += child.files_processed;
412        self.entries_skipped += child.entries_skipped;
413        self.structured_hits += child.structured_hits;
414        self.scanner_fallback += child.scanner_fallback;
415        self.nested_archives += child.nested_archives;
416        self.total_input_bytes += child.total_input_bytes;
417        self.total_output_bytes += child.total_output_bytes;
418        self.entries_filtered += child.entries_filtered;
419        self.file_methods.extend(
420            child
421                .file_methods
422                .iter()
423                .map(|(k, v)| (k.clone(), v.clone())),
424        );
425        self.file_scan_stats.extend(
426            child
427                .file_scan_stats
428                .iter()
429                .map(|(k, v)| (k.clone(), v.clone())),
430        );
431    }
432}
433
434// ---------------------------------------------------------------------------
435// ArchiveProcessor
436// ---------------------------------------------------------------------------
437
438/// Processes archives by sanitizing each contained file and rebuilding
439/// the archive with the same format and preserved metadata.
440///
441/// # Usage
442///
443/// ```rust,no_run
444/// use scour_secrets::processor::archive::{ArchiveProcessor, ArchiveFormat};
445/// use scour_secrets::processor::registry::ProcessorRegistry;
446/// use scour_secrets::scanner::{StreamScanner, ScanPattern, ScanConfig};
447/// use scour_secrets::generator::HmacGenerator;
448/// use scour_secrets::store::MappingStore;
449/// use scour_secrets::category::Category;
450/// use std::sync::Arc;
451///
452/// let gen = Arc::new(HmacGenerator::new([42u8; 32]));
453/// let store = Arc::new(MappingStore::new(gen, None));
454/// let patterns = vec![
455///     ScanPattern::from_regex(r"secret\w+", Category::Custom("secret".into()), "secrets").unwrap(),
456/// ];
457/// let scanner = Arc::new(
458///     StreamScanner::new(patterns, Arc::clone(&store), ScanConfig::default()).unwrap(),
459/// );
460/// let registry = Arc::new(ProcessorRegistry::with_builtins());
461///
462/// let archive_proc = ArchiveProcessor::new(registry, scanner, store, vec![]);
463/// ```
464pub struct ArchiveProcessor {
465    /// Registry of structured processors.
466    registry: Arc<ProcessorRegistry>,
467    /// Streaming scanner for fallback processing.
468    scanner: Arc<StreamScanner>,
469    /// Shared mapping store (one-way replacements).
470    store: Arc<MappingStore>,
471    /// File-type profiles for structured processor matching.
472    profiles: Vec<FileTypeProfile>,
473    /// Maximum nesting depth for recursive archive processing.
474    max_depth: u32,
475    /// Optional callback for per-entry progress updates.
476    progress_callback: Option<ArchiveProgressCallback>,
477    /// Minimum number of file entries required to enable parallel entry
478    /// sanitization. Default: [`PARALLEL_ENTRY_THRESHOLD`].
479    parallel_threshold: usize,
480    /// Entry-level filter controlling which paths are included in the
481    /// output archive. Default: empty (pass all entries).
482    filter: ArchiveFilter,
483    /// When true, bypass all structured processors and use only the
484    /// streaming scanner for every entry. Trades format preservation
485    /// for maximum sanitization coverage.
486    force_text: bool,
487    /// Optional callback invoked with `(entry_name, sanitized_bytes)` after
488    /// each file entry is processed. Only called for regular file entries.
489    entry_callback: Option<EntryCallback>,
490    /// Entropy-detection configs applied to each file entry's output after
491    /// the pattern pass (placeholders are low-entropy, so no double-fire).
492    /// Empty by default. When non-empty, entries that would otherwise stream
493    /// are buffered (bounded by [`STRUCTURED_ENTRY_SIZE`]); over-cap entries
494    /// fall back to streaming without entropy detection.
495    entropy_configs: Vec<EntropyConfig>,
496    /// Lazily-built format-preserving scanner for structured entries: the
497    /// scanner with structure-corrupting key/value patterns stripped. Built
498    /// once on first use and reused across entries (and threads).
499    structured_scanner: OnceLock<Arc<StreamScanner>>,
500}
501
502impl ArchiveProcessor {
503    /// Create a new archive processor.
504    ///
505    /// # Arguments
506    ///
507    /// - `registry` — structured processor registry.
508    /// - `scanner` — streaming scanner for fallback.
509    /// - `store` — shared mapping store for one-way dedup replacements.
510    /// - `profiles` — file-type profiles for structured matching.
511    pub fn new(
512        registry: Arc<ProcessorRegistry>,
513        scanner: Arc<StreamScanner>,
514        store: Arc<MappingStore>,
515        profiles: Vec<FileTypeProfile>,
516    ) -> Self {
517        Self {
518            registry,
519            scanner,
520            store,
521            profiles,
522            max_depth: DEFAULT_ARCHIVE_DEPTH,
523            progress_callback: None,
524            parallel_threshold: PARALLEL_ENTRY_THRESHOLD,
525            filter: ArchiveFilter::default(),
526            force_text: false,
527            entry_callback: None,
528            entropy_configs: Vec::new(),
529            structured_scanner: OnceLock::new(),
530        }
531    }
532
533    /// Format-preserving scanner for structured entries, built lazily from
534    /// `self.scanner` with structure-corrupting key/value patterns removed (see
535    /// [`StreamScanner::for_structured_pass`]). Applied to an entry's **original**
536    /// bytes so comments, key order, and whitespace are preserved while
537    /// discovered field values and non-structural patterns are still redacted.
538    fn structured_pass_scanner(&self) -> Result<&Arc<StreamScanner>> {
539        if let Some(scanner) = self.structured_scanner.get() {
540            return Ok(scanner);
541        }
542        let built = Arc::new(self.scanner.for_structured_pass(Vec::new())?);
543        // A racing thread may have set it first; either value is equivalent.
544        let _ = self.structured_scanner.set(built);
545        Ok(self
546            .structured_scanner
547            .get()
548            .expect("structured scanner was just set"))
549    }
550
551    /// Override the maximum nesting depth for recursive archive
552    /// processing.
553    ///
554    /// The default is [`DEFAULT_ARCHIVE_DEPTH`] (5). Values above
555    /// 10 are clamped.
556    #[must_use]
557    pub fn with_max_depth(mut self, depth: u32) -> Self {
558        self.max_depth = depth.min(MAX_ARCHIVE_DEPTH);
559        self
560    }
561
562    /// Override the minimum entry count required to enable parallel
563    /// entry sanitization. Set to `usize::MAX` to disable parallelism
564    /// entirely for this processor instance (e.g. when outer file-level
565    /// parallelism is already saturating the thread budget).
566    #[must_use]
567    pub fn with_parallel_threshold(mut self, threshold: usize) -> Self {
568        self.parallel_threshold = threshold;
569        self
570    }
571
572    /// Register a per-entry archive progress callback.
573    #[must_use]
574    pub fn with_progress_callback(mut self, callback: ArchiveProgressCallback) -> Self {
575        self.progress_callback = Some(callback);
576        self
577    }
578
579    /// Apply an [`ArchiveFilter`] that controls which file entries are
580    /// included in the output archive.
581    ///
582    /// Entries that do not pass the filter are **removed** from the
583    /// output entirely. Directory / symlink entries are never filtered.
584    #[must_use]
585    pub fn with_filter(mut self, filter: ArchiveFilter) -> Self {
586        self.filter = filter;
587        self
588    }
589
590    /// When set, bypass all structured processors and use only the
591    /// streaming scanner for every archive entry.
592    ///
593    /// Trades format preservation for maximum sanitization coverage.
594    /// Useful when the user is uncertain about field rules or wants a
595    /// belt-and-suspenders guarantee that every byte is scanned.
596    #[must_use]
597    pub fn with_force_text(mut self, force_text: bool) -> Self {
598        self.force_text = force_text;
599        self
600    }
601
602    /// Register a callback that is invoked with `(entry_name, sanitized_bytes)`
603    /// after each regular file entry is fully processed.
604    #[must_use]
605    pub fn with_entry_callback(mut self, callback: EntryCallback) -> Self {
606        self.entry_callback = Some(callback);
607        self
608    }
609
610    /// Apply entropy-detection configs to every file entry after the pattern
611    /// pass, so `kind: entropy` rules and `--entropy-threshold` behave inside
612    /// archives exactly as they do for directly-processed files.
613    #[must_use]
614    pub fn with_entropy_configs(mut self, configs: Vec<EntropyConfig>) -> Self {
615        self.entropy_configs = configs;
616        self
617    }
618
619    /// Run the entropy pass over an entry's sanitized bytes, merging label
620    /// counts into `scan_stats`. No-op (no copy) when unconfigured.
621    fn apply_entropy(&self, bytes: Vec<u8>, scan_stats: &mut ScanStats) -> Vec<u8> {
622        if self.entropy_configs.is_empty() {
623            return bytes;
624        }
625        let (out, counts) = entropy_scan_bytes(&bytes, &self.entropy_configs, &self.store);
626        merge_entropy_counts(scan_stats, counts);
627        out
628    }
629
630    fn emit_entry_bytes(&self, name: &str, bytes: &[u8]) {
631        if let Some(cb) = &self.entry_callback {
632            cb(name, bytes);
633        }
634    }
635
636    /// Find the first profile matching a filename.
637    fn find_profile(&self, filename: &str) -> Option<&FileTypeProfile> {
638        self.profiles.iter().find(|p| p.matches_filename(filename))
639    }
640
641    fn emit_progress(&self, stats: &ArchiveStats, total_entries: Option<u64>, current_entry: &str) {
642        if let Some(callback) = &self.progress_callback {
643            callback(&ArchiveProgress {
644                entries_seen: stats.files_processed + stats.entries_skipped,
645                files_processed: stats.files_processed,
646                entries_skipped: stats.entries_skipped,
647                total_entries,
648                current_entry: current_entry.to_string(),
649            });
650        }
651    }
652
653    /// Sanitize a file entry given its raw bytes.
654    ///
655    /// Returns the sanitized bytes together with a fresh [`ArchiveStats`]
656    /// covering only this entry. This is the core work unit for parallel
657    /// entry processing in [`process_tar_at_depth`] and
658    /// [`process_zip_at_depth`].
659    fn sanitize_entry_bytes(
660        &self,
661        filename: &str,
662        data: &[u8],
663        entry_size_hint: Option<u64>,
664        depth: u32,
665    ) -> Result<(Vec<u8>, ArchiveStats)> {
666        let mut out: Vec<u8> = Vec::with_capacity(data.len());
667        let mut entry_stats = ArchiveStats::default();
668        let mut reader = io::Cursor::new(data);
669        self.sanitize_entry(
670            filename,
671            &mut reader,
672            &mut out,
673            &mut entry_stats,
674            entry_size_hint,
675            depth,
676        )?;
677        Ok((out, entry_stats))
678    }
679
680    /// Sanitize the content of a single file entry.
681    ///
682    /// If the entry is itself an archive (detected via extension), it is
683    /// recursively processed up to `self.max_depth`. Otherwise, tries a
684    /// structured processor first; falls back to the streaming scanner
685    /// if no processor matches.
686    ///
687    /// For the streaming scanner path, the content is piped through
688    /// `scan_reader` directly to the writer for memory-efficient
689    /// chunk-based processing (F-02 fix: no full output buffering).
690    #[allow(clippy::missing_errors_doc)] // private method
691    fn sanitize_entry(
692        &self,
693        filename: &str,
694        reader: &mut dyn Read,
695        writer: &mut dyn Write,
696        stats: &mut ArchiveStats,
697        entry_size_hint: Option<u64>,
698        depth: u32,
699    ) -> Result<()> {
700        // --- Nested archive detection ---
701        if let Some(nested_fmt) = nested_archive_format(filename) {
702            return self.sanitize_nested_archive(
703                filename,
704                reader,
705                writer,
706                stats,
707                entry_size_hint,
708                nested_fmt,
709                depth,
710            );
711        }
712
713        // --- Single-file gzip entries (.tar.gz/.tgz matched above) ---
714        // Compressed logs (`db-migrate.log.1.gz`) are decompressed before
715        // scanning and recompressed after. `--force-text` keeps the raw
716        // byte-scan behavior.
717        if !self.force_text {
718            if let Some(inner_name) = strip_gz_suffix(filename) {
719                return self.sanitize_gz_entry(
720                    filename,
721                    inner_name,
722                    reader,
723                    writer,
724                    stats,
725                    entry_size_hint,
726                    depth,
727                );
728            }
729        }
730
731        // --- Structured / scanner processing ---
732
733        // Try structured processing first, but only if the entry is
734        // within the size cap and --force-text is not set.
735        // Oversized entries fall through to the streaming scanner (M-3 fix).
736        let within_size_cap = entry_size_hint.is_none_or(|sz| sz <= STRUCTURED_ENTRY_SIZE); // unknown size → allow (conservative)
737
738        if !self.force_text && within_size_cap {
739            if let Some(profile) = self.find_profile(filename) {
740                // Structured processors need the full content in memory.
741                let mut content = Vec::new();
742                reader.read_to_end(&mut content).map_err(|e| {
743                    SanitizeError::ArchiveError(format!("read entry '{filename}': {e}"))
744                })?;
745
746                stats.total_input_bytes += content.len() as u64;
747
748                // A parse error (e.g. binary content with a .yaml extension, like
749                // macOS resource-fork ._* files) falls through to the scanner
750                // rather than failing the whole archive.
751                // A parse error or heuristic rejection falls through to the scanner below.
752                let pre_snapshot = self.store.snapshot();
753                // Prefer span-based edit mode (field values replaced in place —
754                // exact, format-preserving, leak-free even for escaped values).
755                // Fall back to the literal structured pass, whose re-serialized
756                // output is discarded; either way the store is populated.
757                let structured_base: Option<Vec<u8>> =
758                    match self
759                        .registry
760                        .process_to_edits(&content, profile, &self.store)
761                    {
762                        Ok(Some((edited, _count))) => Some(edited),
763                        Ok(None) => match self.registry.process(&content, profile, &self.store) {
764                            Ok(Some(_)) => Some(content.clone()),
765                            _ => None,
766                        },
767                        Err(_) => None,
768                    };
769                if let Some(base) = structured_base {
770                    // Run the format-preserving scanner over the structured-redacted
771                    // bytes to catch the same values in comments / unstructured
772                    // regions. Field values discovered *for this entry* are added
773                    // as literal patterns so they are redacted even when the
774                    // caller's scanner does not already carry them (library usage
775                    // without a pre-discovery pass). When the delta is empty — the
776                    // common case in the CLI, where the augmented scanner already
777                    // holds every literal — a cached scanner is reused to avoid
778                    // rebuilding the automaton per entry.
779                    let extra: Vec<ScanPattern> = self
780                        .store
781                        .iter_since(pre_snapshot)
782                        .filter(|(_, original, _)| {
783                            original.len() >= crate::scanner::MIN_DISCOVERED_LITERAL_LEN
784                        })
785                        .filter_map(|(category, original, _)| {
786                            // Label by category, never the value (labels surface
787                            // in report/findings/summary output — no secrets).
788                            let label = format!("field:{category}");
789                            ScanPattern::from_literal(original.as_str(), category, label).ok()
790                        })
791                        .collect();
792                    let (output, mut scan_stats) = if extra.is_empty() {
793                        self.structured_pass_scanner()?.scan_bytes(&base)?
794                    } else {
795                        self.scanner.for_structured_pass(extra)?.scan_bytes(&base)?
796                    };
797                    let output = self.apply_entropy(output, &mut scan_stats);
798                    stats.structured_hits += 1;
799                    stats.total_output_bytes += output.len() as u64;
800                    stats.file_methods.insert(
801                        filename.to_string(),
802                        format!("structured+scan:{}", profile.processor),
803                    );
804                    stats
805                        .file_scan_stats
806                        .insert(filename.to_string(), scan_stats);
807                    writer.write_all(&output).map_err(|e| {
808                        SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
809                    })?;
810                    return Ok(());
811                }
812
813                // Processor didn't match or failed — fall back to
814                // scanner with the already-buffered content.
815                let (output, mut scan_stats) = self.scanner.scan_bytes(&content)?;
816                let output = self.apply_entropy(output, &mut scan_stats);
817                stats.scanner_fallback += 1;
818                stats.total_output_bytes += output.len() as u64;
819                stats
820                    .file_methods
821                    .insert(filename.to_string(), "scanner".to_string());
822                stats
823                    .file_scan_stats
824                    .insert(filename.to_string(), scan_stats);
825                writer.write_all(&output).map_err(|e| {
826                    SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
827                })?;
828                return Ok(());
829            }
830        }
831
832        // No profile (or entry too large) → streaming scanner.
833        //
834        // Entropy detection needs whole tokens, so it can't run on the
835        // chunked stream. When configured, buffer entries whose size hint
836        // fits the cap; hintless or over-cap entries stream without it
837        // (buffering on a bad hint would consume the reader with no way to
838        // fall back).
839        if !self.entropy_configs.is_empty()
840            && entry_size_hint.is_some_and(|sz| sz <= STRUCTURED_ENTRY_SIZE)
841        {
842            return self.scan_buffered_with_entropy(filename, reader, writer, stats);
843        }
844
845        // F-02 fix: stream directly from reader → scanner → writer
846        // without buffering the full output.
847        self.scan_streaming(filename, reader, writer, stats)
848    }
849
850    /// Stream one entry through the scanner without buffering the full
851    /// output: reader → scanner → writer, with input/output byte counting.
852    fn scan_streaming(
853        &self,
854        filename: &str,
855        reader: &mut dyn Read,
856        writer: &mut dyn Write,
857        stats: &mut ArchiveStats,
858    ) -> Result<()> {
859        let mut counting_r = CountingReader::new(reader);
860        let mut counting_w = CountingWriter::new(writer);
861        let scan_stats = self.scanner.scan_reader(&mut counting_r, &mut counting_w)?;
862
863        stats.scanner_fallback += 1;
864        stats.total_input_bytes += counting_r.bytes_read();
865        stats.total_output_bytes += counting_w.bytes_written();
866        stats
867            .file_methods
868            .insert(filename.to_string(), "scanner".to_string());
869        stats
870            .file_scan_stats
871            .insert(filename.to_string(), scan_stats);
872
873        Ok(())
874    }
875
876    /// Sanitize a single-file gzip entry (`app.log.1.gz` — not `.tar.gz`,
877    /// which the nested-archive path handles): decompress, sanitize the inner
878    /// content under its real name so profiles and nested-archive detection
879    /// still apply, and recompress.
880    ///
881    /// Running the byte-level scanner over compressed data — the previous
882    /// behavior, kept for `--force-text` — can never match real plaintext but
883    /// *can* false-positive on short literal patterns, corrupting the gzip
884    /// stream. Entries that are too large to buffer or do not decode as gzip
885    /// fall back to the raw streaming scanner: a broken stream must not leak
886    /// plaintext hiding behind a gzip magic prefix.
887    #[allow(clippy::too_many_arguments)]
888    fn sanitize_gz_entry(
889        &self,
890        filename: &str,
891        inner_name: &str,
892        reader: &mut dyn Read,
893        writer: &mut dyn Write,
894        stats: &mut ArchiveStats,
895        entry_size_hint: Option<u64>,
896        depth: u32,
897    ) -> Result<()> {
898        // Buffer the compressed bytes ourselves (not via read_bounded) so the
899        // raw content is still available for the streaming fallback when the
900        // entry is oversized or not actually gzip.
901        let mut compressed = Vec::new();
902        let oversized_hint = entry_size_hint.is_some_and(|sz| sz > STRUCTURED_ENTRY_SIZE);
903        if !oversized_hint {
904            reader
905                .take(STRUCTURED_ENTRY_SIZE + 1)
906                .read_to_end(&mut compressed)
907                .map_err(|e| {
908                    SanitizeError::ArchiveError(format!("read entry '{filename}': {e}"))
909                })?;
910        }
911        if oversized_hint || compressed.len() as u64 > STRUCTURED_ENTRY_SIZE {
912            let mut chained = io::Cursor::new(compressed).chain(reader);
913            return self.scan_streaming(filename, &mut chained, writer, stats);
914        }
915
916        // Bounded decode guards against decompression bombs; a decode failure
917        // (bad magic, truncated stream, over-cap expansion) takes the raw
918        // scanner fallback.
919        let decoded = if compressed.starts_with(GZIP_MAGIC) {
920            let mut dec = flate2::read::GzDecoder::new(&compressed[..]);
921            read_bounded(&mut dec, STRUCTURED_ENTRY_SIZE, filename).ok()
922        } else {
923            None
924        };
925        let Some(decoded) = decoded else {
926            return self.scan_streaming(filename, &mut &compressed[..], writer, stats);
927        };
928
929        if depth >= self.max_depth {
930            return Err(SanitizeError::RecursionDepthExceeded(format!(
931                "compressed entry '{}' at depth {} exceeds maximum nesting depth of {}",
932                filename, depth, self.max_depth,
933            )));
934        }
935
936        stats.total_input_bytes += compressed.len() as u64;
937
938        let mut enc = flate2::write::GzEncoder::new(&mut *writer, flate2::Compression::fast());
939        let inner_len = decoded.len() as u64;
940        self.sanitize_entry(
941            inner_name,
942            &mut &decoded[..],
943            &mut enc,
944            stats,
945            Some(inner_len),
946            depth + 1,
947        )?;
948        enc.finish().map_err(|e| {
949            SanitizeError::ArchiveError(format!("recompress entry '{filename}': {e}"))
950        })?;
951
952        // Re-key the inner file's records under the on-archive entry name.
953        let inner_method = stats
954            .file_methods
955            .remove(inner_name)
956            .unwrap_or_else(|| "scanner".to_string());
957        stats
958            .file_methods
959            .insert(filename.to_string(), format!("gzip:{inner_method}"));
960        if let Some(ss) = stats.file_scan_stats.remove(inner_name) {
961            stats.file_scan_stats.insert(filename.to_string(), ss);
962        }
963        Ok(())
964    }
965
966    /// Buffered scanner + entropy pass for one file entry (used when entropy
967    /// detection is configured and the entry's size hint fits the cap).
968    fn scan_buffered_with_entropy(
969        &self,
970        filename: &str,
971        reader: &mut dyn Read,
972        writer: &mut dyn Write,
973        stats: &mut ArchiveStats,
974    ) -> Result<()> {
975        let content = read_bounded(reader, STRUCTURED_ENTRY_SIZE, filename)?;
976        let (output, mut scan_stats) = self.scanner.scan_bytes(&content)?;
977        let output = self.apply_entropy(output, &mut scan_stats);
978        stats.scanner_fallback += 1;
979        stats.total_input_bytes += content.len() as u64;
980        stats.total_output_bytes += output.len() as u64;
981        stats
982            .file_methods
983            .insert(filename.to_string(), "scanner".to_string());
984        stats
985            .file_scan_stats
986            .insert(filename.to_string(), scan_stats);
987        writer
988            .write_all(&output)
989            .map_err(|e| SanitizeError::ArchiveError(format!("write entry '{filename}': {e}")))
990    }
991
992    /// Handle a nested archive entry: validate depth/size, buffer, recurse,
993    /// and write the sanitized output.
994    #[allow(clippy::too_many_arguments)]
995    fn sanitize_nested_archive(
996        &self,
997        filename: &str,
998        reader: &mut dyn Read,
999        writer: &mut dyn Write,
1000        stats: &mut ArchiveStats,
1001        entry_size_hint: Option<u64>,
1002        nested_fmt: ArchiveFormat,
1003        depth: u32,
1004    ) -> Result<()> {
1005        if depth >= self.max_depth {
1006            return Err(SanitizeError::RecursionDepthExceeded(format!(
1007                "nested archive '{}' at depth {} exceeds maximum nesting depth of {}",
1008                filename, depth, self.max_depth,
1009            )));
1010        }
1011
1012        // Buffer the nested archive (always bounded by STRUCTURED_ENTRY_SIZE).
1013        // The size hint (from a zip central-directory entry) is checked first for
1014        // a fast early rejection; the bounded read enforces the cap even when the
1015        // hint is absent (e.g. some tar entries) to prevent unbounded heap growth.
1016        if let Some(sz) = entry_size_hint {
1017            if sz > STRUCTURED_ENTRY_SIZE {
1018                return Err(SanitizeError::ArchiveError(format!(
1019                    "nested archive '{}' is too large ({} bytes, limit {} bytes)",
1020                    filename, sz, STRUCTURED_ENTRY_SIZE,
1021                )));
1022            }
1023        }
1024
1025        let content = read_bounded(reader, STRUCTURED_ENTRY_SIZE, filename)?;
1026        stats.total_input_bytes += content.len() as u64;
1027
1028        // Recurse into the nested archive.
1029        let mut output_buf: Vec<u8> = Vec::new();
1030        let child_stats = match nested_fmt {
1031            ArchiveFormat::Tar => {
1032                self.process_tar_at_depth(&content[..], &mut output_buf, depth + 1)?
1033            }
1034            ArchiveFormat::TarGz => {
1035                self.process_tar_gz_at_depth(&content[..], &mut output_buf, depth + 1)?
1036            }
1037            ArchiveFormat::Zip => {
1038                let reader = io::Cursor::new(&content);
1039                let mut writer = io::Cursor::new(Vec::new());
1040                let s = self.process_zip_at_depth(reader, &mut writer, depth + 1)?;
1041                output_buf = writer.into_inner();
1042                s
1043            }
1044            ArchiveFormat::Gz => unreachable!(
1045                "single-file gzip entries are routed to sanitize_gz_entry by sanitize_entry"
1046            ),
1047        };
1048
1049        stats.nested_archives += 1;
1050        stats.merge(&child_stats);
1051        stats.total_output_bytes += output_buf.len() as u64;
1052        let fmt_name = match nested_fmt {
1053            ArchiveFormat::Tar => "tar",
1054            ArchiveFormat::TarGz => "tar.gz",
1055            ArchiveFormat::Zip => "zip",
1056            ArchiveFormat::Gz => "gz",
1057        };
1058        stats
1059            .file_methods
1060            .insert(filename.to_string(), format!("nested:{fmt_name}"));
1061        writer.write_all(&output_buf).map_err(|e| {
1062            SanitizeError::ArchiveError(format!("write nested archive '{filename}': {e}"))
1063        })?;
1064        Ok(())
1065    }
1066
1067    // -----------------------------------------------------------------------
1068    // Profile discovery passes (two-phase support)
1069    // -----------------------------------------------------------------------
1070    //
1071    // These methods perform a read-only pre-pass over an archive, running the
1072    // structured processor on every profile-matched entry and discarding the
1073    // output.  The side-effect is that `self.store` is populated with the
1074    // original→replacement mappings for those fields, so a subsequent call to
1075    // `build_augmented_scanner` can inject those values as literals into the
1076    // scanner used for the real processing pass.
1077
1078    /// Run the structured processor on every profile-matched entry in a
1079    /// `.tar` archive, recording replacements into the store.  Output is
1080    /// discarded; the archive is not modified.
1081    ///
1082    /// # Errors
1083    ///
1084    /// Returns an error if the archive cannot be read or an entry cannot be processed.
1085    pub fn discover_profiles_tar<R: Read>(&self, reader: R) -> Result<()> {
1086        if self.profiles.is_empty() {
1087            return Ok(());
1088        }
1089        let mut archive = tar::Archive::new(reader);
1090        let entries = archive
1091            .entries()
1092            .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entries: {e}")))?;
1093        for entry_result in entries {
1094            let mut entry = entry_result
1095                .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entry: {e}")))?;
1096            if !entry.header().entry_type().is_file() {
1097                continue;
1098            }
1099            let path = entry
1100                .path()
1101                .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1102                .to_string_lossy()
1103                .to_string();
1104            let Some(profile) = self.find_profile(&path) else {
1105                continue;
1106            };
1107            let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &path) {
1108                Ok(c) => c,
1109                Err(e) => {
1110                    tracing::warn!(path = %path, error = %e, "discovery: skipping oversized entry");
1111                    continue;
1112                }
1113            };
1114            if let Err(e) = self.registry.process(&content, profile, &self.store) {
1115                tracing::warn!(path = %path, error = %e, "discovery: structured processor failed; partial mappings may persist");
1116            }
1117        }
1118        Ok(())
1119    }
1120
1121    /// Run the structured processor on every profile-matched entry in a
1122    /// `.tar.gz` archive, recording replacements into the store.  Output is
1123    /// discarded; the archive is not modified.
1124    ///
1125    /// # Errors
1126    ///
1127    /// Returns an error if the archive cannot be read or an entry cannot be processed.
1128    pub fn discover_profiles_tar_gz<R: Read>(&self, reader: R) -> Result<()> {
1129        let gz = flate2::read::GzDecoder::new(reader);
1130        self.discover_profiles_tar(gz)
1131    }
1132
1133    /// Run the structured processor on every profile-matched entry in a
1134    /// `.zip` archive, recording replacements into the store.  Output is
1135    /// discarded; the archive is not modified.
1136    ///
1137    /// # Errors
1138    ///
1139    /// Returns an error if the archive cannot be read or an entry cannot be processed.
1140    pub fn discover_profiles_zip<R: Read + Seek>(&self, reader: R) -> Result<()> {
1141        if self.profiles.is_empty() {
1142            return Ok(());
1143        }
1144        let mut zip = zip::ZipArchive::new(reader)
1145            .map_err(|e| SanitizeError::ArchiveError(format!("open zip for discovery: {e}")))?;
1146        for i in 0..zip.len() {
1147            let mut entry = zip
1148                .by_index(i)
1149                .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {i}: {e}")))?;
1150            if entry.is_dir() {
1151                continue;
1152            }
1153            let name = sanitize_zip_entry_name(entry.name());
1154            let Some(profile) = self.find_profile(&name) else {
1155                continue;
1156            };
1157            let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &name) {
1158                Ok(c) => c,
1159                Err(e) => {
1160                    tracing::warn!(name = %name, error = %e, "discovery: skipping oversized entry");
1161                    continue;
1162                }
1163            };
1164            if let Err(e) = self.registry.process(&content, profile, &self.store) {
1165                tracing::warn!(name = %name, error = %e, "discovery: structured processor failed; partial mappings may persist");
1166            }
1167        }
1168        Ok(())
1169    }
1170
1171    /// Run the structured processor on a profile-matched standalone `.gz`
1172    /// file's decompressed content, recording replacements into the store.
1173    /// Output is discarded; the file is not modified.
1174    ///
1175    /// `name` should be the file's base name — the inner name derived from it
1176    /// (`config.json.gz` → `config.json`) drives profile matching exactly like
1177    /// a `.gz` entry inside an archive. Content that does not decode as gzip
1178    /// is skipped; the processing pass takes the raw-scanner fallback for it.
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns an error if the file cannot be read.
1183    pub fn discover_profiles_gz<R: Read>(&self, name: &str, reader: R) -> Result<()> {
1184        if self.profiles.is_empty() {
1185            return Ok(());
1186        }
1187        let Some(inner_name) = strip_gz_suffix(name) else {
1188            return Ok(());
1189        };
1190        let Some(profile) = self.find_profile(inner_name) else {
1191            return Ok(());
1192        };
1193        let mut dec = flate2::read::GzDecoder::new(reader);
1194        let content = match read_bounded(&mut dec, STRUCTURED_ENTRY_SIZE, name) {
1195            Ok(c) => c,
1196            Err(e) => {
1197                tracing::warn!(name = %name, error = %e, "discovery: skipping undecodable or oversized gzip file");
1198                return Ok(());
1199            }
1200        };
1201        if let Err(e) = self.registry.process(&content, profile, &self.store) {
1202            tracing::warn!(name = %name, error = %e, "discovery: structured processor failed; partial mappings may persist");
1203        }
1204        Ok(())
1205    }
1206
1207    // Standalone single-file gzip processing
1208    // -----------------------------------------------------------------------
1209
1210    /// Process a standalone single-file gzip stream (`config.json.gz` — not
1211    /// `.tar.gz`, which [`Self::process_tar_gz`] handles), giving it the same
1212    /// treatment `.gz` entries inside archives receive: decompress (bounded,
1213    /// magic-checked), sanitize under the inner name so structured processors
1214    /// and profiles apply, and recompress. Content that is not valid gzip
1215    /// takes the raw streaming-scanner fallback; with `force_text` the raw
1216    /// byte scanner runs over the compressed stream unchanged.
1217    ///
1218    /// `name` should be the file's base name — it determines the inner name
1219    /// (`config.json.gz` → `config.json`) used for format detection.
1220    ///
1221    /// # Errors
1222    ///
1223    /// Returns [`SanitizeError::ArchiveError`] on I/O failures.
1224    pub fn process_gz<R: Read, W: Write>(
1225        &self,
1226        name: &str,
1227        mut reader: R,
1228        mut writer: W,
1229    ) -> Result<ArchiveStats> {
1230        let mut stats = ArchiveStats::default();
1231        match strip_gz_suffix(name) {
1232            Some(inner_name) if !self.force_text => {
1233                self.sanitize_gz_entry(
1234                    name,
1235                    inner_name,
1236                    &mut reader,
1237                    &mut writer,
1238                    &mut stats,
1239                    None,
1240                    0,
1241                )?;
1242            }
1243            _ => {
1244                self.scan_streaming(name, &mut reader, &mut writer, &mut stats)?;
1245            }
1246        }
1247        stats.files_processed += 1;
1248        Ok(stats)
1249    }
1250
1251    // Tar processing
1252    // -----------------------------------------------------------------------
1253
1254    /// Process a `.tar` archive, sanitizing each file entry and
1255    /// rebuilding the archive with preserved metadata.
1256    ///
1257    /// Entries that are not regular files (directories, symlinks, etc.)
1258    /// are copied through unchanged.
1259    ///
1260    /// # Errors
1261    ///
1262    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1263    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1264    pub fn process_tar<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
1265        self.process_tar_at_depth(reader, writer, 0)
1266    }
1267
1268    /// Internal: process a tar archive at a given nesting depth.
1269    ///
1270    /// Uses a speculative-buffer strategy to decide between parallel and
1271    /// sequential processing:
1272    ///
1273    /// - **Parallel** (total buffered data ≤ `PARALLEL_TAR_DATA_SIZE` AND
1274    ///   file count ≥ threshold AND not inside a rayon worker): buffer all
1275    ///   entries, sanitize concurrently with rayon, write in source order.
1276    /// - **Sequential — buffered** (threshold not met but data fits): process
1277    ///   entries from the in-memory buffer one at a time.
1278    /// - **Sequential — streaming** (data exceeds cap mid-stream): process
1279    ///   already-buffered entries from memory, then continue streaming the
1280    ///   remainder of the archive without additional buffering.
1281    ///
1282    /// Unlike zip, tar has no central directory so sizes cannot be known before
1283    /// reading. The buffer cap (`PARALLEL_TAR_DATA_SIZE`) bounds peak memory to
1284    /// cap + one entry overhead regardless of archive size.
1285    #[allow(clippy::too_many_lines)]
1286    fn process_tar_at_depth<R: Read, W: Write>(
1287        &self,
1288        reader: R,
1289        writer: W,
1290        depth: u32,
1291    ) -> Result<ArchiveStats> {
1292        struct TarEntry {
1293            header: tar::Header,
1294            path: String,
1295            link_name: Option<PathBuf>,
1296            is_file: bool,
1297            passes_filter: bool,
1298            data: Vec<u8>,
1299        }
1300
1301        let mut archive = tar::Archive::new(reader);
1302        let mut builder = tar::Builder::new(writer);
1303        let mut stats = ArchiveStats::default();
1304
1305        // --- Phase 1: speculative buffering ----------------------------------
1306        // Stream entries into memory, tracking total file-data size.
1307        // Stop buffering (but keep the last entry) if the cap is exceeded.
1308        let mut entries_iter = archive
1309            .entries()
1310            .map_err(|e| SanitizeError::ArchiveError(format!("read tar entries: {e}")))?;
1311
1312        let mut buffered: Vec<TarEntry> = Vec::new();
1313        let mut file_count: usize = 0;
1314        let mut total_data: u64 = 0;
1315        let mut overflowed = false;
1316
1317        for entry_result in entries_iter.by_ref() {
1318            let mut entry = entry_result
1319                .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
1320
1321            let header = entry.header().clone();
1322            let path = entry
1323                .path()
1324                .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1325                .to_string_lossy()
1326                .into_owned();
1327            let link_name = entry.link_name().ok().flatten().map(|c| c.into_owned());
1328            let is_file = header.entry_type().is_file();
1329            let passes_filter = !is_file || self.filter.passes(&path);
1330
1331            let mut data = Vec::new();
1332            entry
1333                .read_to_end(&mut data)
1334                .map_err(|e| SanitizeError::ArchiveError(format!("read entry '{path}': {e}")))?;
1335            drop(entry);
1336
1337            if is_file && passes_filter {
1338                file_count += 1;
1339                total_data = total_data.saturating_add(data.len() as u64);
1340            }
1341
1342            buffered.push(TarEntry {
1343                header,
1344                path,
1345                link_name,
1346                is_file,
1347                passes_filter,
1348                data,
1349            });
1350
1351            if total_data > PARALLEL_TAR_DATA_SIZE {
1352                overflowed = true;
1353                break;
1354            }
1355        }
1356
1357        // --- Phase 2: choose strategy ----------------------------------------
1358        let use_parallel = !overflowed
1359            && file_count >= self.parallel_threshold
1360            && rayon::current_thread_index().is_none();
1361
1362        if use_parallel {
1363            // --- Parallel path -----------------------------------------------
1364            // Sanitize all file entries concurrently; write in source order.
1365            let file_indices: Vec<usize> = buffered
1366                .iter()
1367                .enumerate()
1368                .filter(|(_, e)| e.is_file && e.passes_filter)
1369                .map(|(i, _)| i)
1370                .collect();
1371
1372            let results: Vec<ParEntryResult> = file_indices
1373                .into_par_iter()
1374                .map(|i| {
1375                    let e = &buffered[i];
1376                    let size_hint = e.header.size().ok();
1377                    (
1378                        i,
1379                        self.sanitize_entry_bytes(&e.path, &e.data, size_hint, depth),
1380                    )
1381                })
1382                .collect();
1383
1384            let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; buffered.len()];
1385            for (i, r) in results {
1386                sanitized[i] = Some(r?);
1387            }
1388
1389            for (i, entry) in buffered.iter().enumerate() {
1390                if !entry.is_file {
1391                    append_tar_entry(
1392                        &mut builder,
1393                        entry.header.clone(),
1394                        &entry.path,
1395                        entry.link_name.as_deref(),
1396                        &entry.data,
1397                    )?;
1398                    stats.entries_skipped += 1;
1399                    self.emit_progress(&stats, None, &entry.path);
1400                    continue;
1401                }
1402                if !entry.passes_filter {
1403                    stats.entries_filtered += 1;
1404                    self.emit_progress(&stats, None, &entry.path);
1405                    continue;
1406                }
1407
1408                let (sanitized_buf, entry_stats) =
1409                    sanitized[i].take().expect("parallel result missing");
1410                stats.merge(&entry_stats);
1411                self.emit_entry_bytes(&entry.path, &sanitized_buf);
1412
1413                append_tar_entry(
1414                    &mut builder,
1415                    entry.header.clone(),
1416                    &entry.path,
1417                    None,
1418                    &sanitized_buf,
1419                )?;
1420                stats.files_processed += 1;
1421                self.emit_progress(&stats, None, &entry.path);
1422            }
1423        } else {
1424            // --- Sequential path ---------------------------------------------
1425            // Process buffered entries first, then stream the remainder.
1426
1427            // Helper: write one buffered entry to the builder.
1428            let write_buffered = |entry: &TarEntry,
1429                                  builder: &mut tar::Builder<W>,
1430                                  stats: &mut ArchiveStats,
1431                                  processor: &ArchiveProcessor|
1432             -> Result<()> {
1433                if !entry.is_file {
1434                    append_tar_entry(
1435                        builder,
1436                        entry.header.clone(),
1437                        &entry.path,
1438                        entry.link_name.as_deref(),
1439                        &entry.data,
1440                    )?;
1441                    stats.entries_skipped += 1;
1442                    processor.emit_progress(stats, None, &entry.path);
1443                    return Ok(());
1444                }
1445                if !entry.passes_filter {
1446                    stats.entries_filtered += 1;
1447                    processor.emit_progress(stats, None, &entry.path);
1448                    return Ok(());
1449                }
1450                let size_hint = entry.header.size().ok();
1451                let (sanitized_buf, entry_stats) =
1452                    processor.sanitize_entry_bytes(&entry.path, &entry.data, size_hint, depth)?;
1453                stats.merge(&entry_stats);
1454                processor.emit_entry_bytes(&entry.path, &sanitized_buf);
1455                append_tar_entry(
1456                    builder,
1457                    entry.header.clone(),
1458                    &entry.path,
1459                    None,
1460                    &sanitized_buf,
1461                )?;
1462                stats.files_processed += 1;
1463                processor.emit_progress(stats, None, &entry.path);
1464                Ok(())
1465            };
1466
1467            for entry in &buffered {
1468                write_buffered(entry, &mut builder, &mut stats, self)?;
1469            }
1470            drop(buffered);
1471
1472            // Stream remaining entries when the buffer cap was exceeded.
1473            if overflowed {
1474                for entry_result in entries_iter {
1475                    let mut entry = entry_result
1476                        .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
1477
1478                    let header = entry.header().clone();
1479                    let path = entry
1480                        .path()
1481                        .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1482                        .to_string_lossy()
1483                        .into_owned();
1484                    let is_file = header.entry_type().is_file();
1485
1486                    if !is_file {
1487                        let link_name = entry.link_name().ok().flatten().map(|c| c.into_owned());
1488                        let mut data = Vec::new();
1489                        entry.read_to_end(&mut data).map_err(|e| {
1490                            SanitizeError::ArchiveError(format!("read '{path}': {e}"))
1491                        })?;
1492                        drop(entry);
1493                        append_tar_entry(&mut builder, header, &path, link_name.as_deref(), &data)?;
1494                        stats.entries_skipped += 1;
1495                        self.emit_progress(&stats, None, &path);
1496                        continue;
1497                    }
1498
1499                    if !self.filter.passes(&path) {
1500                        stats.entries_filtered += 1;
1501                        continue;
1502                    }
1503
1504                    let size_hint = header.size().ok();
1505                    let mut sanitized_buf = Vec::new();
1506                    let mut entry_stats = ArchiveStats::default();
1507                    self.sanitize_entry(
1508                        &path,
1509                        &mut entry,
1510                        &mut sanitized_buf,
1511                        &mut entry_stats,
1512                        size_hint,
1513                        depth,
1514                    )?;
1515                    drop(entry);
1516                    self.emit_entry_bytes(&path, &sanitized_buf);
1517
1518                    append_tar_entry(&mut builder, header, &path, None, &sanitized_buf)?;
1519
1520                    stats.merge(&entry_stats);
1521                    stats.files_processed += 1;
1522                    self.emit_progress(&stats, None, &path);
1523                }
1524            }
1525        }
1526
1527        builder
1528            .finish()
1529            .map_err(|e| SanitizeError::ArchiveError(format!("finalize tar: {e}")))?;
1530
1531        Ok(stats)
1532    }
1533
1534    /// Process a `.tar.gz` archive (gzip-compressed tar).
1535    ///
1536    /// Decompresses on the fly, processes each entry, and recompresses
1537    /// the output.
1538    ///
1539    /// # Errors
1540    ///
1541    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1542    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1543    pub fn process_tar_gz<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
1544        self.process_tar_gz_at_depth(reader, writer, 0)
1545    }
1546
1547    /// Internal: process a tar.gz archive at a given nesting depth.
1548    fn process_tar_gz_at_depth<R: Read, W: Write>(
1549        &self,
1550        reader: R,
1551        writer: W,
1552        depth: u32,
1553    ) -> Result<ArchiveStats> {
1554        let gz_reader = flate2::read::GzDecoder::new(reader);
1555        let gz_writer = flate2::write::GzEncoder::new(writer, flate2::Compression::fast());
1556
1557        let stats = self.process_tar_at_depth(gz_reader, gz_writer, depth)?;
1558        // GzEncoder is flushed when the tar builder finishes and the
1559        // encoder is dropped. The `finish()` call in `process_tar`
1560        // flushes the tar builder, which flushes writes to the
1561        // GzEncoder. When the GzEncoder is dropped it finalises the
1562        // gzip stream.
1563        Ok(stats)
1564    }
1565
1566    // -----------------------------------------------------------------------
1567    // Zip processing
1568    // -----------------------------------------------------------------------
1569
1570    /// Process a `.zip` archive, sanitizing each file entry and
1571    /// rebuilding the archive with preserved metadata.
1572    ///
1573    /// # Type Bounds
1574    ///
1575    /// Zip requires seekable I/O for both reading and writing.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1580    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1581    pub fn process_zip<R: Read + Seek, W: Write + Seek>(
1582        &self,
1583        reader: R,
1584        writer: W,
1585    ) -> Result<ArchiveStats> {
1586        self.process_zip_at_depth(reader, writer, 0)
1587    }
1588
1589    /// Internal: process a zip archive at a given nesting depth.
1590    ///
1591    /// Uses a lightweight metadata pre-pass (local-header reads, no data
1592    /// decompression) to decide between parallel and sequential strategies:
1593    ///
1594    /// - **Parallel** (total uncompressed ≤ `PARALLEL_ZIP_DATA_SIZE` AND
1595    ///   file count ≥ threshold AND depth == 0): load all entry data into
1596    ///   memory, sanitize with rayon, write in order.
1597    /// - **Sequential** (everything else): read → sanitize → write one entry
1598    ///   at a time.  Peak memory is bounded to 2 × largest single entry.
1599    #[allow(clippy::too_many_lines)]
1600    fn process_zip_at_depth<R: Read + Seek, W: Write + Seek>(
1601        &self,
1602        reader: R,
1603        writer: W,
1604        depth: u32,
1605    ) -> Result<ArchiveStats> {
1606        // --- Stage 0: metadata pre-pass (no data reads) ---------------------
1607        // Read local file headers to collect names, sizes, and options.
1608        // This does N seeks but decompresses nothing, keeping memory flat.
1609        struct ZipMeta {
1610            name: String,
1611            is_dir: bool,
1612            compression: zip::CompressionMethod,
1613            last_modified: Option<zip::DateTime>,
1614            unix_mode: Option<u32>,
1615            size: u64,
1616        }
1617
1618        let mut zip_in = zip::ZipArchive::new(reader)
1619            .map_err(|e| SanitizeError::ArchiveError(format!("open zip: {}", e)))?;
1620        let total_entries = zip_in.len();
1621        let total_entries_hint = Some(total_entries as u64);
1622
1623        let mut metas: Vec<ZipMeta> = Vec::with_capacity(total_entries);
1624        let mut file_count = 0usize;
1625        let mut total_uncompressed_size: u64 = 0;
1626
1627        for i in 0..total_entries {
1628            let entry = zip_in
1629                .by_index(i)
1630                .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1631            let is_dir = entry.is_dir();
1632            let size = entry.size();
1633            if !is_dir {
1634                file_count += 1;
1635                total_uncompressed_size = total_uncompressed_size.saturating_add(size);
1636            }
1637            metas.push(ZipMeta {
1638                name: sanitize_zip_entry_name(entry.name()),
1639                is_dir,
1640                compression: entry.compression(),
1641                last_modified: entry.last_modified(),
1642                unix_mode: entry.unix_mode(),
1643                size,
1644            });
1645            // entry dropped here — no data decompressed
1646        }
1647
1648        // Parallel only when the total data fits comfortably in memory.
1649        // Parallel when: enough entries, data fits in memory, and we are not
1650        // already running inside a rayon worker thread (nested parallelism
1651        // would over-subscribe the pool without proportional gains).
1652        let use_parallel = file_count >= self.parallel_threshold
1653            && rayon::current_thread_index().is_none()
1654            && total_uncompressed_size <= PARALLEL_ZIP_DATA_SIZE;
1655
1656        let mut stats = ArchiveStats::default();
1657
1658        // Helper: build SimpleFileOptions for a metadata entry.
1659        let make_options = |m: &ZipMeta| {
1660            let mut opts =
1661                zip::write::SimpleFileOptions::default().compression_method(m.compression);
1662            if let Some(dt) = m.last_modified {
1663                opts = opts.last_modified_time(dt);
1664            }
1665            if let Some(mode) = m.unix_mode {
1666                opts.unix_permissions(mode)
1667            } else {
1668                opts
1669            }
1670        };
1671
1672        if use_parallel {
1673            // --- Parallel path: load all data then sanitize concurrently ----
1674            struct ZipEntry {
1675                meta_idx: usize,
1676                data: Vec<u8>,
1677            }
1678
1679            let mut file_entries: Vec<ZipEntry> = Vec::with_capacity(file_count);
1680
1681            for (i, meta) in metas.iter().enumerate() {
1682                if meta.is_dir {
1683                    continue;
1684                }
1685                // Skip loading data for entries that will be filtered out.
1686                if !self.filter.passes(&meta.name) {
1687                    continue;
1688                }
1689                let mut entry = zip_in
1690                    .by_index(i)
1691                    .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1692                let mut data = Vec::new();
1693                entry.read_to_end(&mut data).map_err(|e| {
1694                    SanitizeError::ArchiveError(format!("read zip entry '{}': {}", meta.name, e))
1695                })?;
1696                file_entries.push(ZipEntry { meta_idx: i, data });
1697            }
1698
1699            let results: Vec<ParEntryResult> = file_entries
1700                .into_par_iter()
1701                .map(|e| {
1702                    let meta = &metas[e.meta_idx];
1703                    let result =
1704                        self.sanitize_entry_bytes(&meta.name, &e.data, Some(meta.size), depth);
1705                    (e.meta_idx, result)
1706                })
1707                .collect();
1708
1709            // Collect into a positional Vec (indexed by metas position) for
1710            // O(1) ordered writes, avoiding HashMap hashing overhead.
1711            let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; metas.len()];
1712            for (meta_idx, r) in results {
1713                sanitized[meta_idx] = Some(r?);
1714            }
1715
1716            let mut zip_out = zip::ZipWriter::new(writer);
1717            for (i, meta) in metas.iter().enumerate() {
1718                let options = make_options(meta);
1719                if meta.is_dir {
1720                    zip_out.add_directory(&meta.name, options).map_err(|e| {
1721                        SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1722                    })?;
1723                    stats.entries_skipped += 1;
1724                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1725                    continue;
1726                }
1727                // Filter: drop entries not matching --only/--exclude rules.
1728                if !self.filter.passes(&meta.name) {
1729                    stats.entries_filtered += 1;
1730                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1731                    continue;
1732                }
1733                let (sanitized_buf, entry_stats) = sanitized[i]
1734                    .take()
1735                    .expect("file entry sanitization result missing");
1736                stats.merge(&entry_stats);
1737                self.emit_entry_bytes(&meta.name, &sanitized_buf);
1738                zip_out.start_file(&meta.name, options).map_err(|e| {
1739                    SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1740                })?;
1741                zip_out.write_all(&sanitized_buf).map_err(|e| {
1742                    SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1743                })?;
1744                stats.files_processed += 1;
1745                self.emit_progress(&stats, total_entries_hint, &meta.name);
1746            }
1747            zip_out
1748                .finish()
1749                .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1750        } else {
1751            // --- Sequential path: one entry at a time -----------------------
1752            // Only one entry's data (input + sanitized output) is live at once.
1753            let mut zip_out = zip::ZipWriter::new(writer);
1754            for (i, meta) in metas.iter().enumerate() {
1755                let options = make_options(meta);
1756                if meta.is_dir {
1757                    zip_out.add_directory(&meta.name, options).map_err(|e| {
1758                        SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1759                    })?;
1760                    stats.entries_skipped += 1;
1761                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1762                    continue;
1763                }
1764
1765                // Filter: drop entries not matching --only/--exclude rules.
1766                if !self.filter.passes(&meta.name) {
1767                    stats.entries_filtered += 1;
1768                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1769                    continue;
1770                }
1771
1772                let data = {
1773                    let mut entry = zip_in.by_index(i).map_err(|e| {
1774                        SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e))
1775                    })?;
1776                    let mut buf = Vec::new();
1777                    entry.read_to_end(&mut buf).map_err(|e| {
1778                        SanitizeError::ArchiveError(format!(
1779                            "read zip entry '{}': {}",
1780                            meta.name, e
1781                        ))
1782                    })?;
1783                    buf
1784                    // entry dropped here
1785                };
1786
1787                let (sanitized_buf, entry_stats) =
1788                    self.sanitize_entry_bytes(&meta.name, &data, Some(meta.size), depth)?;
1789                drop(data);
1790                self.emit_entry_bytes(&meta.name, &sanitized_buf);
1791
1792                zip_out.start_file(&meta.name, options).map_err(|e| {
1793                    SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1794                })?;
1795                zip_out.write_all(&sanitized_buf).map_err(|e| {
1796                    SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1797                })?;
1798                drop(sanitized_buf);
1799
1800                stats.merge(&entry_stats);
1801                stats.files_processed += 1;
1802                self.emit_progress(&stats, total_entries_hint, &meta.name);
1803            }
1804            zip_out
1805                .finish()
1806                .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1807        }
1808
1809        Ok(stats)
1810    }
1811
1812    // -----------------------------------------------------------------------
1813    // Format-aware dispatch
1814    // -----------------------------------------------------------------------
1815
1816    /// Auto-detect the archive format and process accordingly.
1817    ///
1818    /// For zip archives the reader must additionally implement `Seek`.
1819    /// This method accepts `Read + Seek` to cover all formats uniformly.
1820    /// Tar and tar.gz do not require seeking, but the bound is imposed
1821    /// for a single entry point.
1822    ///
1823    /// # Errors
1824    ///
1825    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1826    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1827    pub fn process<R: Read + Seek, W: Write + Seek>(
1828        &self,
1829        reader: R,
1830        writer: W,
1831        format: ArchiveFormat,
1832    ) -> Result<ArchiveStats> {
1833        match format {
1834            ArchiveFormat::Zip => self.process_zip(reader, writer),
1835            ArchiveFormat::Tar => self.process_tar(reader, writer),
1836            ArchiveFormat::TarGz => self.process_tar_gz(reader, writer),
1837            // No file name is available here, so the inner name cannot drive
1838            // structured-format detection — prefer `process_gz` with the real
1839            // file name for standalone gzip inputs.
1840            ArchiveFormat::Gz => self.process_gz("input.gz", reader, writer),
1841        }
1842    }
1843}
1844
1845// ---------------------------------------------------------------------------
1846// Counting reader wrapper (for input byte tracking)
1847// ---------------------------------------------------------------------------
1848
1849/// A thin wrapper around a reader that counts bytes read.
1850struct CountingReader<'a> {
1851    inner: &'a mut dyn Read,
1852    count: u64,
1853}
1854
1855impl<'a> CountingReader<'a> {
1856    fn new(inner: &'a mut dyn Read) -> Self {
1857        Self { inner, count: 0 }
1858    }
1859
1860    fn bytes_read(&self) -> u64 {
1861        self.count
1862    }
1863}
1864
1865impl Read for CountingReader<'_> {
1866    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1867        let n = self.inner.read(buf)?;
1868        self.count += n as u64;
1869        Ok(n)
1870    }
1871}
1872
1873/// A thin wrapper around a writer that counts bytes written (F-02 fix).
1874struct CountingWriter<'a> {
1875    inner: &'a mut dyn Write,
1876    count: u64,
1877}
1878
1879impl<'a> CountingWriter<'a> {
1880    fn new(inner: &'a mut dyn Write) -> Self {
1881        Self { inner, count: 0 }
1882    }
1883
1884    fn bytes_written(&self) -> u64 {
1885        self.count
1886    }
1887}
1888
1889impl Write for CountingWriter<'_> {
1890    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1891        let n = self.inner.write(buf)?;
1892        self.count += n as u64;
1893        Ok(n)
1894    }
1895
1896    fn flush(&mut self) -> io::Result<()> {
1897        self.inner.flush()
1898    }
1899}
1900
1901// ---------------------------------------------------------------------------
1902// Tests
1903// ---------------------------------------------------------------------------
1904
1905#[cfg(test)]
1906mod tests {
1907    use super::*;
1908    use crate::category::Category;
1909    use crate::generator::HmacGenerator;
1910    use crate::processor::profile::{FieldRule, FileTypeProfile};
1911    use crate::processor::registry::ProcessorRegistry;
1912    use crate::scanner::{ScanConfig, ScanPattern};
1913    use std::io::Cursor;
1914    use std::sync::Mutex;
1915
1916    /// Build a test archive processor with an email pattern and a JSON profile.
1917    fn make_archive_processor() -> ArchiveProcessor {
1918        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1919        let store = Arc::new(MappingStore::new(gen, None));
1920
1921        let patterns = vec![
1922            ScanPattern::from_regex(
1923                r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
1924                Category::Email,
1925                "email",
1926            )
1927            .unwrap(),
1928            ScanPattern::from_literal("SUPERSECRET", Category::Custom("api_key".into()), "api_key")
1929                .unwrap(),
1930        ];
1931
1932        let scanner = Arc::new(
1933            StreamScanner::new(patterns, Arc::clone(&store), ScanConfig::default()).unwrap(),
1934        );
1935
1936        let registry = Arc::new(ProcessorRegistry::with_builtins());
1937
1938        let profiles = vec![FileTypeProfile::new(
1939            "json",
1940            vec![FieldRule::new("*").with_category(Category::Custom("field".into()))],
1941        )
1942        .with_extension(".json")];
1943
1944        ArchiveProcessor::new(registry, scanner, store, profiles)
1945    }
1946
1947    // -- command_output discovery seeds other entries ------------------------
1948
1949    #[test]
1950    fn command_output_discovery_scrubs_bare_occurrences_in_other_entries() {
1951        // A hostname captured from a `> hostname --fqdn` block must also be
1952        // scrubbed where it appears bare (no keyword context) in sibling
1953        // entries. Mirrors the CLI's two-phase flow: discovery pre-pass
1954        // populates the store, then the main pass runs with the discovered
1955        // values seeded into the scanner as literals.
1956        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1957        let store = Arc::new(MappingStore::new(gen, None));
1958        let base_scanner = Arc::new(
1959            StreamScanner::new(vec![], Arc::clone(&store), ScanConfig::default()).unwrap(),
1960        );
1961        let registry = Arc::new(ProcessorRegistry::with_builtins());
1962        let profiles = vec![FileTypeProfile::new(
1963            "command_output",
1964            vec![FieldRule::new("hostname*")
1965                .with_category(Category::Hostname)
1966                .with_min_length(2)],
1967        )
1968        .with_extension(".txt")
1969        .with_include("diag.txt")];
1970
1971        let input = build_test_tar(&[
1972            (
1973                "diag.txt",
1974                b"> hostname --fqdn\ndss-prod-01.corp.example.com\n" as &[u8],
1975            ),
1976            (
1977                "backend.log",
1978                b"connecting to dss-prod-01.corp.example.com:10001 failed\n",
1979            ),
1980        ]);
1981
1982        // Phase 1: discovery populates the store from profile-matched entries.
1983        let discovery = ArchiveProcessor::new(
1984            Arc::clone(&registry),
1985            Arc::clone(&base_scanner),
1986            Arc::clone(&store),
1987            profiles.clone(),
1988        );
1989        discovery.discover_profiles_tar(&input[..]).unwrap();
1990
1991        // Phase 2: main pass with discovered values seeded as literals.
1992        let extra: Vec<ScanPattern> = store
1993            .iter()
1994            .map(|(category, original, _)| {
1995                ScanPattern::from_literal(original.as_str(), category, "discovered").unwrap()
1996            })
1997            .collect();
1998        assert!(!extra.is_empty(), "discovery must have found the hostname");
1999        let augmented = Arc::new(base_scanner.with_extra_literals(extra).unwrap());
2000        let proc = ArchiveProcessor::new(registry, augmented, store, profiles);
2001
2002        let mut output = Vec::new();
2003        proc.process_tar(&input[..], &mut output).unwrap();
2004        let out = String::from_utf8_lossy(&output);
2005
2006        assert!(
2007            !out.contains("dss-prod-01.corp.example.com"),
2008            "hostname must be gone from every entry: {out}"
2009        );
2010        assert!(
2011            out.contains("connecting to ") && out.contains(":10001 failed"),
2012            "log context preserved: {out}"
2013        );
2014    }
2015
2016    // -- Entropy pass -------------------------------------------------------
2017
2018    #[test]
2019    fn entropy_configs_apply_to_archive_entries() {
2020        // `kind: entropy` rules and --entropy-threshold must work inside
2021        // archives: a raw single-value secret file (no pattern match, no
2022        // profile) previously passed through archives untouched because the
2023        // entropy pass only ran in the CLI dispatch layer.
2024        let shared_secret = "FLwnxzdPdfIwcrav7PpjWEoYEc55HAcl2Aad0w8rfSsUvYoJHOlqlngm5UzH1zKJ";
2025        let input = build_test_tar(&[
2026            ("run/shared-secret.txt", shared_secret.as_bytes()),
2027            ("notes.txt", b"plain low entropy words only"),
2028        ]);
2029
2030        let proc = make_archive_processor().with_entropy_configs(vec![crate::EntropyConfig {
2031            min_length: 40,
2032            charset: crate::EntropyCharset::Base64,
2033            ..Default::default()
2034        }]);
2035
2036        let mut output = Vec::new();
2037        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2038
2039        let out = String::from_utf8_lossy(&output);
2040        assert!(
2041            !out.contains(shared_secret),
2042            "high-entropy entry content must be replaced"
2043        );
2044        assert!(
2045            out.contains("plain low entropy words only"),
2046            "low-entropy entry must pass through"
2047        );
2048        let entry_stats = &stats.file_scan_stats["run/shared-secret.txt"];
2049        assert_eq!(entry_stats.pattern_counts["high_entropy_token"], 1);
2050    }
2051
2052    // -- Tar tests ----------------------------------------------------------
2053
2054    fn build_test_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
2055        let mut buf = Vec::new();
2056        {
2057            let mut builder = tar::Builder::new(&mut buf);
2058            for (name, data) in entries {
2059                let mut header = tar::Header::new_gnu();
2060                header.set_size(data.len() as u64);
2061                header.set_mode(0o644);
2062                header.set_mtime(1_700_000_000);
2063                header.set_cksum();
2064                builder.append_data(&mut header, *name, *data).unwrap();
2065            }
2066            builder.finish().unwrap();
2067        }
2068        buf
2069    }
2070
2071    #[test]
2072    fn tar_sanitizes_plaintext_with_scanner() {
2073        let proc = make_archive_processor();
2074        let input = build_test_tar(&[("readme.txt", b"Contact alice@corp.com for help.")]);
2075
2076        let mut output = Vec::new();
2077        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2078
2079        assert_eq!(stats.files_processed, 1);
2080        assert_eq!(stats.scanner_fallback, 1);
2081        assert_eq!(stats.structured_hits, 0);
2082
2083        // Verify the output is a valid tar and the secret is gone.
2084        let mut archive = tar::Archive::new(&output[..]);
2085        for entry in archive.entries().unwrap() {
2086            let mut e = entry.unwrap();
2087            let mut content = String::new();
2088            e.read_to_string(&mut content).unwrap();
2089            assert!(
2090                !content.contains("alice@corp.com"),
2091                "email should be sanitized: {content}"
2092            );
2093        }
2094    }
2095
2096    #[test]
2097    fn tar_sanitizes_json_with_structured_processor() {
2098        let proc = make_archive_processor();
2099        let json_content = br#"{"email": "bob@example.org", "name": "Bob"}"#;
2100        let input = build_test_tar(&[("config.json", json_content)]);
2101
2102        let mut output = Vec::new();
2103        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2104
2105        assert_eq!(stats.files_processed, 1);
2106        assert_eq!(stats.structured_hits, 1);
2107        assert_eq!(stats.scanner_fallback, 0);
2108        assert_eq!(
2109            stats.file_methods.get("config.json").unwrap(),
2110            "structured+scan:json"
2111        );
2112
2113        // Verify sanitized output.
2114        let mut archive = tar::Archive::new(&output[..]);
2115        for entry in archive.entries().unwrap() {
2116            let mut e = entry.unwrap();
2117            let mut content = String::new();
2118            e.read_to_string(&mut content).unwrap();
2119            assert!(
2120                !content.contains("bob@example.org"),
2121                "email should be sanitized"
2122            );
2123            assert!(!content.contains("Bob"), "name should be sanitized");
2124        }
2125    }
2126
2127    // -- Single-file gzip entries --------------------------------------------
2128
2129    fn gzip_bytes(data: &[u8]) -> Vec<u8> {
2130        let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2131        enc.write_all(data).unwrap();
2132        enc.finish().unwrap()
2133    }
2134
2135    fn read_tar_entry(output: &[u8], name: &str) -> Vec<u8> {
2136        let mut archive = tar::Archive::new(output);
2137        for entry in archive.entries().unwrap() {
2138            let mut e = entry.unwrap();
2139            if e.path().unwrap().to_string_lossy() == name {
2140                let mut buf = Vec::new();
2141                e.read_to_end(&mut buf).unwrap();
2142                return buf;
2143            }
2144        }
2145        panic!("entry '{name}' not found in output tar");
2146    }
2147
2148    #[test]
2149    fn gz_entry_is_decompressed_scanned_and_recompressed() {
2150        // Regression (GitLab SOS eval): a rotated log like
2151        // `db-migrate.log.1.gz` must be sanitized as its decompressed content
2152        // and remain valid gzip afterwards — byte-level scanning of the
2153        // compressed stream corrupts it and can never match real plaintext.
2154        let proc = make_archive_processor();
2155        let inner = b"job start\ntoken=SUPERSECRET\njob end\n";
2156        let input = build_test_tar(&[("logs/db-migrate.log.1.gz", &gzip_bytes(inner)[..])]);
2157
2158        let mut output = Vec::new();
2159        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2160
2161        let entry = read_tar_entry(&output, "logs/db-migrate.log.1.gz");
2162        let mut dec = flate2::read::GzDecoder::new(&entry[..]);
2163        let mut text = String::new();
2164        dec.read_to_string(&mut text)
2165            .expect("output entry must still be valid gzip");
2166        assert!(
2167            !text.contains("SUPERSECRET"),
2168            "secret must be redacted inside the gzip: {text}"
2169        );
2170        assert!(
2171            text.contains("job start") && text.contains("job end"),
2172            "surrounding context preserved: {text}"
2173        );
2174        assert_eq!(
2175            stats.file_methods.get("logs/db-migrate.log.1.gz").unwrap(),
2176            "gzip:scanner"
2177        );
2178    }
2179
2180    #[test]
2181    fn gz_entry_with_structured_inner_name_uses_processor() {
2182        // `config.json.gz` must get the same structured treatment as
2183        // `config.json`: the profile matches on the decompressed inner name.
2184        let proc = make_archive_processor();
2185        let inner = br#"{"email": "bob@example.org"}"#;
2186        let input = build_test_tar(&[("config.json.gz", &gzip_bytes(inner)[..])]);
2187
2188        let mut output = Vec::new();
2189        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2190
2191        let entry = read_tar_entry(&output, "config.json.gz");
2192        let mut dec = flate2::read::GzDecoder::new(&entry[..]);
2193        let mut text = String::new();
2194        dec.read_to_string(&mut text).unwrap();
2195        assert!(!text.contains("bob@example.org"), "field redacted: {text}");
2196        assert_eq!(
2197            stats.file_methods.get("config.json.gz").unwrap(),
2198            "gzip:structured+scan:json"
2199        );
2200    }
2201
2202    #[test]
2203    fn gz_named_entry_that_is_not_gzip_falls_back_to_scanner() {
2204        // Plaintext (or a corrupt stream) behind a .gz name must still be
2205        // scanned, never passed through unexamined.
2206        let proc = make_archive_processor();
2207        let input = build_test_tar(&[("fake.log.gz", b"call alice@corp.com now" as &[u8])]);
2208
2209        let mut output = Vec::new();
2210        proc.process_tar(&input[..], &mut output).unwrap();
2211
2212        let entry = read_tar_entry(&output, "fake.log.gz");
2213        let text = String::from_utf8_lossy(&entry);
2214        assert!(
2215            !text.contains("alice@corp.com"),
2216            "fallback scan must redact the raw bytes: {text}"
2217        );
2218    }
2219
2220    #[test]
2221    fn standalone_gz_file_gets_structured_treatment() {
2222        // A `config.json.gz` passed directly as an input must get the same
2223        // treatment as a `.gz` entry inside an archive: decompress, sanitize
2224        // under the inner name (structured JSON here), recompress.
2225        let proc = make_archive_processor();
2226        let inner = br#"{"email": "bob@example.org"}"#;
2227        let input = gzip_bytes(inner);
2228
2229        let mut output = Vec::new();
2230        let stats = proc
2231            .process_gz("config.json.gz", &input[..], &mut output)
2232            .unwrap();
2233
2234        let mut dec = flate2::read::GzDecoder::new(&output[..]);
2235        let mut text = String::new();
2236        dec.read_to_string(&mut text)
2237            .expect("output must still be valid gzip");
2238        assert!(!text.contains("bob@example.org"), "field redacted: {text}");
2239        assert_eq!(
2240            stats.file_methods.get("config.json.gz").unwrap(),
2241            "gzip:structured+scan:json"
2242        );
2243        assert_eq!(stats.files_processed, 1);
2244    }
2245
2246    #[test]
2247    fn standalone_gz_that_is_not_gzip_falls_back_to_scanner() {
2248        // Plaintext behind a top-level .gz name must still be scanned raw,
2249        // never passed through unexamined.
2250        let proc = make_archive_processor();
2251        let mut output = Vec::new();
2252        proc.process_gz(
2253            "fake.log.gz",
2254            b"call alice@corp.com now" as &[u8],
2255            &mut output,
2256        )
2257        .unwrap();
2258
2259        let text = String::from_utf8_lossy(&output);
2260        assert!(
2261            !text.contains("alice@corp.com"),
2262            "fallback scan must redact the raw bytes: {text}"
2263        );
2264    }
2265
2266    #[test]
2267    fn standalone_gz_force_text_scans_compressed_bytes() {
2268        // --force-text keeps the old behavior: the raw byte scanner runs over
2269        // the compressed stream. With no matching patterns the output is the
2270        // input, byte for byte.
2271        let proc = make_archive_processor().with_force_text(true);
2272        let input = gzip_bytes(b"nothing sensitive here");
2273
2274        let mut output = Vec::new();
2275        proc.process_gz("data.txt.gz", &input[..], &mut output)
2276            .unwrap();
2277        assert_eq!(output, input, "force-text must not touch the gzip stream");
2278    }
2279
2280    #[test]
2281    fn archive_format_detects_standalone_gz() {
2282        assert_eq!(
2283            ArchiveFormat::from_path("x.log.gz"),
2284            Some(ArchiveFormat::Gz)
2285        );
2286        assert_eq!(
2287            ArchiveFormat::from_path("x.tar.gz"),
2288            Some(ArchiveFormat::TarGz)
2289        );
2290        assert_eq!(
2291            ArchiveFormat::from_path("x.tgz"),
2292            Some(ArchiveFormat::TarGz)
2293        );
2294        assert_eq!(ArchiveFormat::from_path(".gz"), None);
2295        assert_eq!(ArchiveFormat::from_path("plain.txt"), None);
2296    }
2297
2298    #[test]
2299    fn short_discovered_values_are_not_propagated_as_literals() {
2300        // Regression (GitLab SOS eval): a structured field holding a trivial
2301        // value ("ok", "2", "/") must not become a scan literal — as one it
2302        // rewrites every occurrence of those bytes in the entry, mangling
2303        // keys, digits, and timestamps.
2304        let proc = make_archive_processor();
2305        let json = br#"{"note": "ok", "tokens": "many-values-here"}"#;
2306        let input = build_test_tar(&[("config.json", json as &[u8])]);
2307
2308        let mut output = Vec::new();
2309        proc.process_tar(&input[..], &mut output).unwrap();
2310
2311        let text = String::from_utf8_lossy(&output).to_string();
2312        assert!(
2313            text.contains(r#""tokens""#),
2314            "the 'tokens' key must not be mangled by an 'ok' literal: {text}"
2315        );
2316        assert!(
2317            !text.contains("many-values-here"),
2318            "the long field value is still redacted: {text}"
2319        );
2320    }
2321
2322    /// Regression: structured entries inside archives must preserve comments,
2323    /// key order, and whitespace (byte-level), redacting only field values —
2324    /// not re-serialize the parsed tree. Uses a scanner with NO pre-loaded
2325    /// literals, so the field value is redacted purely via the per-entry
2326    /// discovery delta; the same value embedded in a comment is redacted too.
2327    #[test]
2328    fn archive_structured_entry_preserves_comments_and_formatting() {
2329        let gen = Arc::new(HmacGenerator::new([7u8; 32]));
2330        let store = Arc::new(MappingStore::new(gen, None));
2331        // Empty pattern set: redaction of the email can only come from the
2332        // structured field discovery, not from a base regex.
2333        let scanner = Arc::new(
2334            StreamScanner::new(vec![], Arc::clone(&store), ScanConfig::default()).unwrap(),
2335        );
2336        let registry = Arc::new(ProcessorRegistry::with_builtins());
2337        let profiles = vec![FileTypeProfile::new(
2338            "yaml",
2339            vec![FieldRule::new("owner_email").with_category(Category::Email)],
2340        )
2341        .with_extension(".yaml")];
2342        let proc = ArchiveProcessor::new(registry, scanner, store, profiles);
2343
2344        let yaml = b"# owner was secret.person@corp.test  (keep this comment)\nowner_email: secret.person@corp.test\nport: 8080\n";
2345        let input = build_test_tar(&[("settings.yaml", yaml)]);
2346
2347        let mut output = Vec::new();
2348        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2349        assert_eq!(stats.structured_hits, 1);
2350
2351        let mut archive = tar::Archive::new(&output[..]);
2352        let mut content = String::new();
2353        for entry in archive.entries().unwrap() {
2354            entry.unwrap().read_to_string(&mut content).unwrap();
2355        }
2356
2357        // Secret email gone from both the field and the comment.
2358        assert!(
2359            !content.contains("secret.person@corp.test"),
2360            "email must be redacted everywhere: {content}"
2361        );
2362        // Formatting preserved byte-for-byte around the redactions: the comment
2363        // line (with its trailing text) and the unrelated `port` line survive,
2364        // and the file was not re-serialized into a different shape.
2365        assert!(
2366            content.starts_with("# owner was "),
2367            "leading comment must be preserved: {content}"
2368        );
2369        assert!(
2370            content.contains("(keep this comment)"),
2371            "comment trailing text must be preserved: {content}"
2372        );
2373        assert!(
2374            content.contains("\nport: 8080\n"),
2375            "unrelated line and surrounding whitespace must be untouched: {content}"
2376        );
2377        assert!(
2378            content.contains("owner_email: "),
2379            "key and `: ` separator must be preserved: {content}"
2380        );
2381    }
2382
2383    #[test]
2384    fn tar_preserves_metadata() {
2385        let proc = make_archive_processor();
2386        let input = build_test_tar(&[("data.txt", b"SUPERSECRET token here")]);
2387
2388        let mut output = Vec::new();
2389        proc.process_tar(&input[..], &mut output).unwrap();
2390
2391        let mut archive = tar::Archive::new(&output[..]);
2392        for entry in archive.entries().unwrap() {
2393            let e = entry.unwrap();
2394            let hdr = e.header();
2395            assert_eq!(hdr.mode().unwrap(), 0o644);
2396            assert_eq!(hdr.mtime().unwrap(), 1_700_000_000);
2397        }
2398    }
2399
2400    #[test]
2401    fn tar_handles_multiple_files() {
2402        let proc = make_archive_processor();
2403        let input = build_test_tar(&[
2404            ("a.txt", b"alice@corp.com"),
2405            ("b.json", br#"{"key":"value"}"#),
2406            ("c.log", b"no secrets here"),
2407        ]);
2408
2409        let mut output = Vec::new();
2410        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2411
2412        assert_eq!(stats.files_processed, 3);
2413        // b.json matched the JSON profile
2414        assert_eq!(stats.structured_hits, 1);
2415        // a.txt and c.log fall back to scanner
2416        assert_eq!(stats.scanner_fallback, 2);
2417    }
2418
2419    #[test]
2420    fn tar_passes_through_directories() {
2421        let mut buf = Vec::new();
2422        {
2423            let mut builder = tar::Builder::new(&mut buf);
2424
2425            // Add a directory entry.
2426            let mut dir_header = tar::Header::new_gnu();
2427            dir_header.set_entry_type(tar::EntryType::Directory);
2428            dir_header.set_size(0);
2429            dir_header.set_mode(0o755);
2430            dir_header.set_cksum();
2431            builder
2432                .append_data(&mut dir_header, "mydir/", &b""[..])
2433                .unwrap();
2434
2435            // Add a file.
2436            let mut file_header = tar::Header::new_gnu();
2437            file_header.set_size(5);
2438            file_header.set_mode(0o644);
2439            file_header.set_cksum();
2440            builder
2441                .append_data(&mut file_header, "mydir/hello.txt", &b"hello"[..])
2442                .unwrap();
2443
2444            builder.finish().unwrap();
2445        }
2446
2447        let proc = make_archive_processor();
2448        let mut output = Vec::new();
2449        let stats = proc.process_tar(&buf[..], &mut output).unwrap();
2450
2451        assert_eq!(stats.entries_skipped, 1);
2452        assert_eq!(stats.files_processed, 1);
2453    }
2454
2455    #[test]
2456    fn tar_preserves_file_paths_longer_than_ustar_limit() {
2457        // Regression: GitLab SOS bundles nest files under a >100-byte
2458        // top-level directory; Header::set_path fails on such names.
2459        let long_dir = format!(
2460            "gitlabsos.sr-env-0a1b2c3d-omnibus_20260101-000000_{}",
2461            "gitaly-nginx-prometheus-psql-puma-redis-sidekiq"
2462        );
2463        let long_path = format!("{long_dir}/mpstat");
2464        assert!(long_path.len() > 100);
2465
2466        let proc = make_archive_processor();
2467        let input = build_test_tar(&[(long_path.as_str(), b"cpu stats here".as_slice())]);
2468
2469        let mut output = Vec::new();
2470        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2471        assert_eq!(stats.files_processed, 1);
2472
2473        let mut archive = tar::Archive::new(&output[..]);
2474        let paths: Vec<String> = archive
2475            .entries()
2476            .unwrap()
2477            .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
2478            .collect();
2479        assert_eq!(paths, vec![long_path]);
2480    }
2481
2482    #[test]
2483    fn tar_preserves_long_directory_and_symlink_entries() {
2484        let long_dir = format!("{}/", "d".repeat(120));
2485        let link_path = format!("{}/current-log", "l".repeat(120));
2486        let link_target = format!("{}/log.2026-06-26", "t".repeat(120));
2487
2488        let mut buf = Vec::new();
2489        {
2490            let mut builder = tar::Builder::new(&mut buf);
2491
2492            let mut dir_header = tar::Header::new_gnu();
2493            dir_header.set_entry_type(tar::EntryType::Directory);
2494            dir_header.set_size(0);
2495            dir_header.set_mode(0o755);
2496            dir_header.set_cksum();
2497            builder
2498                .append_data(&mut dir_header, long_dir.as_str(), &b""[..])
2499                .unwrap();
2500
2501            let mut link_header = tar::Header::new_gnu();
2502            link_header.set_entry_type(tar::EntryType::Symlink);
2503            link_header.set_size(0);
2504            link_header.set_cksum();
2505            builder
2506                .append_link(&mut link_header, link_path.as_str(), link_target.as_str())
2507                .unwrap();
2508
2509            builder.finish().unwrap();
2510        }
2511
2512        let proc = make_archive_processor();
2513        let mut output = Vec::new();
2514        let stats = proc.process_tar(&buf[..], &mut output).unwrap();
2515        assert_eq!(stats.entries_skipped, 2);
2516
2517        let mut archive = tar::Archive::new(&output[..]);
2518        let mut entries = archive.entries().unwrap();
2519
2520        let dir_entry = entries.next().unwrap().unwrap();
2521        assert_eq!(
2522            dir_entry
2523                .path()
2524                .unwrap()
2525                .to_string_lossy()
2526                .trim_end_matches('/'),
2527            long_dir.trim_end_matches('/')
2528        );
2529
2530        let link_entry = entries.next().unwrap().unwrap();
2531        assert_eq!(
2532            link_entry.path().unwrap().to_string_lossy(),
2533            link_path.as_str()
2534        );
2535        assert_eq!(
2536            link_entry.link_name().unwrap().unwrap().to_string_lossy(),
2537            link_target.as_str()
2538        );
2539    }
2540
2541    // -- Tar.gz tests -------------------------------------------------------
2542
2543    #[test]
2544    fn tar_gz_round_trip() {
2545        let proc = make_archive_processor();
2546
2547        // Build a tar and gzip it.
2548        let tar_data = build_test_tar(&[("secret.txt", b"Key is SUPERSECRET okay")]);
2549        let mut gz_input = Vec::new();
2550        {
2551            let mut encoder =
2552                flate2::write::GzEncoder::new(&mut gz_input, flate2::Compression::fast());
2553            encoder.write_all(&tar_data).unwrap();
2554            encoder.finish().unwrap();
2555        }
2556
2557        let mut gz_output = Vec::new();
2558        let stats = proc.process_tar_gz(&gz_input[..], &mut gz_output).unwrap();
2559
2560        assert_eq!(stats.files_processed, 1);
2561        assert_eq!(stats.scanner_fallback, 1);
2562
2563        // Decompress and verify.
2564        let decoder = flate2::read::GzDecoder::new(&gz_output[..]);
2565        let mut archive = tar::Archive::new(decoder);
2566        for entry in archive.entries().unwrap() {
2567            let mut e = entry.unwrap();
2568            let mut content = String::new();
2569            e.read_to_string(&mut content).unwrap();
2570            assert!(
2571                !content.contains("SUPERSECRET"),
2572                "secret should be sanitized: {content}"
2573            );
2574        }
2575    }
2576
2577    // -- Zip tests ----------------------------------------------------------
2578
2579    fn build_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
2580        let mut buf = Cursor::new(Vec::new());
2581        {
2582            let mut zip = zip::ZipWriter::new(&mut buf);
2583            for (name, data) in entries {
2584                let options = zip::write::SimpleFileOptions::default()
2585                    .compression_method(zip::CompressionMethod::Deflated);
2586                zip.start_file(*name, options).unwrap();
2587                zip.write_all(data).unwrap();
2588            }
2589            zip.finish().unwrap();
2590        }
2591        buf.into_inner()
2592    }
2593
2594    #[test]
2595    fn zip_sanitizes_plaintext_with_scanner() {
2596        let proc = make_archive_processor();
2597        let zip_data = build_test_zip(&[("notes.txt", b"Reach alice@corp.com for info.")]);
2598
2599        let reader = Cursor::new(&zip_data);
2600        let mut writer = Cursor::new(Vec::new());
2601        let stats = proc.process_zip(reader, &mut writer).unwrap();
2602
2603        assert_eq!(stats.files_processed, 1);
2604        assert_eq!(stats.scanner_fallback, 1);
2605
2606        // Verify the output zip.
2607        let out_data = writer.into_inner();
2608        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
2609        let mut entry = zip_out.by_index(0).unwrap();
2610        let mut content = String::new();
2611        entry.read_to_string(&mut content).unwrap();
2612        assert!(
2613            !content.contains("alice@corp.com"),
2614            "email should be sanitized: {content}"
2615        );
2616    }
2617
2618    #[test]
2619    fn zip_sanitizes_json_with_structured_processor() {
2620        let proc = make_archive_processor();
2621        let json_content = br#"{"password": "hunter2", "host": "db.internal"}"#;
2622        let zip_data = build_test_zip(&[("settings.json", json_content)]);
2623
2624        let reader = Cursor::new(&zip_data);
2625        let mut writer = Cursor::new(Vec::new());
2626        let stats = proc.process_zip(reader, &mut writer).unwrap();
2627
2628        assert_eq!(stats.files_processed, 1);
2629        assert_eq!(stats.structured_hits, 1);
2630
2631        let out_data = writer.into_inner();
2632        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
2633        let mut entry = zip_out.by_index(0).unwrap();
2634        let mut content = String::new();
2635        entry.read_to_string(&mut content).unwrap();
2636        assert!(!content.contains("hunter2"), "password should be sanitized");
2637        assert!(!content.contains("db.internal"), "host should be sanitized");
2638    }
2639
2640    #[test]
2641    fn zip_preserves_directory_entries() {
2642        let mut buf = Cursor::new(Vec::new());
2643        {
2644            let mut zip = zip::ZipWriter::new(&mut buf);
2645
2646            let dir_options = zip::write::SimpleFileOptions::default();
2647            zip.add_directory("subdir/", dir_options).unwrap();
2648
2649            let file_options = zip::write::SimpleFileOptions::default()
2650                .compression_method(zip::CompressionMethod::Stored);
2651            zip.start_file("subdir/data.txt", file_options).unwrap();
2652            zip.write_all(b"SUPERSECRET value").unwrap();
2653
2654            zip.finish().unwrap();
2655        }
2656
2657        let zip_data = buf.into_inner();
2658        let proc = make_archive_processor();
2659        let reader = Cursor::new(&zip_data);
2660        let mut writer = Cursor::new(Vec::new());
2661        let stats = proc.process_zip(reader, &mut writer).unwrap();
2662
2663        assert_eq!(stats.entries_skipped, 1); // directory
2664        assert_eq!(stats.files_processed, 1);
2665    }
2666
2667    #[test]
2668    fn zip_handles_multiple_files() {
2669        let proc = make_archive_processor();
2670        let zip_data = build_test_zip(&[
2671            ("file1.txt", b"alice@corp.com"),
2672            ("file2.json", br#"{"secret":"SUPERSECRET"}"#),
2673            ("file3.log", b"nothing to see"),
2674        ]);
2675
2676        let reader = Cursor::new(&zip_data);
2677        let mut writer = Cursor::new(Vec::new());
2678        let stats = proc.process_zip(reader, &mut writer).unwrap();
2679
2680        assert_eq!(stats.files_processed, 3);
2681        assert_eq!(stats.structured_hits, 1); // JSON
2682        assert_eq!(stats.scanner_fallback, 2); // .txt + .log
2683    }
2684
2685    #[test]
2686    fn tar_progress_callback_receives_updates() {
2687        let updates = Arc::new(Mutex::new(Vec::new()));
2688        let proc = make_archive_processor().with_progress_callback({
2689            let updates = Arc::clone(&updates);
2690            Arc::new(move |progress| {
2691                updates
2692                    .lock()
2693                    .expect("archive progress lock")
2694                    .push(progress.clone());
2695            })
2696        });
2697        let input = build_test_tar(&[("a.txt", b"alice@corp.com"), ("b.txt", b"SUPERSECRET")]);
2698
2699        let mut output = Vec::new();
2700        let stats = proc.process_tar(&input[..], &mut output).unwrap();
2701        let updates = updates.lock().unwrap();
2702
2703        assert_eq!(updates.len(), 2);
2704        assert_eq!(updates.last().unwrap().entries_seen, 2);
2705        assert_eq!(
2706            updates.last().unwrap().files_processed,
2707            stats.files_processed
2708        );
2709        assert_eq!(updates.last().unwrap().total_entries, None);
2710    }
2711
2712    #[test]
2713    fn zip_progress_callback_reports_total_entries() {
2714        let updates = Arc::new(Mutex::new(Vec::new()));
2715        let proc = make_archive_processor().with_progress_callback({
2716            let updates = Arc::clone(&updates);
2717            Arc::new(move |progress| {
2718                updates
2719                    .lock()
2720                    .expect("archive progress lock")
2721                    .push(progress.clone());
2722            })
2723        });
2724        let zip_data = build_test_zip(&[
2725            ("file1.txt", b"alice@corp.com"),
2726            ("file2.log", b"nothing to see"),
2727        ]);
2728
2729        let reader = Cursor::new(&zip_data);
2730        let mut writer = Cursor::new(Vec::new());
2731        let stats = proc.process_zip(reader, &mut writer).unwrap();
2732        let updates = updates.lock().unwrap();
2733
2734        assert_eq!(updates.len(), 2);
2735        assert_eq!(
2736            updates.last().unwrap().files_processed,
2737            stats.files_processed
2738        );
2739        assert_eq!(updates.last().unwrap().total_entries, Some(2));
2740        assert_eq!(updates.last().unwrap().current_entry, "file2.log");
2741    }
2742
2743    // -- Format detection tests ---------------------------------------------
2744
2745    #[test]
2746    fn format_detection_from_path() {
2747        assert_eq!(
2748            ArchiveFormat::from_path("data.tar"),
2749            Some(ArchiveFormat::Tar)
2750        );
2751        assert_eq!(
2752            ArchiveFormat::from_path("data.tar.gz"),
2753            Some(ArchiveFormat::TarGz)
2754        );
2755        assert_eq!(
2756            ArchiveFormat::from_path("data.tgz"),
2757            Some(ArchiveFormat::TarGz)
2758        );
2759        assert_eq!(
2760            ArchiveFormat::from_path("data.zip"),
2761            Some(ArchiveFormat::Zip)
2762        );
2763        assert_eq!(
2764            ArchiveFormat::from_path("DATA.ZIP"),
2765            Some(ArchiveFormat::Zip)
2766        );
2767        assert_eq!(ArchiveFormat::from_path("photo.png"), None);
2768    }
2769
2770    // -- Determinism / dedup tests ------------------------------------------
2771
2772    #[test]
2773    fn same_secret_gets_same_replacement_across_entries() {
2774        let proc = make_archive_processor();
2775        let input = build_test_tar(&[
2776            ("a.txt", b"contact alice@corp.com"),
2777            ("b.txt", b"reach alice@corp.com"),
2778        ]);
2779
2780        let mut output = Vec::new();
2781        proc.process_tar(&input[..], &mut output).unwrap();
2782
2783        let mut archive = tar::Archive::new(&output[..]);
2784        let mut contents: Vec<String> = Vec::new();
2785        for entry in archive.entries().unwrap() {
2786            let mut e = entry.unwrap();
2787            let mut s = String::new();
2788            e.read_to_string(&mut s).unwrap();
2789            contents.push(s);
2790        }
2791
2792        // Both files should have the *same* replacement for alice@corp.com.
2793        // Extract the replacement by removing the prefix.
2794        let replacement_a = contents[0].strip_prefix("contact ").unwrap();
2795        let replacement_b = contents[1].strip_prefix("reach ").unwrap();
2796        assert_eq!(
2797            replacement_a, replacement_b,
2798            "dedup should produce identical replacements"
2799        );
2800        assert!(!replacement_a.contains("alice@corp.com"));
2801    }
2802
2803    // -- Auto-dispatch test -------------------------------------------------
2804
2805    #[test]
2806    fn process_auto_dispatch_tar() {
2807        let proc = make_archive_processor();
2808        let tar_data = build_test_tar(&[("f.txt", b"SUPERSECRET")]);
2809
2810        let reader = Cursor::new(tar_data);
2811        let writer = Cursor::new(Vec::new());
2812        let stats = proc.process(reader, writer, ArchiveFormat::Tar).unwrap();
2813
2814        assert_eq!(stats.files_processed, 1);
2815    }
2816
2817    #[test]
2818    fn process_auto_dispatch_zip() {
2819        let proc = make_archive_processor();
2820        let zip_data = build_test_zip(&[("f.txt", b"SUPERSECRET")]);
2821
2822        let reader = Cursor::new(zip_data);
2823        let mut writer = Cursor::new(Vec::new());
2824        let stats = proc
2825            .process(reader, &mut writer, ArchiveFormat::Zip)
2826            .unwrap();
2827
2828        assert_eq!(stats.files_processed, 1);
2829    }
2830
2831    // -- Empty archive tests ------------------------------------------------
2832
2833    #[test]
2834    fn tar_empty_archive() {
2835        let proc = make_archive_processor();
2836        let tar_data = build_test_tar(&[]);
2837
2838        let mut output = Vec::new();
2839        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2840
2841        assert_eq!(stats.files_processed, 0);
2842        assert_eq!(stats.entries_skipped, 0);
2843    }
2844
2845    #[test]
2846    fn zip_empty_archive() {
2847        let proc = make_archive_processor();
2848        let zip_data = build_test_zip(&[]);
2849
2850        let reader = Cursor::new(zip_data);
2851        let mut writer = Cursor::new(Vec::new());
2852        let stats = proc.process_zip(reader, &mut writer).unwrap();
2853
2854        assert_eq!(stats.files_processed, 0);
2855    }
2856
2857    // sanitize_zip_entry_name
2858
2859    #[test]
2860    fn zip_entry_name_clean_passthrough() {
2861        assert_eq!(sanitize_zip_entry_name("logs/app.log"), "logs/app.log");
2862        assert_eq!(sanitize_zip_entry_name("config.yaml"), "config.yaml");
2863        assert_eq!(sanitize_zip_entry_name("a/b/c.txt"), "a/b/c.txt");
2864    }
2865
2866    #[test]
2867    fn zip_entry_name_strips_leading_slash() {
2868        assert_eq!(sanitize_zip_entry_name("/etc/passwd"), "etc/passwd");
2869        assert_eq!(sanitize_zip_entry_name("///etc/passwd"), "etc/passwd");
2870    }
2871
2872    #[test]
2873    fn zip_entry_name_strips_dotdot() {
2874        assert_eq!(sanitize_zip_entry_name("../etc/passwd"), "etc/passwd");
2875        assert_eq!(
2876            sanitize_zip_entry_name("a/../../etc/passwd"),
2877            "a/etc/passwd"
2878        );
2879        assert_eq!(
2880            sanitize_zip_entry_name("../../root/.ssh/id_rsa"),
2881            "root/.ssh/id_rsa"
2882        );
2883    }
2884
2885    #[test]
2886    fn zip_entry_name_strips_leading_dot_slash() {
2887        assert_eq!(sanitize_zip_entry_name("./config.yaml"), "config.yaml");
2888        assert_eq!(sanitize_zip_entry_name("././config.yaml"), "config.yaml");
2889    }
2890
2891    #[test]
2892    fn zip_entry_name_backslash_normalised() {
2893        assert_eq!(sanitize_zip_entry_name("a\\b\\c.txt"), "a/b/c.txt");
2894        assert_eq!(sanitize_zip_entry_name("..\\etc\\passwd"), "etc/passwd");
2895    }
2896
2897    #[test]
2898    fn zip_entry_name_empty_result_replaced() {
2899        assert_eq!(sanitize_zip_entry_name("../.."), "_");
2900        assert_eq!(sanitize_zip_entry_name(""), "_");
2901        assert_eq!(sanitize_zip_entry_name("/"), "_");
2902    }
2903
2904    #[test]
2905    fn zip_entry_name_absolute_dotdot_combo() {
2906        assert_eq!(sanitize_zip_entry_name("/../etc/passwd"), "etc/passwd");
2907    }
2908
2909    // -- ArchiveFilter tests ------------------------------------------------
2910
2911    #[test]
2912    fn filter_empty_passes_everything() {
2913        let f = ArchiveFilter::new(vec![], vec![]).unwrap();
2914        assert!(f.is_empty());
2915        assert!(f.passes("config/app.yaml"));
2916        assert!(f.passes("logs/server.log"));
2917    }
2918
2919    #[test]
2920    fn filter_only_glob_includes_match() {
2921        let f = ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap();
2922        assert!(!f.is_empty());
2923        assert!(f.passes("config/settings.json"));
2924        assert!(f.passes("deep/nested/file.json"));
2925        assert!(!f.passes("config/settings.yaml"));
2926    }
2927
2928    #[test]
2929    fn filter_only_dir_prefix_includes_subtree() {
2930        let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2931        assert!(f.passes("config/app.yaml"));
2932        assert!(f.passes("config/nested/db.yaml"));
2933        assert!(!f.passes("logs/server.log"));
2934    }
2935
2936    #[test]
2937    fn filter_dir_prefix_exact_match() {
2938        let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2939        // Exact prefix without trailing separator should also match.
2940        assert!(f.passes("config"));
2941    }
2942
2943    #[test]
2944    fn filter_exclude_removes_match() {
2945        let f = ArchiveFilter::new(vec![], vec!["**/*.log".into()]).unwrap();
2946        assert!(!f.passes("logs/server.log"));
2947        assert!(f.passes("config/app.yaml"));
2948    }
2949
2950    #[test]
2951    fn filter_only_and_exclude_combined() {
2952        let f =
2953            ArchiveFilter::new(vec!["config/".into()], vec!["config/secrets.yaml".into()]).unwrap();
2954        assert!(f.passes("config/app.yaml"));
2955        assert!(!f.passes("config/secrets.yaml"));
2956        assert!(!f.passes("logs/server.log"));
2957    }
2958
2959    #[test]
2960    fn filter_invalid_glob_returns_error() {
2961        assert!(ArchiveFilter::new(vec!["[invalid".into()], vec![]).is_err());
2962        assert!(ArchiveFilter::new(vec![], vec!["[bad".into()]).is_err());
2963    }
2964
2965    // -- ArchiveProcessor builder methods -----------------------------------
2966
2967    #[test]
2968    fn builder_with_max_depth_clamps_at_max() {
2969        let proc = make_archive_processor().with_max_depth(999);
2970        assert_eq!(proc.max_depth, MAX_ARCHIVE_DEPTH);
2971    }
2972
2973    #[test]
2974    fn builder_with_max_depth_sets_value() {
2975        let proc = make_archive_processor().with_max_depth(2);
2976        assert_eq!(proc.max_depth, 2);
2977    }
2978
2979    #[test]
2980    fn builder_with_parallel_threshold_sets_value() {
2981        let proc = make_archive_processor().with_parallel_threshold(usize::MAX);
2982        assert_eq!(proc.parallel_threshold, usize::MAX);
2983    }
2984
2985    #[test]
2986    fn builder_with_force_text_enables_flag() {
2987        let proc = make_archive_processor().with_force_text(true);
2988        assert!(proc.force_text);
2989    }
2990
2991    #[test]
2992    fn builder_with_filter_applied_to_zip() {
2993        let proc = make_archive_processor()
2994            .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2995
2996        let zip_data = build_test_zip(&[
2997            ("config.json", br#"{"email":"alice@corp.com"}"#),
2998            ("notes.txt", b"alice@corp.com"),
2999        ]);
3000
3001        let reader = Cursor::new(zip_data);
3002        let mut writer = Cursor::new(Vec::new());
3003        let stats = proc.process_zip(reader, &mut writer).unwrap();
3004
3005        // notes.txt is excluded by the filter — only config.json processed.
3006        assert_eq!(stats.files_processed, 1);
3007        assert_eq!(stats.entries_filtered, 1);
3008    }
3009
3010    #[test]
3011    fn builder_with_filter_applied_to_tar() {
3012        let proc = make_archive_processor()
3013            .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
3014
3015        let tar_data = build_test_tar(&[
3016            ("config.json", br#"{"email":"alice@corp.com"}"#),
3017            ("notes.txt", b"alice@corp.com"),
3018        ]);
3019
3020        let mut output = Vec::new();
3021        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3022
3023        assert_eq!(stats.files_processed, 1);
3024        assert_eq!(stats.entries_filtered, 1);
3025    }
3026
3027    // -- Parallel path tests ------------------------------------------------
3028
3029    #[test]
3030    fn parallel_tar_sanitizes_all_entries() {
3031        // parallel_threshold(0) forces parallel execution regardless of entry count.
3032        let proc = make_archive_processor().with_parallel_threshold(0);
3033        let tar_data = build_test_tar(&[
3034            ("a.txt", b"alice@corp.com"),
3035            ("b.txt", b"bob@corp.com"),
3036            ("c.txt", b"carol@corp.com"),
3037            ("d.txt", b"dave@corp.com"),
3038        ]);
3039
3040        let mut output = Vec::new();
3041        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3042
3043        assert_eq!(stats.files_processed, 4);
3044
3045        // Verify originals are gone (domain is preserved by email strategy, full addresses must not appear).
3046        let originals = [
3047            "alice@corp.com",
3048            "bob@corp.com",
3049            "carol@corp.com",
3050            "dave@corp.com",
3051        ];
3052        let mut archive = tar::Archive::new(&output[..]);
3053        for entry in archive.entries().unwrap() {
3054            let mut e = entry.unwrap();
3055            let mut content = String::new();
3056            e.read_to_string(&mut content).unwrap();
3057            for orig in &originals {
3058                assert!(
3059                    !content.contains(orig),
3060                    "original secret leaked in {:?}",
3061                    e.path()
3062                );
3063            }
3064        }
3065    }
3066
3067    #[test]
3068    fn parallel_tar_preserves_entry_order() {
3069        let proc = make_archive_processor().with_parallel_threshold(0);
3070        let tar_data = build_test_tar(&[
3071            ("first.txt", b"alice@corp.com"),
3072            ("second.txt", b"hello"),
3073            ("third.txt", b"bob@corp.com"),
3074        ]);
3075
3076        let mut output = Vec::new();
3077        proc.process_tar(&tar_data[..], &mut output).unwrap();
3078
3079        let mut archive = tar::Archive::new(&output[..]);
3080        let names: Vec<String> = archive
3081            .entries()
3082            .unwrap()
3083            .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
3084            .collect();
3085
3086        assert_eq!(names, vec!["first.txt", "second.txt", "third.txt"]);
3087    }
3088
3089    #[test]
3090    fn parallel_zip_sanitizes_all_entries() {
3091        let proc = make_archive_processor().with_parallel_threshold(0);
3092        let zip_data = build_test_zip(&[
3093            ("a.txt", b"alice@corp.com"),
3094            ("b.txt", b"bob@corp.com"),
3095            ("c.txt", b"carol@corp.com"),
3096            ("d.txt", b"dave@corp.com"),
3097        ]);
3098
3099        let reader = Cursor::new(zip_data);
3100        let mut writer = Cursor::new(Vec::new());
3101        let stats = proc.process_zip(reader, &mut writer).unwrap();
3102
3103        assert_eq!(stats.files_processed, 4);
3104
3105        let originals = [
3106            "alice@corp.com",
3107            "bob@corp.com",
3108            "carol@corp.com",
3109            "dave@corp.com",
3110        ];
3111        let out_data = writer.into_inner();
3112        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
3113        for i in 0..zip_out.len() {
3114            let mut entry = zip_out.by_index(i).unwrap();
3115            let mut content = String::new();
3116            entry.read_to_string(&mut content).unwrap();
3117            for orig in &originals {
3118                assert!(
3119                    !content.contains(orig),
3120                    "original secret leaked in entry {i}"
3121                );
3122            }
3123        }
3124    }
3125
3126    #[test]
3127    fn parallel_tar_mixed_structured_and_scanner() {
3128        let proc = make_archive_processor().with_parallel_threshold(0);
3129        let tar_data = build_test_tar(&[
3130            ("config.json", br#"{"email":"alice@corp.com","port":5432}"#),
3131            ("notes.txt", b"contact bob@corp.com for help"),
3132            ("data.json", br#"{"email":"carol@corp.com"}"#),
3133            ("readme.txt", b"dave@corp.com is the owner"),
3134        ]);
3135
3136        let mut output = Vec::new();
3137        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3138
3139        assert_eq!(stats.files_processed, 4);
3140        assert_eq!(stats.structured_hits, 2); // two JSON files
3141        assert_eq!(stats.scanner_fallback, 2); // two plain text files
3142
3143        let originals = [
3144            "alice@corp.com",
3145            "bob@corp.com",
3146            "carol@corp.com",
3147            "dave@corp.com",
3148        ];
3149        let mut archive = tar::Archive::new(&output[..]);
3150        for entry in archive.entries().unwrap() {
3151            let mut e = entry.unwrap();
3152            let mut content = String::new();
3153            e.read_to_string(&mut content).unwrap();
3154            for orig in &originals {
3155                assert!(!content.contains(orig), "original secret leaked");
3156            }
3157        }
3158    }
3159
3160    // -- Nested archive tests -----------------------------------------------
3161
3162    #[test]
3163    fn tar_in_tar_secrets_sanitized() {
3164        // Build inner tar with a secret.
3165        let inner_tar = build_test_tar(&[("inner.txt", b"alice@corp.com")]);
3166
3167        // Embed the inner tar as an entry in the outer tar.
3168        let outer_tar = build_test_tar(&[("nested.tar", &inner_tar)]);
3169
3170        let proc = make_archive_processor();
3171        let mut output = Vec::new();
3172        let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
3173
3174        assert_eq!(stats.nested_archives, 1);
3175
3176        // Unpack the outer tar and read the inner tar's content.
3177        let mut outer = tar::Archive::new(&output[..]);
3178        for entry in outer.entries().unwrap() {
3179            let mut e = entry.unwrap();
3180            let mut inner_bytes = Vec::new();
3181            e.read_to_end(&mut inner_bytes).unwrap();
3182            let mut inner = tar::Archive::new(&inner_bytes[..]);
3183            for inner_entry in inner.entries().unwrap() {
3184                let mut ie = inner_entry.unwrap();
3185                let mut content = String::new();
3186                ie.read_to_string(&mut content).unwrap();
3187                assert!(
3188                    !content.contains("alice@corp.com"),
3189                    "secret survived nested tar"
3190                );
3191            }
3192        }
3193    }
3194
3195    #[test]
3196    fn zip_in_tar_secrets_sanitized() {
3197        let inner_zip = build_test_zip(&[("inner.txt", b"SUPERSECRET")]);
3198        let outer_tar = build_test_tar(&[("nested.zip", &inner_zip)]);
3199
3200        let proc = make_archive_processor();
3201        let mut output = Vec::new();
3202        let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
3203
3204        assert_eq!(stats.nested_archives, 1);
3205
3206        let mut outer = tar::Archive::new(&output[..]);
3207        for entry in outer.entries().unwrap() {
3208            let mut e = entry.unwrap();
3209            let mut zip_bytes = Vec::new();
3210            e.read_to_end(&mut zip_bytes).unwrap();
3211            let mut zip_out = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
3212            for i in 0..zip_out.len() {
3213                let mut ze = zip_out.by_index(i).unwrap();
3214                let mut content = String::new();
3215                ze.read_to_string(&mut content).unwrap();
3216                assert!(
3217                    !content.contains("SUPERSECRET"),
3218                    "secret survived zip-in-tar"
3219                );
3220            }
3221        }
3222    }
3223
3224    #[test]
3225    fn zip_in_zip_secrets_sanitized() {
3226        let inner_zip = build_test_zip(&[("secret.txt", b"alice@corp.com")]);
3227        let outer_zip = build_test_zip(&[("nested.zip", &inner_zip)]);
3228
3229        let proc = make_archive_processor();
3230        let reader = Cursor::new(outer_zip);
3231        let mut writer = Cursor::new(Vec::new());
3232        let stats = proc.process_zip(reader, &mut writer).unwrap();
3233
3234        assert_eq!(stats.nested_archives, 1);
3235
3236        let out_bytes = writer.into_inner();
3237        let mut outer = zip::ZipArchive::new(Cursor::new(out_bytes)).unwrap();
3238        let mut inner_bytes = Vec::new();
3239        outer
3240            .by_index(0)
3241            .unwrap()
3242            .read_to_end(&mut inner_bytes)
3243            .unwrap();
3244        let mut inner = zip::ZipArchive::new(Cursor::new(inner_bytes)).unwrap();
3245        let mut content = String::new();
3246        inner
3247            .by_index(0)
3248            .unwrap()
3249            .read_to_string(&mut content)
3250            .unwrap();
3251        assert!(
3252            !content.contains("alice@corp.com"),
3253            "secret survived zip-in-zip"
3254        );
3255    }
3256
3257    #[test]
3258    fn nested_archive_depth_limit_returns_error() {
3259        // Build an archive nested max_depth + 1 levels deep.
3260        // Default max_depth is DEFAULT_ARCHIVE_DEPTH (5); use a proc with depth=1.
3261        let proc = make_archive_processor().with_max_depth(1);
3262
3263        let innermost = build_test_tar(&[("file.txt", b"secret")]);
3264        let middle = build_test_tar(&[("inner.tar", &innermost)]);
3265        let outer = build_test_tar(&[("middle.tar", &middle)]);
3266
3267        let mut output = Vec::new();
3268        let err = proc.process_tar(&outer[..], &mut output).unwrap_err();
3269        assert!(matches!(err, SanitizeError::RecursionDepthExceeded(_)));
3270    }
3271
3272    #[test]
3273    fn force_text_skips_structured_processor() {
3274        let proc = make_archive_processor().with_force_text(true);
3275        let tar_data = build_test_tar(&[("config.json", br#"{"email":"alice@corp.com"}"#)]);
3276
3277        let mut output = Vec::new();
3278        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3279
3280        // With force_text, JSON is scanned as plain text — no structured hit.
3281        assert_eq!(stats.scanner_fallback, 1);
3282        assert_eq!(stats.structured_hits, 0);
3283    }
3284}