Skip to main content

rust_sanitize/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 unchanged.
52
53use crate::error::{Result, SanitizeError};
54use crate::processor::profile::FileTypeProfile;
55use crate::processor::registry::ProcessorRegistry;
56use crate::scanner::{ScanStats, StreamScanner};
57use crate::store::MappingStore;
58
59/// Strip path traversal components from an archive entry path before writing output.
60///
61/// Removes: leading `/`, `./`, `../`, and Windows drive-letter prefixes (`C:`).
62/// The result is always a relative path with no upward traversal. An empty
63/// result is replaced with `"_"` to avoid writing an entry with a blank name.
64/// Backslashes are normalised to forward slashes (handles Windows-style zip entries).
65fn sanitize_archive_entry_name(name: &str) -> String {
66    let name = name.replace('\\', "/");
67    let name = name.trim_start_matches('/');
68    let safe: Vec<&str> = name
69        .split('/')
70        .filter(|s| {
71            if s.is_empty() || *s == "." || *s == ".." {
72                return false;
73            }
74            // Strip Windows drive-letter prefixes ("C:", "D:", etc.) to prevent
75            // zip-slip path-traversal when the output is extracted on Windows.
76            if s.len() == 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
77                return false;
78            }
79            true
80        })
81        .collect();
82    let result = safe.join("/");
83    if result.is_empty() {
84        "_".to_string()
85    } else {
86        result
87    }
88}
89
90#[inline]
91fn sanitize_zip_entry_name(name: &str) -> String {
92    sanitize_archive_entry_name(name)
93}
94
95#[inline]
96fn sanitize_tar_entry_name(name: &str) -> String {
97    sanitize_archive_entry_name(name)
98}
99
100use glob::MatchOptions;
101use rayon::prelude::*;
102use std::collections::HashMap;
103use std::io::{self, Read, Seek, Write};
104use std::sync::Arc;
105
106use crate::processor::limits::{
107    DEFAULT_ARCHIVE_DEPTH, MAX_ARCHIVE_DEPTH, PARALLEL_ENTRY_THRESHOLD, PARALLEL_TAR_DATA_SIZE,
108    PARALLEL_ZIP_DATA_SIZE, STRUCTURED_ENTRY_SIZE,
109};
110
111/// Read up to `limit` bytes from `reader` into a `Vec<u8>`.
112///
113/// Returns an error if the reader yields more than `limit` bytes, preventing
114/// unbounded heap growth from crafted archive entries.
115fn read_bounded(reader: &mut dyn Read, limit: u64, label: &str) -> Result<Vec<u8>> {
116    let mut content = Vec::new();
117    // Read one byte beyond the limit so we can detect over-sized entries.
118    reader
119        .take(limit + 1)
120        .read_to_end(&mut content)
121        .map_err(|e| SanitizeError::ArchiveError(format!("read '{label}': {e}")))?;
122    if content.len() as u64 > limit {
123        return Err(SanitizeError::ArchiveError(format!(
124            "entry '{label}' exceeds the {limit}-byte size limit",
125        )));
126    }
127    Ok(content)
128}
129
130// ---------------------------------------------------------------------------
131// Archive format enum
132// ---------------------------------------------------------------------------
133
134/// Per-entry result from parallel archive processing: `(source_index, sanitized_bytes_and_stats)`.
135type ParEntryResult = (usize, Result<(Vec<u8>, ArchiveStats)>);
136
137/// Callback invoked with `(entry_name, sanitized_bytes)` after each file entry
138/// inside an archive is processed. Used by callers that need to inspect the
139/// sanitized content without buffering the entire archive (e.g. log context
140/// extraction).
141pub type EntryCallback = Arc<dyn Fn(&str, &[u8]) + Send + Sync>;
142
143// ---------------------------------------------------------------------------
144// ArchiveFilter
145// ---------------------------------------------------------------------------
146
147/// A compiled glob-based entry filter for archive processing.
148///
149/// Patterns are compiled once at construction time. At processing time
150/// `passes()` is called for each file entry path inside the archive.
151///
152/// ## Pattern semantics
153///
154/// - `*` matches any sequence of characters that does **not** contain `/`.
155/// - `**` matches any sequence of characters including `/`.
156/// - `?` matches any single character except `/`.
157/// - `[abc]` matches one of the listed characters.
158/// - A pattern ending with `/` is a *directory prefix* — it matches
159///   the directory itself and any path underneath it.
160///
161/// ## Filter logic
162///
163/// 1. If `--only` patterns are present: the entry path must match at
164///    least one pattern, otherwise it is dropped.
165/// 2. If `--exclude` patterns are present: if the entry path matches
166///    any pattern, it is dropped.
167/// 3. Only file entries are filtered; directory / symlink entries
168///    always pass through to preserve archive structure.
169#[derive(Default, Clone)]
170pub struct ArchiveFilter {
171    only: Vec<CompiledPattern>,
172    exclude: Vec<CompiledPattern>,
173}
174
175#[derive(Clone)]
176enum CompiledPattern {
177    /// Pattern that ended with `/` — matches the prefix directory and
178    /// everything inside it.
179    DirPrefix(String),
180    /// General glob pattern compiled with `require_literal_separator`.
181    Glob(glob::Pattern),
182}
183
184const GLOB_OPTS: MatchOptions = MatchOptions {
185    case_sensitive: true,
186    require_literal_separator: true,
187    require_literal_leading_dot: false,
188};
189
190impl CompiledPattern {
191    fn compile(raw: &str) -> std::result::Result<Self, String> {
192        if raw.ends_with('/') {
193            // Strip trailing slash; matching is done manually in `matches`.
194            Ok(CompiledPattern::DirPrefix(
195                raw.trim_end_matches('/').to_string(),
196            ))
197        } else {
198            glob::Pattern::new(raw)
199                .map(CompiledPattern::Glob)
200                .map_err(|e| format!("invalid glob pattern '{raw}': {e}"))
201        }
202    }
203
204    fn matches(&self, path: &str) -> bool {
205        match self {
206            CompiledPattern::DirPrefix(prefix) => {
207                path == prefix || path.starts_with(&format!("{prefix}/"))
208            }
209            CompiledPattern::Glob(pat) => pat.matches_with(path, GLOB_OPTS),
210        }
211    }
212}
213
214impl ArchiveFilter {
215    /// Compile `only` and `exclude` pattern lists into an `ArchiveFilter`.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if any pattern contains invalid glob syntax.
220    pub fn new(only: Vec<String>, exclude: Vec<String>) -> std::result::Result<Self, String> {
221        let only = only
222            .into_iter()
223            .map(|p| CompiledPattern::compile(&p))
224            .collect::<std::result::Result<Vec<_>, _>>()?;
225        let exclude = exclude
226            .into_iter()
227            .map(|p| CompiledPattern::compile(&p))
228            .collect::<std::result::Result<Vec<_>, _>>()?;
229        Ok(Self { only, exclude })
230    }
231
232    /// Returns `true` when neither `--only` nor `--exclude` patterns are set.
233    pub fn is_empty(&self) -> bool {
234        self.only.is_empty() && self.exclude.is_empty()
235    }
236
237    /// Returns `true` if `path` should be included in the output archive.
238    ///
239    /// Only applies to file entries; directory entries bypass this check.
240    pub fn passes(&self, path: &str) -> bool {
241        if !self.only.is_empty() && !self.only.iter().any(|p| p.matches(path)) {
242            return false;
243        }
244        if self.exclude.iter().any(|p| p.matches(path)) {
245            return false;
246        }
247        true
248    }
249}
250
251// ---------------------------------------------------------------------------
252// Archive format enum
253// ---------------------------------------------------------------------------
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum ArchiveFormat {
256    /// `.zip` archive.
257    Zip,
258    /// Uncompressed `.tar` archive.
259    Tar,
260    /// Gzip-compressed `.tar.gz` / `.tgz` archive.
261    TarGz,
262}
263
264impl ArchiveFormat {
265    /// Detect archive format from a file path / extension.
266    ///
267    /// Returns `None` for unrecognised extensions.
268    pub fn from_path(path: &str) -> Option<Self> {
269        let lower = path.to_ascii_lowercase();
270        if lower.ends_with(".tar.gz")
271            || std::path::Path::new(&lower)
272                .extension()
273                .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
274        {
275            Some(Self::TarGz)
276        } else if std::path::Path::new(&lower)
277            .extension()
278            .is_some_and(|ext| ext.eq_ignore_ascii_case("tar"))
279        {
280            Some(Self::Tar)
281        } else if std::path::Path::new(&lower)
282            .extension()
283            .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
284        {
285            Some(Self::Zip)
286        } else {
287            None
288        }
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Archive statistics
294// ---------------------------------------------------------------------------
295
296/// Statistics collected while processing an archive.
297#[derive(Debug, Clone, Default)]
298pub struct ArchiveStats {
299    /// Number of file entries processed (excludes dirs/symlinks).
300    pub files_processed: u64,
301    /// Number of entries passed through unchanged (dirs, symlinks, etc.).
302    pub entries_skipped: u64,
303    /// Number of files handled by a structured processor.
304    pub structured_hits: u64,
305    /// Number of files handled by the streaming scanner fallback.
306    pub scanner_fallback: u64,
307    /// Number of entries that were themselves archives and processed
308    /// recursively.
309    pub nested_archives: u64,
310    /// Total input bytes across all file entries.
311    pub total_input_bytes: u64,
312    /// Total output bytes across all file entries.
313    pub total_output_bytes: u64,
314    /// Per-file processing method: filename → `"structured:<proc>"`, `"scanner"`,
315    /// or `"nested:<format>"`.
316    pub file_methods: HashMap<String, String>,
317    /// Per-file scan statistics (matches, replacements, bytes, pattern counts).
318    pub file_scan_stats: HashMap<String, ScanStats>,
319    /// Number of file entries removed by the [`ArchiveFilter`].
320    pub entries_filtered: u64,
321}
322
323/// Progress snapshot emitted while processing archive entries.
324#[derive(Debug, Clone, Eq, PartialEq)]
325pub struct ArchiveProgress {
326    /// Entries seen so far, including skipped entries.
327    pub entries_seen: u64,
328    /// Regular file entries processed so far.
329    pub files_processed: u64,
330    /// Non-file entries skipped so far.
331    pub entries_skipped: u64,
332    /// Total entries when cheaply known.
333    pub total_entries: Option<u64>,
334    /// Path of the current entry.
335    pub current_entry: String,
336}
337
338type ArchiveProgressCallback = Arc<dyn Fn(&ArchiveProgress) + Send + Sync>;
339
340impl ArchiveStats {
341    /// Merge statistics from a nested archive into this parent.
342    fn merge(&mut self, child: &ArchiveStats) {
343        self.files_processed += child.files_processed;
344        self.entries_skipped += child.entries_skipped;
345        self.structured_hits += child.structured_hits;
346        self.scanner_fallback += child.scanner_fallback;
347        self.nested_archives += child.nested_archives;
348        self.total_input_bytes += child.total_input_bytes;
349        self.total_output_bytes += child.total_output_bytes;
350        self.entries_filtered += child.entries_filtered;
351        self.file_methods.extend(
352            child
353                .file_methods
354                .iter()
355                .map(|(k, v)| (k.clone(), v.clone())),
356        );
357        self.file_scan_stats.extend(
358            child
359                .file_scan_stats
360                .iter()
361                .map(|(k, v)| (k.clone(), v.clone())),
362        );
363    }
364}
365
366// ---------------------------------------------------------------------------
367// ArchiveProcessor
368// ---------------------------------------------------------------------------
369
370/// Processes archives by sanitizing each contained file and rebuilding
371/// the archive with the same format and preserved metadata.
372///
373/// # Usage
374///
375/// ```rust,no_run
376/// use rust_sanitize::processor::archive::{ArchiveProcessor, ArchiveFormat};
377/// use rust_sanitize::processor::registry::ProcessorRegistry;
378/// use rust_sanitize::scanner::{StreamScanner, ScanPattern, ScanConfig};
379/// use rust_sanitize::generator::HmacGenerator;
380/// use rust_sanitize::store::MappingStore;
381/// use rust_sanitize::category::Category;
382/// use std::sync::Arc;
383///
384/// let gen = Arc::new(HmacGenerator::new([42u8; 32]));
385/// let store = Arc::new(MappingStore::new(gen, None));
386/// let patterns = vec![
387///     ScanPattern::from_regex(r"secret\w+", Category::Custom("secret".into()), "secrets").unwrap(),
388/// ];
389/// let scanner = Arc::new(
390///     StreamScanner::new(patterns, Arc::clone(&store), ScanConfig::default()).unwrap(),
391/// );
392/// let registry = Arc::new(ProcessorRegistry::with_builtins());
393///
394/// let archive_proc = ArchiveProcessor::new(registry, scanner, store, vec![]);
395/// ```
396pub struct ArchiveProcessor {
397    /// Registry of structured processors.
398    registry: Arc<ProcessorRegistry>,
399    /// Streaming scanner for fallback processing.
400    scanner: Arc<StreamScanner>,
401    /// Shared mapping store (one-way replacements).
402    store: Arc<MappingStore>,
403    /// File-type profiles for structured processor matching.
404    profiles: Vec<FileTypeProfile>,
405    /// Maximum nesting depth for recursive archive processing.
406    max_depth: u32,
407    /// Optional callback for per-entry progress updates.
408    progress_callback: Option<ArchiveProgressCallback>,
409    /// Minimum number of file entries required to enable parallel entry
410    /// sanitization. Default: [`PARALLEL_ENTRY_THRESHOLD`].
411    parallel_threshold: usize,
412    /// Entry-level filter controlling which paths are included in the
413    /// output archive. Default: empty (pass all entries).
414    filter: ArchiveFilter,
415    /// When true, bypass all structured processors and use only the
416    /// streaming scanner for every entry. Trades format preservation
417    /// for maximum sanitization coverage.
418    force_text: bool,
419    /// Optional callback invoked with `(entry_name, sanitized_bytes)` after
420    /// each file entry is processed. Only called for regular file entries.
421    entry_callback: Option<EntryCallback>,
422}
423
424impl ArchiveProcessor {
425    /// Create a new archive processor.
426    ///
427    /// # Arguments
428    ///
429    /// - `registry` — structured processor registry.
430    /// - `scanner` — streaming scanner for fallback.
431    /// - `store` — shared mapping store for one-way dedup replacements.
432    /// - `profiles` — file-type profiles for structured matching.
433    pub fn new(
434        registry: Arc<ProcessorRegistry>,
435        scanner: Arc<StreamScanner>,
436        store: Arc<MappingStore>,
437        profiles: Vec<FileTypeProfile>,
438    ) -> Self {
439        Self {
440            registry,
441            scanner,
442            store,
443            profiles,
444            max_depth: DEFAULT_ARCHIVE_DEPTH,
445            progress_callback: None,
446            parallel_threshold: PARALLEL_ENTRY_THRESHOLD,
447            filter: ArchiveFilter::default(),
448            force_text: false,
449            entry_callback: None,
450        }
451    }
452
453    /// Override the maximum nesting depth for recursive archive
454    /// processing.
455    ///
456    /// The default is [`DEFAULT_ARCHIVE_DEPTH`] (5). Values above
457    /// 10 are clamped.
458    #[must_use]
459    pub fn with_max_depth(mut self, depth: u32) -> Self {
460        self.max_depth = depth.min(MAX_ARCHIVE_DEPTH);
461        self
462    }
463
464    /// Override the minimum entry count required to enable parallel
465    /// entry sanitization. Set to `usize::MAX` to disable parallelism
466    /// entirely for this processor instance (e.g. when outer file-level
467    /// parallelism is already saturating the thread budget).
468    #[must_use]
469    pub fn with_parallel_threshold(mut self, threshold: usize) -> Self {
470        self.parallel_threshold = threshold;
471        self
472    }
473
474    /// Register a per-entry archive progress callback.
475    #[must_use]
476    pub fn with_progress_callback(mut self, callback: ArchiveProgressCallback) -> Self {
477        self.progress_callback = Some(callback);
478        self
479    }
480
481    /// Apply an [`ArchiveFilter`] that controls which file entries are
482    /// included in the output archive.
483    ///
484    /// Entries that do not pass the filter are **removed** from the
485    /// output entirely. Directory / symlink entries are never filtered.
486    #[must_use]
487    pub fn with_filter(mut self, filter: ArchiveFilter) -> Self {
488        self.filter = filter;
489        self
490    }
491
492    /// When set, bypass all structured processors and use only the
493    /// streaming scanner for every archive entry.
494    ///
495    /// Trades format preservation for maximum sanitization coverage.
496    /// Useful when the user is uncertain about field rules or wants a
497    /// belt-and-suspenders guarantee that every byte is scanned.
498    #[must_use]
499    pub fn with_force_text(mut self, force_text: bool) -> Self {
500        self.force_text = force_text;
501        self
502    }
503
504    /// Register a callback that is invoked with `(entry_name, sanitized_bytes)`
505    /// after each regular file entry is fully processed.
506    #[must_use]
507    pub fn with_entry_callback(mut self, callback: EntryCallback) -> Self {
508        self.entry_callback = Some(callback);
509        self
510    }
511
512    fn emit_entry_bytes(&self, name: &str, bytes: &[u8]) {
513        if let Some(cb) = &self.entry_callback {
514            cb(name, bytes);
515        }
516    }
517
518    /// Find the first profile matching a filename.
519    fn find_profile(&self, filename: &str) -> Option<&FileTypeProfile> {
520        self.profiles.iter().find(|p| p.matches_filename(filename))
521    }
522
523    fn emit_progress(&self, stats: &ArchiveStats, total_entries: Option<u64>, current_entry: &str) {
524        if let Some(callback) = &self.progress_callback {
525            callback(&ArchiveProgress {
526                entries_seen: stats.files_processed + stats.entries_skipped,
527                files_processed: stats.files_processed,
528                entries_skipped: stats.entries_skipped,
529                total_entries,
530                current_entry: current_entry.to_string(),
531            });
532        }
533    }
534
535    /// Sanitize a file entry given its raw bytes.
536    ///
537    /// Returns the sanitized bytes together with a fresh [`ArchiveStats`]
538    /// covering only this entry. This is the core work unit for parallel
539    /// entry processing in [`process_tar_at_depth`] and
540    /// [`process_zip_at_depth`].
541    fn sanitize_entry_bytes(
542        &self,
543        filename: &str,
544        data: &[u8],
545        entry_size_hint: Option<u64>,
546        depth: u32,
547    ) -> Result<(Vec<u8>, ArchiveStats)> {
548        let mut out: Vec<u8> = Vec::with_capacity(data.len());
549        let mut entry_stats = ArchiveStats::default();
550        let mut reader = io::Cursor::new(data);
551        self.sanitize_entry(
552            filename,
553            &mut reader,
554            &mut out,
555            &mut entry_stats,
556            entry_size_hint,
557            depth,
558        )?;
559        Ok((out, entry_stats))
560    }
561
562    /// Sanitize the content of a single file entry.
563    ///
564    /// If the entry is itself an archive (detected via extension), it is
565    /// recursively processed up to `self.max_depth`. Otherwise, tries a
566    /// structured processor first; falls back to the streaming scanner
567    /// if no processor matches.
568    ///
569    /// For the streaming scanner path, the content is piped through
570    /// `scan_reader` directly to the writer for memory-efficient
571    /// chunk-based processing (F-02 fix: no full output buffering).
572    #[allow(clippy::missing_errors_doc)] // private method
573    fn sanitize_entry(
574        &self,
575        filename: &str,
576        reader: &mut dyn Read,
577        writer: &mut dyn Write,
578        stats: &mut ArchiveStats,
579        entry_size_hint: Option<u64>,
580        depth: u32,
581    ) -> Result<()> {
582        // --- Nested archive detection ---
583        if let Some(nested_fmt) = ArchiveFormat::from_path(filename) {
584            return self.sanitize_nested_archive(
585                filename,
586                reader,
587                writer,
588                stats,
589                entry_size_hint,
590                nested_fmt,
591                depth,
592            );
593        }
594
595        // --- Structured / scanner processing ---
596
597        // Try structured processing first, but only if the entry is
598        // within the size cap and --force-text is not set.
599        // Oversized entries fall through to the streaming scanner (M-3 fix).
600        let within_size_cap = entry_size_hint.is_none_or(|sz| sz <= STRUCTURED_ENTRY_SIZE); // unknown size → allow (conservative)
601
602        if !self.force_text && within_size_cap {
603            if let Some(profile) = self.find_profile(filename) {
604                // Structured processors need the full content in memory.
605                let mut content = Vec::new();
606                reader.read_to_end(&mut content).map_err(|e| {
607                    SanitizeError::ArchiveError(format!("read entry '{filename}': {e}"))
608                })?;
609
610                stats.total_input_bytes += content.len() as u64;
611
612                // A parse error (e.g. binary content with a .yaml extension, like
613                // macOS resource-fork ._* files) falls through to the scanner
614                // rather than failing the whole archive.
615                // A parse error or heuristic rejection falls through to the scanner below.
616                if let Ok(Some(structured_out)) =
617                    self.registry.process(&content, profile, &self.store)
618                {
619                    // Double-pass: run the streaming scanner on the structured
620                    // output to catch anything the field rules missed.
621                    let (output, scan_stats) = self.scanner.scan_bytes(&structured_out)?;
622                    stats.structured_hits += 1;
623                    stats.total_output_bytes += output.len() as u64;
624                    stats.file_methods.insert(
625                        filename.to_string(),
626                        format!("structured+scan:{}", profile.processor),
627                    );
628                    stats
629                        .file_scan_stats
630                        .insert(filename.to_string(), scan_stats);
631                    writer.write_all(&output).map_err(|e| {
632                        SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
633                    })?;
634                    return Ok(());
635                }
636
637                // Processor didn't match or failed — fall back to
638                // scanner with the already-buffered content.
639                let (output, scan_stats) = self.scanner.scan_bytes(&content)?;
640                stats.scanner_fallback += 1;
641                stats.total_output_bytes += output.len() as u64;
642                stats
643                    .file_methods
644                    .insert(filename.to_string(), "scanner".to_string());
645                stats
646                    .file_scan_stats
647                    .insert(filename.to_string(), scan_stats);
648                writer.write_all(&output).map_err(|e| {
649                    SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
650                })?;
651                return Ok(());
652            }
653        }
654
655        // No profile (or entry too large) → streaming scanner.
656        // F-02 fix: stream directly from reader → scanner → writer
657        // without buffering the full output. We use a CountingWriter
658        // to track output bytes alongside the CountingReader for input.
659        let mut counting_r = CountingReader::new(reader);
660        let mut counting_w = CountingWriter::new(writer);
661        let scan_stats = self.scanner.scan_reader(&mut counting_r, &mut counting_w)?;
662
663        stats.scanner_fallback += 1;
664        stats.total_input_bytes += counting_r.bytes_read();
665        stats.total_output_bytes += counting_w.bytes_written();
666        stats
667            .file_methods
668            .insert(filename.to_string(), "scanner".to_string());
669        stats
670            .file_scan_stats
671            .insert(filename.to_string(), scan_stats);
672
673        Ok(())
674    }
675
676    /// Handle a nested archive entry: validate depth/size, buffer, recurse,
677    /// and write the sanitized output.
678    #[allow(clippy::too_many_arguments)]
679    fn sanitize_nested_archive(
680        &self,
681        filename: &str,
682        reader: &mut dyn Read,
683        writer: &mut dyn Write,
684        stats: &mut ArchiveStats,
685        entry_size_hint: Option<u64>,
686        nested_fmt: ArchiveFormat,
687        depth: u32,
688    ) -> Result<()> {
689        if depth >= self.max_depth {
690            return Err(SanitizeError::RecursionDepthExceeded(format!(
691                "nested archive '{}' at depth {} exceeds maximum nesting depth of {}",
692                filename, depth, self.max_depth,
693            )));
694        }
695
696        // Buffer the nested archive (always bounded by STRUCTURED_ENTRY_SIZE).
697        // The size hint (from a zip central-directory entry) is checked first for
698        // a fast early rejection; the bounded read enforces the cap even when the
699        // hint is absent (e.g. some tar entries) to prevent unbounded heap growth.
700        if let Some(sz) = entry_size_hint {
701            if sz > STRUCTURED_ENTRY_SIZE {
702                return Err(SanitizeError::ArchiveError(format!(
703                    "nested archive '{}' is too large ({} bytes, limit {} bytes)",
704                    filename, sz, STRUCTURED_ENTRY_SIZE,
705                )));
706            }
707        }
708
709        let content = read_bounded(reader, STRUCTURED_ENTRY_SIZE, filename)?;
710        stats.total_input_bytes += content.len() as u64;
711
712        // Recurse into the nested archive.
713        let mut output_buf: Vec<u8> = Vec::new();
714        let child_stats = match nested_fmt {
715            ArchiveFormat::Tar => {
716                self.process_tar_at_depth(&content[..], &mut output_buf, depth + 1)?
717            }
718            ArchiveFormat::TarGz => {
719                self.process_tar_gz_at_depth(&content[..], &mut output_buf, depth + 1)?
720            }
721            ArchiveFormat::Zip => {
722                let reader = io::Cursor::new(&content);
723                let mut writer = io::Cursor::new(Vec::new());
724                let s = self.process_zip_at_depth(reader, &mut writer, depth + 1)?;
725                output_buf = writer.into_inner();
726                s
727            }
728        };
729
730        stats.nested_archives += 1;
731        stats.merge(&child_stats);
732        stats.total_output_bytes += output_buf.len() as u64;
733        let fmt_name = match nested_fmt {
734            ArchiveFormat::Tar => "tar",
735            ArchiveFormat::TarGz => "tar.gz",
736            ArchiveFormat::Zip => "zip",
737        };
738        stats
739            .file_methods
740            .insert(filename.to_string(), format!("nested:{fmt_name}"));
741        writer.write_all(&output_buf).map_err(|e| {
742            SanitizeError::ArchiveError(format!("write nested archive '{filename}': {e}"))
743        })?;
744        Ok(())
745    }
746
747    // -----------------------------------------------------------------------
748    // Profile discovery passes (two-phase support)
749    // -----------------------------------------------------------------------
750    //
751    // These methods perform a read-only pre-pass over an archive, running the
752    // structured processor on every profile-matched entry and discarding the
753    // output.  The side-effect is that `self.store` is populated with the
754    // original→replacement mappings for those fields, so a subsequent call to
755    // `build_augmented_scanner` can inject those values as literals into the
756    // scanner used for the real processing pass.
757
758    /// Run the structured processor on every profile-matched entry in a
759    /// `.tar` archive, recording replacements into the store.  Output is
760    /// discarded; the archive is not modified.
761    ///
762    /// # Errors
763    ///
764    /// Returns an error if the archive cannot be read or an entry cannot be processed.
765    pub fn discover_profiles_tar<R: Read>(&self, reader: R) -> Result<()> {
766        if self.profiles.is_empty() {
767            return Ok(());
768        }
769        let mut archive = tar::Archive::new(reader);
770        let entries = archive
771            .entries()
772            .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entries: {e}")))?;
773        for entry_result in entries {
774            let mut entry = entry_result
775                .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entry: {e}")))?;
776            if !entry.header().entry_type().is_file() {
777                continue;
778            }
779            let path = entry
780                .path()
781                .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
782                .to_string_lossy()
783                .to_string();
784            let Some(profile) = self.find_profile(&path) else {
785                continue;
786            };
787            let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &path) {
788                Ok(c) => c,
789                Err(e) => {
790                    tracing::warn!(path = %path, error = %e, "discovery: skipping oversized entry");
791                    continue;
792                }
793            };
794            if let Err(e) = self.registry.process(&content, profile, &self.store) {
795                tracing::warn!(path = %path, error = %e, "discovery: structured processor failed; partial mappings may persist");
796            }
797        }
798        Ok(())
799    }
800
801    /// Run the structured processor on every profile-matched entry in a
802    /// `.tar.gz` archive, recording replacements into the store.  Output is
803    /// discarded; the archive is not modified.
804    ///
805    /// # Errors
806    ///
807    /// Returns an error if the archive cannot be read or an entry cannot be processed.
808    pub fn discover_profiles_tar_gz<R: Read>(&self, reader: R) -> Result<()> {
809        let gz = flate2::read::GzDecoder::new(reader);
810        self.discover_profiles_tar(gz)
811    }
812
813    /// Run the structured processor on every profile-matched entry in a
814    /// `.zip` archive, recording replacements into the store.  Output is
815    /// discarded; the archive is not modified.
816    ///
817    /// # Errors
818    ///
819    /// Returns an error if the archive cannot be read or an entry cannot be processed.
820    pub fn discover_profiles_zip<R: Read + Seek>(&self, reader: R) -> Result<()> {
821        if self.profiles.is_empty() {
822            return Ok(());
823        }
824        let mut zip = zip::ZipArchive::new(reader)
825            .map_err(|e| SanitizeError::ArchiveError(format!("open zip for discovery: {e}")))?;
826        for i in 0..zip.len() {
827            let mut entry = zip
828                .by_index(i)
829                .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {i}: {e}")))?;
830            if entry.is_dir() {
831                continue;
832            }
833            let name = sanitize_zip_entry_name(entry.name());
834            let Some(profile) = self.find_profile(&name) else {
835                continue;
836            };
837            let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &name) {
838                Ok(c) => c,
839                Err(e) => {
840                    tracing::warn!(name = %name, error = %e, "discovery: skipping oversized entry");
841                    continue;
842                }
843            };
844            if let Err(e) = self.registry.process(&content, profile, &self.store) {
845                tracing::warn!(name = %name, error = %e, "discovery: structured processor failed; partial mappings may persist");
846            }
847        }
848        Ok(())
849    }
850
851    // Tar processing
852    // -----------------------------------------------------------------------
853
854    /// Process a `.tar` archive, sanitizing each file entry and
855    /// rebuilding the archive with preserved metadata.
856    ///
857    /// Entries that are not regular files (directories, symlinks, etc.)
858    /// are copied through unchanged.
859    ///
860    /// # Errors
861    ///
862    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
863    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
864    pub fn process_tar<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
865        self.process_tar_at_depth(reader, writer, 0)
866    }
867
868    /// Internal: process a tar archive at a given nesting depth.
869    ///
870    /// Uses a speculative-buffer strategy to decide between parallel and
871    /// sequential processing:
872    ///
873    /// - **Parallel** (total buffered data ≤ `PARALLEL_TAR_DATA_SIZE` AND
874    ///   file count ≥ threshold AND not inside a rayon worker): buffer all
875    ///   entries, sanitize concurrently with rayon, write in source order.
876    /// - **Sequential — buffered** (threshold not met but data fits): process
877    ///   entries from the in-memory buffer one at a time.
878    /// - **Sequential — streaming** (data exceeds cap mid-stream): process
879    ///   already-buffered entries from memory, then continue streaming the
880    ///   remainder of the archive without additional buffering.
881    ///
882    /// Unlike zip, tar has no central directory so sizes cannot be known before
883    /// reading. The buffer cap (`PARALLEL_TAR_DATA_SIZE`) bounds peak memory to
884    /// cap + one entry overhead regardless of archive size.
885    #[allow(clippy::too_many_lines)]
886    fn process_tar_at_depth<R: Read, W: Write>(
887        &self,
888        reader: R,
889        writer: W,
890        depth: u32,
891    ) -> Result<ArchiveStats> {
892        struct TarEntry {
893            header: tar::Header,
894            path: String,
895            is_file: bool,
896            passes_filter: bool,
897            data: Vec<u8>,
898        }
899
900        let mut archive = tar::Archive::new(reader);
901        let mut builder = tar::Builder::new(writer);
902        let mut stats = ArchiveStats::default();
903
904        // --- Phase 1: speculative buffering ----------------------------------
905        // Stream entries into memory, tracking total file-data size.
906        // Stop buffering (but keep the last entry) if the cap is exceeded.
907        let mut entries_iter = archive
908            .entries()
909            .map_err(|e| SanitizeError::ArchiveError(format!("read tar entries: {e}")))?;
910
911        let mut buffered: Vec<TarEntry> = Vec::new();
912        let mut file_count: usize = 0;
913        let mut total_data: u64 = 0;
914        let mut overflowed = false;
915
916        for entry_result in entries_iter.by_ref() {
917            let mut entry = entry_result
918                .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
919
920            let header = entry.header().clone();
921            let path = entry
922                .path()
923                .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
924                .to_string_lossy()
925                .into_owned();
926            let is_file = header.entry_type().is_file();
927            let passes_filter = !is_file || self.filter.passes(&path);
928
929            let mut data = Vec::new();
930            entry
931                .read_to_end(&mut data)
932                .map_err(|e| SanitizeError::ArchiveError(format!("read entry '{path}': {e}")))?;
933            drop(entry);
934
935            if is_file && passes_filter {
936                file_count += 1;
937                total_data = total_data.saturating_add(data.len() as u64);
938            }
939
940            buffered.push(TarEntry {
941                header,
942                path,
943                is_file,
944                passes_filter,
945                data,
946            });
947
948            if total_data > PARALLEL_TAR_DATA_SIZE {
949                overflowed = true;
950                break;
951            }
952        }
953
954        // --- Phase 2: choose strategy ----------------------------------------
955        let use_parallel = !overflowed
956            && file_count >= self.parallel_threshold
957            && rayon::current_thread_index().is_none();
958
959        if use_parallel {
960            // --- Parallel path -----------------------------------------------
961            // Sanitize all file entries concurrently; write in source order.
962            let file_indices: Vec<usize> = buffered
963                .iter()
964                .enumerate()
965                .filter(|(_, e)| e.is_file && e.passes_filter)
966                .map(|(i, _)| i)
967                .collect();
968
969            let results: Vec<ParEntryResult> = file_indices
970                .into_par_iter()
971                .map(|i| {
972                    let e = &buffered[i];
973                    let size_hint = e.header.size().ok();
974                    (
975                        i,
976                        self.sanitize_entry_bytes(&e.path, &e.data, size_hint, depth),
977                    )
978                })
979                .collect();
980
981            let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; buffered.len()];
982            for (i, r) in results {
983                sanitized[i] = Some(r?);
984            }
985
986            for (i, entry) in buffered.iter().enumerate() {
987                if !entry.is_file {
988                    builder
989                        .append(&entry.header, entry.data.as_slice())
990                        .map_err(|e| {
991                            SanitizeError::ArchiveError(format!("append '{}': {e}", entry.path))
992                        })?;
993                    stats.entries_skipped += 1;
994                    self.emit_progress(&stats, None, &entry.path);
995                    continue;
996                }
997                if !entry.passes_filter {
998                    stats.entries_filtered += 1;
999                    self.emit_progress(&stats, None, &entry.path);
1000                    continue;
1001                }
1002
1003                let (sanitized_buf, entry_stats) =
1004                    sanitized[i].take().expect("parallel result missing");
1005                stats.merge(&entry_stats);
1006                self.emit_entry_bytes(&entry.path, &sanitized_buf);
1007
1008                let mut new_header = entry.header.clone();
1009                let safe_path = sanitize_tar_entry_name(&entry.path);
1010                new_header.set_path(&safe_path).map_err(|e| {
1011                    SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1012                })?;
1013                new_header.set_size(sanitized_buf.len() as u64);
1014                new_header.set_cksum();
1015                builder
1016                    .append(&new_header, sanitized_buf.as_slice())
1017                    .map_err(|e| {
1018                        SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1019                    })?;
1020                stats.files_processed += 1;
1021                self.emit_progress(&stats, None, &entry.path);
1022            }
1023        } else {
1024            // --- Sequential path ---------------------------------------------
1025            // Process buffered entries first, then stream the remainder.
1026
1027            // Helper: write one buffered entry to the builder.
1028            let write_buffered = |entry: &TarEntry,
1029                                  builder: &mut tar::Builder<W>,
1030                                  stats: &mut ArchiveStats,
1031                                  processor: &ArchiveProcessor|
1032             -> Result<()> {
1033                if !entry.is_file {
1034                    builder
1035                        .append(&entry.header, entry.data.as_slice())
1036                        .map_err(|e| {
1037                            SanitizeError::ArchiveError(format!("append '{}': {e}", entry.path))
1038                        })?;
1039                    stats.entries_skipped += 1;
1040                    processor.emit_progress(stats, None, &entry.path);
1041                    return Ok(());
1042                }
1043                if !entry.passes_filter {
1044                    stats.entries_filtered += 1;
1045                    processor.emit_progress(stats, None, &entry.path);
1046                    return Ok(());
1047                }
1048                let size_hint = entry.header.size().ok();
1049                let (sanitized_buf, entry_stats) =
1050                    processor.sanitize_entry_bytes(&entry.path, &entry.data, size_hint, depth)?;
1051                stats.merge(&entry_stats);
1052                processor.emit_entry_bytes(&entry.path, &sanitized_buf);
1053                let mut new_header = entry.header.clone();
1054                let safe_path = sanitize_tar_entry_name(&entry.path);
1055                new_header.set_path(&safe_path).map_err(|e| {
1056                    SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1057                })?;
1058                new_header.set_size(sanitized_buf.len() as u64);
1059                new_header.set_cksum();
1060                builder
1061                    .append(&new_header, sanitized_buf.as_slice())
1062                    .map_err(|e| {
1063                        SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1064                    })?;
1065                stats.files_processed += 1;
1066                processor.emit_progress(stats, None, &entry.path);
1067                Ok(())
1068            };
1069
1070            for entry in &buffered {
1071                write_buffered(entry, &mut builder, &mut stats, self)?;
1072            }
1073            drop(buffered);
1074
1075            // Stream remaining entries when the buffer cap was exceeded.
1076            if overflowed {
1077                for entry_result in entries_iter {
1078                    let mut entry = entry_result
1079                        .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
1080
1081                    let header = entry.header().clone();
1082                    let path = entry
1083                        .path()
1084                        .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1085                        .to_string_lossy()
1086                        .into_owned();
1087                    let is_file = header.entry_type().is_file();
1088
1089                    if !is_file {
1090                        let mut data = Vec::new();
1091                        entry.read_to_end(&mut data).map_err(|e| {
1092                            SanitizeError::ArchiveError(format!("read '{path}': {e}"))
1093                        })?;
1094                        drop(entry);
1095                        builder.append(&header, data.as_slice()).map_err(|e| {
1096                            SanitizeError::ArchiveError(format!("append '{path}': {e}"))
1097                        })?;
1098                        stats.entries_skipped += 1;
1099                        self.emit_progress(&stats, None, &path);
1100                        continue;
1101                    }
1102
1103                    if !self.filter.passes(&path) {
1104                        stats.entries_filtered += 1;
1105                        continue;
1106                    }
1107
1108                    let size_hint = header.size().ok();
1109                    let mut sanitized_buf = Vec::new();
1110                    let mut entry_stats = ArchiveStats::default();
1111                    self.sanitize_entry(
1112                        &path,
1113                        &mut entry,
1114                        &mut sanitized_buf,
1115                        &mut entry_stats,
1116                        size_hint,
1117                        depth,
1118                    )?;
1119                    drop(entry);
1120                    self.emit_entry_bytes(&path, &sanitized_buf);
1121
1122                    let mut new_header = header.clone();
1123                    let safe_path = sanitize_tar_entry_name(&path);
1124                    new_header.set_path(&safe_path).map_err(|e| {
1125                        SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1126                    })?;
1127                    new_header.set_size(sanitized_buf.len() as u64);
1128                    new_header.set_cksum();
1129                    builder
1130                        .append(&new_header, sanitized_buf.as_slice())
1131                        .map_err(|e| {
1132                            SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1133                        })?;
1134
1135                    stats.merge(&entry_stats);
1136                    stats.files_processed += 1;
1137                    self.emit_progress(&stats, None, &path);
1138                }
1139            }
1140        }
1141
1142        builder
1143            .finish()
1144            .map_err(|e| SanitizeError::ArchiveError(format!("finalize tar: {e}")))?;
1145
1146        Ok(stats)
1147    }
1148
1149    /// Process a `.tar.gz` archive (gzip-compressed tar).
1150    ///
1151    /// Decompresses on the fly, processes each entry, and recompresses
1152    /// the output.
1153    ///
1154    /// # Errors
1155    ///
1156    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1157    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1158    pub fn process_tar_gz<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
1159        self.process_tar_gz_at_depth(reader, writer, 0)
1160    }
1161
1162    /// Internal: process a tar.gz archive at a given nesting depth.
1163    fn process_tar_gz_at_depth<R: Read, W: Write>(
1164        &self,
1165        reader: R,
1166        writer: W,
1167        depth: u32,
1168    ) -> Result<ArchiveStats> {
1169        let gz_reader = flate2::read::GzDecoder::new(reader);
1170        let gz_writer = flate2::write::GzEncoder::new(writer, flate2::Compression::fast());
1171
1172        let stats = self.process_tar_at_depth(gz_reader, gz_writer, depth)?;
1173        // GzEncoder is flushed when the tar builder finishes and the
1174        // encoder is dropped. The `finish()` call in `process_tar`
1175        // flushes the tar builder, which flushes writes to the
1176        // GzEncoder. When the GzEncoder is dropped it finalises the
1177        // gzip stream.
1178        Ok(stats)
1179    }
1180
1181    // -----------------------------------------------------------------------
1182    // Zip processing
1183    // -----------------------------------------------------------------------
1184
1185    /// Process a `.zip` archive, sanitizing each file entry and
1186    /// rebuilding the archive with preserved metadata.
1187    ///
1188    /// # Type Bounds
1189    ///
1190    /// Zip requires seekable I/O for both reading and writing.
1191    ///
1192    /// # Errors
1193    ///
1194    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1195    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1196    pub fn process_zip<R: Read + Seek, W: Write + Seek>(
1197        &self,
1198        reader: R,
1199        writer: W,
1200    ) -> Result<ArchiveStats> {
1201        self.process_zip_at_depth(reader, writer, 0)
1202    }
1203
1204    /// Internal: process a zip archive at a given nesting depth.
1205    ///
1206    /// Uses a lightweight metadata pre-pass (local-header reads, no data
1207    /// decompression) to decide between parallel and sequential strategies:
1208    ///
1209    /// - **Parallel** (total uncompressed ≤ `PARALLEL_ZIP_DATA_SIZE` AND
1210    ///   file count ≥ threshold AND depth == 0): load all entry data into
1211    ///   memory, sanitize with rayon, write in order.
1212    /// - **Sequential** (everything else): read → sanitize → write one entry
1213    ///   at a time.  Peak memory is bounded to 2 × largest single entry.
1214    #[allow(clippy::too_many_lines)]
1215    fn process_zip_at_depth<R: Read + Seek, W: Write + Seek>(
1216        &self,
1217        reader: R,
1218        writer: W,
1219        depth: u32,
1220    ) -> Result<ArchiveStats> {
1221        // --- Stage 0: metadata pre-pass (no data reads) ---------------------
1222        // Read local file headers to collect names, sizes, and options.
1223        // This does N seeks but decompresses nothing, keeping memory flat.
1224        struct ZipMeta {
1225            name: String,
1226            is_dir: bool,
1227            compression: zip::CompressionMethod,
1228            last_modified: Option<zip::DateTime>,
1229            unix_mode: Option<u32>,
1230            size: u64,
1231        }
1232
1233        let mut zip_in = zip::ZipArchive::new(reader)
1234            .map_err(|e| SanitizeError::ArchiveError(format!("open zip: {}", e)))?;
1235        let total_entries = zip_in.len();
1236        let total_entries_hint = Some(total_entries as u64);
1237
1238        let mut metas: Vec<ZipMeta> = Vec::with_capacity(total_entries);
1239        let mut file_count = 0usize;
1240        let mut total_uncompressed_size: u64 = 0;
1241
1242        for i in 0..total_entries {
1243            let entry = zip_in
1244                .by_index(i)
1245                .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1246            let is_dir = entry.is_dir();
1247            let size = entry.size();
1248            if !is_dir {
1249                file_count += 1;
1250                total_uncompressed_size = total_uncompressed_size.saturating_add(size);
1251            }
1252            metas.push(ZipMeta {
1253                name: sanitize_zip_entry_name(entry.name()),
1254                is_dir,
1255                compression: entry.compression(),
1256                last_modified: entry.last_modified(),
1257                unix_mode: entry.unix_mode(),
1258                size,
1259            });
1260            // entry dropped here — no data decompressed
1261        }
1262
1263        // Parallel only when the total data fits comfortably in memory.
1264        // Parallel when: enough entries, data fits in memory, and we are not
1265        // already running inside a rayon worker thread (nested parallelism
1266        // would over-subscribe the pool without proportional gains).
1267        let use_parallel = file_count >= self.parallel_threshold
1268            && rayon::current_thread_index().is_none()
1269            && total_uncompressed_size <= PARALLEL_ZIP_DATA_SIZE;
1270
1271        let mut stats = ArchiveStats::default();
1272
1273        // Helper: build SimpleFileOptions for a metadata entry.
1274        let make_options = |m: &ZipMeta| {
1275            let mut opts =
1276                zip::write::SimpleFileOptions::default().compression_method(m.compression);
1277            if let Some(dt) = m.last_modified {
1278                opts = opts.last_modified_time(dt);
1279            }
1280            if let Some(mode) = m.unix_mode {
1281                opts.unix_permissions(mode)
1282            } else {
1283                opts
1284            }
1285        };
1286
1287        if use_parallel {
1288            // --- Parallel path: load all data then sanitize concurrently ----
1289            struct ZipEntry {
1290                meta_idx: usize,
1291                data: Vec<u8>,
1292            }
1293
1294            let mut file_entries: Vec<ZipEntry> = Vec::with_capacity(file_count);
1295
1296            for (i, meta) in metas.iter().enumerate() {
1297                if meta.is_dir {
1298                    continue;
1299                }
1300                // Skip loading data for entries that will be filtered out.
1301                if !self.filter.passes(&meta.name) {
1302                    continue;
1303                }
1304                let mut entry = zip_in
1305                    .by_index(i)
1306                    .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1307                let mut data = Vec::new();
1308                entry.read_to_end(&mut data).map_err(|e| {
1309                    SanitizeError::ArchiveError(format!("read zip entry '{}': {}", meta.name, e))
1310                })?;
1311                file_entries.push(ZipEntry { meta_idx: i, data });
1312            }
1313
1314            let results: Vec<ParEntryResult> = file_entries
1315                .into_par_iter()
1316                .map(|e| {
1317                    let meta = &metas[e.meta_idx];
1318                    let result =
1319                        self.sanitize_entry_bytes(&meta.name, &e.data, Some(meta.size), depth);
1320                    (e.meta_idx, result)
1321                })
1322                .collect();
1323
1324            // Collect into a positional Vec (indexed by metas position) for
1325            // O(1) ordered writes, avoiding HashMap hashing overhead.
1326            let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; metas.len()];
1327            for (meta_idx, r) in results {
1328                sanitized[meta_idx] = Some(r?);
1329            }
1330
1331            let mut zip_out = zip::ZipWriter::new(writer);
1332            for (i, meta) in metas.iter().enumerate() {
1333                let options = make_options(meta);
1334                if meta.is_dir {
1335                    zip_out.add_directory(&meta.name, options).map_err(|e| {
1336                        SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1337                    })?;
1338                    stats.entries_skipped += 1;
1339                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1340                    continue;
1341                }
1342                // Filter: drop entries not matching --only/--exclude rules.
1343                if !self.filter.passes(&meta.name) {
1344                    stats.entries_filtered += 1;
1345                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1346                    continue;
1347                }
1348                let (sanitized_buf, entry_stats) = sanitized[i]
1349                    .take()
1350                    .expect("file entry sanitization result missing");
1351                stats.merge(&entry_stats);
1352                self.emit_entry_bytes(&meta.name, &sanitized_buf);
1353                zip_out.start_file(&meta.name, options).map_err(|e| {
1354                    SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1355                })?;
1356                zip_out.write_all(&sanitized_buf).map_err(|e| {
1357                    SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1358                })?;
1359                stats.files_processed += 1;
1360                self.emit_progress(&stats, total_entries_hint, &meta.name);
1361            }
1362            zip_out
1363                .finish()
1364                .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1365        } else {
1366            // --- Sequential path: one entry at a time -----------------------
1367            // Only one entry's data (input + sanitized output) is live at once.
1368            let mut zip_out = zip::ZipWriter::new(writer);
1369            for (i, meta) in metas.iter().enumerate() {
1370                let options = make_options(meta);
1371                if meta.is_dir {
1372                    zip_out.add_directory(&meta.name, options).map_err(|e| {
1373                        SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1374                    })?;
1375                    stats.entries_skipped += 1;
1376                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1377                    continue;
1378                }
1379
1380                // Filter: drop entries not matching --only/--exclude rules.
1381                if !self.filter.passes(&meta.name) {
1382                    stats.entries_filtered += 1;
1383                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1384                    continue;
1385                }
1386
1387                let data = {
1388                    let mut entry = zip_in.by_index(i).map_err(|e| {
1389                        SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e))
1390                    })?;
1391                    let mut buf = Vec::new();
1392                    entry.read_to_end(&mut buf).map_err(|e| {
1393                        SanitizeError::ArchiveError(format!(
1394                            "read zip entry '{}': {}",
1395                            meta.name, e
1396                        ))
1397                    })?;
1398                    buf
1399                    // entry dropped here
1400                };
1401
1402                let (sanitized_buf, entry_stats) =
1403                    self.sanitize_entry_bytes(&meta.name, &data, Some(meta.size), depth)?;
1404                drop(data);
1405                self.emit_entry_bytes(&meta.name, &sanitized_buf);
1406
1407                zip_out.start_file(&meta.name, options).map_err(|e| {
1408                    SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1409                })?;
1410                zip_out.write_all(&sanitized_buf).map_err(|e| {
1411                    SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1412                })?;
1413                drop(sanitized_buf);
1414
1415                stats.merge(&entry_stats);
1416                stats.files_processed += 1;
1417                self.emit_progress(&stats, total_entries_hint, &meta.name);
1418            }
1419            zip_out
1420                .finish()
1421                .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1422        }
1423
1424        Ok(stats)
1425    }
1426
1427    // -----------------------------------------------------------------------
1428    // Format-aware dispatch
1429    // -----------------------------------------------------------------------
1430
1431    /// Auto-detect the archive format and process accordingly.
1432    ///
1433    /// For zip archives the reader must additionally implement `Seek`.
1434    /// This method accepts `Read + Seek` to cover all formats uniformly.
1435    /// Tar and tar.gz do not require seeking, but the bound is imposed
1436    /// for a single entry point.
1437    ///
1438    /// # Errors
1439    ///
1440    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1441    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1442    pub fn process<R: Read + Seek, W: Write + Seek>(
1443        &self,
1444        reader: R,
1445        writer: W,
1446        format: ArchiveFormat,
1447    ) -> Result<ArchiveStats> {
1448        match format {
1449            ArchiveFormat::Zip => self.process_zip(reader, writer),
1450            ArchiveFormat::Tar => self.process_tar(reader, writer),
1451            ArchiveFormat::TarGz => self.process_tar_gz(reader, writer),
1452        }
1453    }
1454}
1455
1456// ---------------------------------------------------------------------------
1457// Counting reader wrapper (for input byte tracking)
1458// ---------------------------------------------------------------------------
1459
1460/// A thin wrapper around a reader that counts bytes read.
1461struct CountingReader<'a> {
1462    inner: &'a mut dyn Read,
1463    count: u64,
1464}
1465
1466impl<'a> CountingReader<'a> {
1467    fn new(inner: &'a mut dyn Read) -> Self {
1468        Self { inner, count: 0 }
1469    }
1470
1471    fn bytes_read(&self) -> u64 {
1472        self.count
1473    }
1474}
1475
1476impl Read for CountingReader<'_> {
1477    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1478        let n = self.inner.read(buf)?;
1479        self.count += n as u64;
1480        Ok(n)
1481    }
1482}
1483
1484/// A thin wrapper around a writer that counts bytes written (F-02 fix).
1485struct CountingWriter<'a> {
1486    inner: &'a mut dyn Write,
1487    count: u64,
1488}
1489
1490impl<'a> CountingWriter<'a> {
1491    fn new(inner: &'a mut dyn Write) -> Self {
1492        Self { inner, count: 0 }
1493    }
1494
1495    fn bytes_written(&self) -> u64 {
1496        self.count
1497    }
1498}
1499
1500impl Write for CountingWriter<'_> {
1501    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1502        let n = self.inner.write(buf)?;
1503        self.count += n as u64;
1504        Ok(n)
1505    }
1506
1507    fn flush(&mut self) -> io::Result<()> {
1508        self.inner.flush()
1509    }
1510}
1511
1512// ---------------------------------------------------------------------------
1513// Tests
1514// ---------------------------------------------------------------------------
1515
1516#[cfg(test)]
1517mod tests {
1518    use super::*;
1519    use crate::category::Category;
1520    use crate::generator::HmacGenerator;
1521    use crate::processor::profile::{FieldRule, FileTypeProfile};
1522    use crate::processor::registry::ProcessorRegistry;
1523    use crate::scanner::{ScanConfig, ScanPattern};
1524    use std::io::Cursor;
1525    use std::sync::Mutex;
1526
1527    /// Build a test archive processor with an email pattern and a JSON profile.
1528    fn make_archive_processor() -> ArchiveProcessor {
1529        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1530        let store = Arc::new(MappingStore::new(gen, None));
1531
1532        let patterns = vec![
1533            ScanPattern::from_regex(
1534                r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
1535                Category::Email,
1536                "email",
1537            )
1538            .unwrap(),
1539            ScanPattern::from_literal("SUPERSECRET", Category::Custom("api_key".into()), "api_key")
1540                .unwrap(),
1541        ];
1542
1543        let scanner = Arc::new(
1544            StreamScanner::new(patterns, Arc::clone(&store), ScanConfig::default()).unwrap(),
1545        );
1546
1547        let registry = Arc::new(ProcessorRegistry::with_builtins());
1548
1549        let profiles = vec![FileTypeProfile::new(
1550            "json",
1551            vec![FieldRule::new("*").with_category(Category::Custom("field".into()))],
1552        )
1553        .with_extension(".json")];
1554
1555        ArchiveProcessor::new(registry, scanner, store, profiles)
1556    }
1557
1558    // -- Tar tests ----------------------------------------------------------
1559
1560    fn build_test_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
1561        let mut buf = Vec::new();
1562        {
1563            let mut builder = tar::Builder::new(&mut buf);
1564            for (name, data) in entries {
1565                let mut header = tar::Header::new_gnu();
1566                header.set_size(data.len() as u64);
1567                header.set_mode(0o644);
1568                header.set_mtime(1_700_000_000);
1569                header.set_cksum();
1570                builder.append_data(&mut header, *name, *data).unwrap();
1571            }
1572            builder.finish().unwrap();
1573        }
1574        buf
1575    }
1576
1577    #[test]
1578    fn tar_sanitizes_plaintext_with_scanner() {
1579        let proc = make_archive_processor();
1580        let input = build_test_tar(&[("readme.txt", b"Contact alice@corp.com for help.")]);
1581
1582        let mut output = Vec::new();
1583        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1584
1585        assert_eq!(stats.files_processed, 1);
1586        assert_eq!(stats.scanner_fallback, 1);
1587        assert_eq!(stats.structured_hits, 0);
1588
1589        // Verify the output is a valid tar and the secret is gone.
1590        let mut archive = tar::Archive::new(&output[..]);
1591        for entry in archive.entries().unwrap() {
1592            let mut e = entry.unwrap();
1593            let mut content = String::new();
1594            e.read_to_string(&mut content).unwrap();
1595            assert!(
1596                !content.contains("alice@corp.com"),
1597                "email should be sanitized: {content}"
1598            );
1599        }
1600    }
1601
1602    #[test]
1603    fn tar_sanitizes_json_with_structured_processor() {
1604        let proc = make_archive_processor();
1605        let json_content = br#"{"email": "bob@example.org", "name": "Bob"}"#;
1606        let input = build_test_tar(&[("config.json", json_content)]);
1607
1608        let mut output = Vec::new();
1609        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1610
1611        assert_eq!(stats.files_processed, 1);
1612        assert_eq!(stats.structured_hits, 1);
1613        assert_eq!(stats.scanner_fallback, 0);
1614        assert_eq!(
1615            stats.file_methods.get("config.json").unwrap(),
1616            "structured+scan:json"
1617        );
1618
1619        // Verify sanitized output.
1620        let mut archive = tar::Archive::new(&output[..]);
1621        for entry in archive.entries().unwrap() {
1622            let mut e = entry.unwrap();
1623            let mut content = String::new();
1624            e.read_to_string(&mut content).unwrap();
1625            assert!(
1626                !content.contains("bob@example.org"),
1627                "email should be sanitized"
1628            );
1629            assert!(!content.contains("Bob"), "name should be sanitized");
1630        }
1631    }
1632
1633    #[test]
1634    fn tar_preserves_metadata() {
1635        let proc = make_archive_processor();
1636        let input = build_test_tar(&[("data.txt", b"SUPERSECRET token here")]);
1637
1638        let mut output = Vec::new();
1639        proc.process_tar(&input[..], &mut output).unwrap();
1640
1641        let mut archive = tar::Archive::new(&output[..]);
1642        for entry in archive.entries().unwrap() {
1643            let e = entry.unwrap();
1644            let hdr = e.header();
1645            assert_eq!(hdr.mode().unwrap(), 0o644);
1646            assert_eq!(hdr.mtime().unwrap(), 1_700_000_000);
1647        }
1648    }
1649
1650    #[test]
1651    fn tar_handles_multiple_files() {
1652        let proc = make_archive_processor();
1653        let input = build_test_tar(&[
1654            ("a.txt", b"alice@corp.com"),
1655            ("b.json", br#"{"key":"value"}"#),
1656            ("c.log", b"no secrets here"),
1657        ]);
1658
1659        let mut output = Vec::new();
1660        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1661
1662        assert_eq!(stats.files_processed, 3);
1663        // b.json matched the JSON profile
1664        assert_eq!(stats.structured_hits, 1);
1665        // a.txt and c.log fall back to scanner
1666        assert_eq!(stats.scanner_fallback, 2);
1667    }
1668
1669    #[test]
1670    fn tar_passes_through_directories() {
1671        let mut buf = Vec::new();
1672        {
1673            let mut builder = tar::Builder::new(&mut buf);
1674
1675            // Add a directory entry.
1676            let mut dir_header = tar::Header::new_gnu();
1677            dir_header.set_entry_type(tar::EntryType::Directory);
1678            dir_header.set_size(0);
1679            dir_header.set_mode(0o755);
1680            dir_header.set_cksum();
1681            builder
1682                .append_data(&mut dir_header, "mydir/", &b""[..])
1683                .unwrap();
1684
1685            // Add a file.
1686            let mut file_header = tar::Header::new_gnu();
1687            file_header.set_size(5);
1688            file_header.set_mode(0o644);
1689            file_header.set_cksum();
1690            builder
1691                .append_data(&mut file_header, "mydir/hello.txt", &b"hello"[..])
1692                .unwrap();
1693
1694            builder.finish().unwrap();
1695        }
1696
1697        let proc = make_archive_processor();
1698        let mut output = Vec::new();
1699        let stats = proc.process_tar(&buf[..], &mut output).unwrap();
1700
1701        assert_eq!(stats.entries_skipped, 1);
1702        assert_eq!(stats.files_processed, 1);
1703    }
1704
1705    // -- Tar.gz tests -------------------------------------------------------
1706
1707    #[test]
1708    fn tar_gz_round_trip() {
1709        let proc = make_archive_processor();
1710
1711        // Build a tar and gzip it.
1712        let tar_data = build_test_tar(&[("secret.txt", b"Key is SUPERSECRET okay")]);
1713        let mut gz_input = Vec::new();
1714        {
1715            let mut encoder =
1716                flate2::write::GzEncoder::new(&mut gz_input, flate2::Compression::fast());
1717            encoder.write_all(&tar_data).unwrap();
1718            encoder.finish().unwrap();
1719        }
1720
1721        let mut gz_output = Vec::new();
1722        let stats = proc.process_tar_gz(&gz_input[..], &mut gz_output).unwrap();
1723
1724        assert_eq!(stats.files_processed, 1);
1725        assert_eq!(stats.scanner_fallback, 1);
1726
1727        // Decompress and verify.
1728        let decoder = flate2::read::GzDecoder::new(&gz_output[..]);
1729        let mut archive = tar::Archive::new(decoder);
1730        for entry in archive.entries().unwrap() {
1731            let mut e = entry.unwrap();
1732            let mut content = String::new();
1733            e.read_to_string(&mut content).unwrap();
1734            assert!(
1735                !content.contains("SUPERSECRET"),
1736                "secret should be sanitized: {content}"
1737            );
1738        }
1739    }
1740
1741    // -- Zip tests ----------------------------------------------------------
1742
1743    fn build_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
1744        let mut buf = Cursor::new(Vec::new());
1745        {
1746            let mut zip = zip::ZipWriter::new(&mut buf);
1747            for (name, data) in entries {
1748                let options = zip::write::SimpleFileOptions::default()
1749                    .compression_method(zip::CompressionMethod::Deflated);
1750                zip.start_file(*name, options).unwrap();
1751                zip.write_all(data).unwrap();
1752            }
1753            zip.finish().unwrap();
1754        }
1755        buf.into_inner()
1756    }
1757
1758    #[test]
1759    fn zip_sanitizes_plaintext_with_scanner() {
1760        let proc = make_archive_processor();
1761        let zip_data = build_test_zip(&[("notes.txt", b"Reach alice@corp.com for info.")]);
1762
1763        let reader = Cursor::new(&zip_data);
1764        let mut writer = Cursor::new(Vec::new());
1765        let stats = proc.process_zip(reader, &mut writer).unwrap();
1766
1767        assert_eq!(stats.files_processed, 1);
1768        assert_eq!(stats.scanner_fallback, 1);
1769
1770        // Verify the output zip.
1771        let out_data = writer.into_inner();
1772        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
1773        let mut entry = zip_out.by_index(0).unwrap();
1774        let mut content = String::new();
1775        entry.read_to_string(&mut content).unwrap();
1776        assert!(
1777            !content.contains("alice@corp.com"),
1778            "email should be sanitized: {content}"
1779        );
1780    }
1781
1782    #[test]
1783    fn zip_sanitizes_json_with_structured_processor() {
1784        let proc = make_archive_processor();
1785        let json_content = br#"{"password": "hunter2", "host": "db.internal"}"#;
1786        let zip_data = build_test_zip(&[("settings.json", json_content)]);
1787
1788        let reader = Cursor::new(&zip_data);
1789        let mut writer = Cursor::new(Vec::new());
1790        let stats = proc.process_zip(reader, &mut writer).unwrap();
1791
1792        assert_eq!(stats.files_processed, 1);
1793        assert_eq!(stats.structured_hits, 1);
1794
1795        let out_data = writer.into_inner();
1796        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
1797        let mut entry = zip_out.by_index(0).unwrap();
1798        let mut content = String::new();
1799        entry.read_to_string(&mut content).unwrap();
1800        assert!(!content.contains("hunter2"), "password should be sanitized");
1801        assert!(!content.contains("db.internal"), "host should be sanitized");
1802    }
1803
1804    #[test]
1805    fn zip_preserves_directory_entries() {
1806        let mut buf = Cursor::new(Vec::new());
1807        {
1808            let mut zip = zip::ZipWriter::new(&mut buf);
1809
1810            let dir_options = zip::write::SimpleFileOptions::default();
1811            zip.add_directory("subdir/", dir_options).unwrap();
1812
1813            let file_options = zip::write::SimpleFileOptions::default()
1814                .compression_method(zip::CompressionMethod::Stored);
1815            zip.start_file("subdir/data.txt", file_options).unwrap();
1816            zip.write_all(b"SUPERSECRET value").unwrap();
1817
1818            zip.finish().unwrap();
1819        }
1820
1821        let zip_data = buf.into_inner();
1822        let proc = make_archive_processor();
1823        let reader = Cursor::new(&zip_data);
1824        let mut writer = Cursor::new(Vec::new());
1825        let stats = proc.process_zip(reader, &mut writer).unwrap();
1826
1827        assert_eq!(stats.entries_skipped, 1); // directory
1828        assert_eq!(stats.files_processed, 1);
1829    }
1830
1831    #[test]
1832    fn zip_handles_multiple_files() {
1833        let proc = make_archive_processor();
1834        let zip_data = build_test_zip(&[
1835            ("file1.txt", b"alice@corp.com"),
1836            ("file2.json", br#"{"secret":"SUPERSECRET"}"#),
1837            ("file3.log", b"nothing to see"),
1838        ]);
1839
1840        let reader = Cursor::new(&zip_data);
1841        let mut writer = Cursor::new(Vec::new());
1842        let stats = proc.process_zip(reader, &mut writer).unwrap();
1843
1844        assert_eq!(stats.files_processed, 3);
1845        assert_eq!(stats.structured_hits, 1); // JSON
1846        assert_eq!(stats.scanner_fallback, 2); // .txt + .log
1847    }
1848
1849    #[test]
1850    fn tar_progress_callback_receives_updates() {
1851        let updates = Arc::new(Mutex::new(Vec::new()));
1852        let proc = make_archive_processor().with_progress_callback({
1853            let updates = Arc::clone(&updates);
1854            Arc::new(move |progress| {
1855                updates
1856                    .lock()
1857                    .expect("archive progress lock")
1858                    .push(progress.clone());
1859            })
1860        });
1861        let input = build_test_tar(&[("a.txt", b"alice@corp.com"), ("b.txt", b"SUPERSECRET")]);
1862
1863        let mut output = Vec::new();
1864        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1865        let updates = updates.lock().unwrap();
1866
1867        assert_eq!(updates.len(), 2);
1868        assert_eq!(updates.last().unwrap().entries_seen, 2);
1869        assert_eq!(
1870            updates.last().unwrap().files_processed,
1871            stats.files_processed
1872        );
1873        assert_eq!(updates.last().unwrap().total_entries, None);
1874    }
1875
1876    #[test]
1877    fn zip_progress_callback_reports_total_entries() {
1878        let updates = Arc::new(Mutex::new(Vec::new()));
1879        let proc = make_archive_processor().with_progress_callback({
1880            let updates = Arc::clone(&updates);
1881            Arc::new(move |progress| {
1882                updates
1883                    .lock()
1884                    .expect("archive progress lock")
1885                    .push(progress.clone());
1886            })
1887        });
1888        let zip_data = build_test_zip(&[
1889            ("file1.txt", b"alice@corp.com"),
1890            ("file2.log", b"nothing to see"),
1891        ]);
1892
1893        let reader = Cursor::new(&zip_data);
1894        let mut writer = Cursor::new(Vec::new());
1895        let stats = proc.process_zip(reader, &mut writer).unwrap();
1896        let updates = updates.lock().unwrap();
1897
1898        assert_eq!(updates.len(), 2);
1899        assert_eq!(
1900            updates.last().unwrap().files_processed,
1901            stats.files_processed
1902        );
1903        assert_eq!(updates.last().unwrap().total_entries, Some(2));
1904        assert_eq!(updates.last().unwrap().current_entry, "file2.log");
1905    }
1906
1907    // -- Format detection tests ---------------------------------------------
1908
1909    #[test]
1910    fn format_detection_from_path() {
1911        assert_eq!(
1912            ArchiveFormat::from_path("data.tar"),
1913            Some(ArchiveFormat::Tar)
1914        );
1915        assert_eq!(
1916            ArchiveFormat::from_path("data.tar.gz"),
1917            Some(ArchiveFormat::TarGz)
1918        );
1919        assert_eq!(
1920            ArchiveFormat::from_path("data.tgz"),
1921            Some(ArchiveFormat::TarGz)
1922        );
1923        assert_eq!(
1924            ArchiveFormat::from_path("data.zip"),
1925            Some(ArchiveFormat::Zip)
1926        );
1927        assert_eq!(
1928            ArchiveFormat::from_path("DATA.ZIP"),
1929            Some(ArchiveFormat::Zip)
1930        );
1931        assert_eq!(ArchiveFormat::from_path("photo.png"), None);
1932    }
1933
1934    // -- Determinism / dedup tests ------------------------------------------
1935
1936    #[test]
1937    fn same_secret_gets_same_replacement_across_entries() {
1938        let proc = make_archive_processor();
1939        let input = build_test_tar(&[
1940            ("a.txt", b"contact alice@corp.com"),
1941            ("b.txt", b"reach alice@corp.com"),
1942        ]);
1943
1944        let mut output = Vec::new();
1945        proc.process_tar(&input[..], &mut output).unwrap();
1946
1947        let mut archive = tar::Archive::new(&output[..]);
1948        let mut contents: Vec<String> = Vec::new();
1949        for entry in archive.entries().unwrap() {
1950            let mut e = entry.unwrap();
1951            let mut s = String::new();
1952            e.read_to_string(&mut s).unwrap();
1953            contents.push(s);
1954        }
1955
1956        // Both files should have the *same* replacement for alice@corp.com.
1957        // Extract the replacement by removing the prefix.
1958        let replacement_a = contents[0].strip_prefix("contact ").unwrap();
1959        let replacement_b = contents[1].strip_prefix("reach ").unwrap();
1960        assert_eq!(
1961            replacement_a, replacement_b,
1962            "dedup should produce identical replacements"
1963        );
1964        assert!(!replacement_a.contains("alice@corp.com"));
1965    }
1966
1967    // -- Auto-dispatch test -------------------------------------------------
1968
1969    #[test]
1970    fn process_auto_dispatch_tar() {
1971        let proc = make_archive_processor();
1972        let tar_data = build_test_tar(&[("f.txt", b"SUPERSECRET")]);
1973
1974        let reader = Cursor::new(tar_data);
1975        let writer = Cursor::new(Vec::new());
1976        let stats = proc.process(reader, writer, ArchiveFormat::Tar).unwrap();
1977
1978        assert_eq!(stats.files_processed, 1);
1979    }
1980
1981    #[test]
1982    fn process_auto_dispatch_zip() {
1983        let proc = make_archive_processor();
1984        let zip_data = build_test_zip(&[("f.txt", b"SUPERSECRET")]);
1985
1986        let reader = Cursor::new(zip_data);
1987        let mut writer = Cursor::new(Vec::new());
1988        let stats = proc
1989            .process(reader, &mut writer, ArchiveFormat::Zip)
1990            .unwrap();
1991
1992        assert_eq!(stats.files_processed, 1);
1993    }
1994
1995    // -- Empty archive tests ------------------------------------------------
1996
1997    #[test]
1998    fn tar_empty_archive() {
1999        let proc = make_archive_processor();
2000        let tar_data = build_test_tar(&[]);
2001
2002        let mut output = Vec::new();
2003        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2004
2005        assert_eq!(stats.files_processed, 0);
2006        assert_eq!(stats.entries_skipped, 0);
2007    }
2008
2009    #[test]
2010    fn zip_empty_archive() {
2011        let proc = make_archive_processor();
2012        let zip_data = build_test_zip(&[]);
2013
2014        let reader = Cursor::new(zip_data);
2015        let mut writer = Cursor::new(Vec::new());
2016        let stats = proc.process_zip(reader, &mut writer).unwrap();
2017
2018        assert_eq!(stats.files_processed, 0);
2019    }
2020
2021    // sanitize_zip_entry_name
2022
2023    #[test]
2024    fn zip_entry_name_clean_passthrough() {
2025        assert_eq!(sanitize_zip_entry_name("logs/app.log"), "logs/app.log");
2026        assert_eq!(sanitize_zip_entry_name("config.yaml"), "config.yaml");
2027        assert_eq!(sanitize_zip_entry_name("a/b/c.txt"), "a/b/c.txt");
2028    }
2029
2030    #[test]
2031    fn zip_entry_name_strips_leading_slash() {
2032        assert_eq!(sanitize_zip_entry_name("/etc/passwd"), "etc/passwd");
2033        assert_eq!(sanitize_zip_entry_name("///etc/passwd"), "etc/passwd");
2034    }
2035
2036    #[test]
2037    fn zip_entry_name_strips_dotdot() {
2038        assert_eq!(sanitize_zip_entry_name("../etc/passwd"), "etc/passwd");
2039        assert_eq!(
2040            sanitize_zip_entry_name("a/../../etc/passwd"),
2041            "a/etc/passwd"
2042        );
2043        assert_eq!(
2044            sanitize_zip_entry_name("../../root/.ssh/id_rsa"),
2045            "root/.ssh/id_rsa"
2046        );
2047    }
2048
2049    #[test]
2050    fn zip_entry_name_strips_leading_dot_slash() {
2051        assert_eq!(sanitize_zip_entry_name("./config.yaml"), "config.yaml");
2052        assert_eq!(sanitize_zip_entry_name("././config.yaml"), "config.yaml");
2053    }
2054
2055    #[test]
2056    fn zip_entry_name_backslash_normalised() {
2057        assert_eq!(sanitize_zip_entry_name("a\\b\\c.txt"), "a/b/c.txt");
2058        assert_eq!(sanitize_zip_entry_name("..\\etc\\passwd"), "etc/passwd");
2059    }
2060
2061    #[test]
2062    fn zip_entry_name_empty_result_replaced() {
2063        assert_eq!(sanitize_zip_entry_name("../.."), "_");
2064        assert_eq!(sanitize_zip_entry_name(""), "_");
2065        assert_eq!(sanitize_zip_entry_name("/"), "_");
2066    }
2067
2068    #[test]
2069    fn zip_entry_name_absolute_dotdot_combo() {
2070        assert_eq!(sanitize_zip_entry_name("/../etc/passwd"), "etc/passwd");
2071    }
2072
2073    // -- ArchiveFilter tests ------------------------------------------------
2074
2075    #[test]
2076    fn filter_empty_passes_everything() {
2077        let f = ArchiveFilter::new(vec![], vec![]).unwrap();
2078        assert!(f.is_empty());
2079        assert!(f.passes("config/app.yaml"));
2080        assert!(f.passes("logs/server.log"));
2081    }
2082
2083    #[test]
2084    fn filter_only_glob_includes_match() {
2085        let f = ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap();
2086        assert!(!f.is_empty());
2087        assert!(f.passes("config/settings.json"));
2088        assert!(f.passes("deep/nested/file.json"));
2089        assert!(!f.passes("config/settings.yaml"));
2090    }
2091
2092    #[test]
2093    fn filter_only_dir_prefix_includes_subtree() {
2094        let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2095        assert!(f.passes("config/app.yaml"));
2096        assert!(f.passes("config/nested/db.yaml"));
2097        assert!(!f.passes("logs/server.log"));
2098    }
2099
2100    #[test]
2101    fn filter_dir_prefix_exact_match() {
2102        let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2103        // Exact prefix without trailing separator should also match.
2104        assert!(f.passes("config"));
2105    }
2106
2107    #[test]
2108    fn filter_exclude_removes_match() {
2109        let f = ArchiveFilter::new(vec![], vec!["**/*.log".into()]).unwrap();
2110        assert!(!f.passes("logs/server.log"));
2111        assert!(f.passes("config/app.yaml"));
2112    }
2113
2114    #[test]
2115    fn filter_only_and_exclude_combined() {
2116        let f =
2117            ArchiveFilter::new(vec!["config/".into()], vec!["config/secrets.yaml".into()]).unwrap();
2118        assert!(f.passes("config/app.yaml"));
2119        assert!(!f.passes("config/secrets.yaml"));
2120        assert!(!f.passes("logs/server.log"));
2121    }
2122
2123    #[test]
2124    fn filter_invalid_glob_returns_error() {
2125        assert!(ArchiveFilter::new(vec!["[invalid".into()], vec![]).is_err());
2126        assert!(ArchiveFilter::new(vec![], vec!["[bad".into()]).is_err());
2127    }
2128
2129    // -- ArchiveProcessor builder methods -----------------------------------
2130
2131    #[test]
2132    fn builder_with_max_depth_clamps_at_max() {
2133        let proc = make_archive_processor().with_max_depth(999);
2134        assert_eq!(proc.max_depth, MAX_ARCHIVE_DEPTH);
2135    }
2136
2137    #[test]
2138    fn builder_with_max_depth_sets_value() {
2139        let proc = make_archive_processor().with_max_depth(2);
2140        assert_eq!(proc.max_depth, 2);
2141    }
2142
2143    #[test]
2144    fn builder_with_parallel_threshold_sets_value() {
2145        let proc = make_archive_processor().with_parallel_threshold(usize::MAX);
2146        assert_eq!(proc.parallel_threshold, usize::MAX);
2147    }
2148
2149    #[test]
2150    fn builder_with_force_text_enables_flag() {
2151        let proc = make_archive_processor().with_force_text(true);
2152        assert!(proc.force_text);
2153    }
2154
2155    #[test]
2156    fn builder_with_filter_applied_to_zip() {
2157        let proc = make_archive_processor()
2158            .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2159
2160        let zip_data = build_test_zip(&[
2161            ("config.json", br#"{"email":"alice@corp.com"}"#),
2162            ("notes.txt", b"alice@corp.com"),
2163        ]);
2164
2165        let reader = Cursor::new(zip_data);
2166        let mut writer = Cursor::new(Vec::new());
2167        let stats = proc.process_zip(reader, &mut writer).unwrap();
2168
2169        // notes.txt is excluded by the filter — only config.json processed.
2170        assert_eq!(stats.files_processed, 1);
2171        assert_eq!(stats.entries_filtered, 1);
2172    }
2173
2174    #[test]
2175    fn builder_with_filter_applied_to_tar() {
2176        let proc = make_archive_processor()
2177            .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2178
2179        let tar_data = build_test_tar(&[
2180            ("config.json", br#"{"email":"alice@corp.com"}"#),
2181            ("notes.txt", b"alice@corp.com"),
2182        ]);
2183
2184        let mut output = Vec::new();
2185        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2186
2187        assert_eq!(stats.files_processed, 1);
2188        assert_eq!(stats.entries_filtered, 1);
2189    }
2190
2191    // -- Parallel path tests ------------------------------------------------
2192
2193    #[test]
2194    fn parallel_tar_sanitizes_all_entries() {
2195        // parallel_threshold(0) forces parallel execution regardless of entry count.
2196        let proc = make_archive_processor().with_parallel_threshold(0);
2197        let tar_data = build_test_tar(&[
2198            ("a.txt", b"alice@corp.com"),
2199            ("b.txt", b"bob@corp.com"),
2200            ("c.txt", b"carol@corp.com"),
2201            ("d.txt", b"dave@corp.com"),
2202        ]);
2203
2204        let mut output = Vec::new();
2205        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2206
2207        assert_eq!(stats.files_processed, 4);
2208
2209        // Verify originals are gone (domain is preserved by email strategy, full addresses must not appear).
2210        let originals = [
2211            "alice@corp.com",
2212            "bob@corp.com",
2213            "carol@corp.com",
2214            "dave@corp.com",
2215        ];
2216        let mut archive = tar::Archive::new(&output[..]);
2217        for entry in archive.entries().unwrap() {
2218            let mut e = entry.unwrap();
2219            let mut content = String::new();
2220            e.read_to_string(&mut content).unwrap();
2221            for orig in &originals {
2222                assert!(
2223                    !content.contains(orig),
2224                    "original secret leaked in {:?}",
2225                    e.path()
2226                );
2227            }
2228        }
2229    }
2230
2231    #[test]
2232    fn parallel_tar_preserves_entry_order() {
2233        let proc = make_archive_processor().with_parallel_threshold(0);
2234        let tar_data = build_test_tar(&[
2235            ("first.txt", b"alice@corp.com"),
2236            ("second.txt", b"hello"),
2237            ("third.txt", b"bob@corp.com"),
2238        ]);
2239
2240        let mut output = Vec::new();
2241        proc.process_tar(&tar_data[..], &mut output).unwrap();
2242
2243        let mut archive = tar::Archive::new(&output[..]);
2244        let names: Vec<String> = archive
2245            .entries()
2246            .unwrap()
2247            .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
2248            .collect();
2249
2250        assert_eq!(names, vec!["first.txt", "second.txt", "third.txt"]);
2251    }
2252
2253    #[test]
2254    fn parallel_zip_sanitizes_all_entries() {
2255        let proc = make_archive_processor().with_parallel_threshold(0);
2256        let zip_data = build_test_zip(&[
2257            ("a.txt", b"alice@corp.com"),
2258            ("b.txt", b"bob@corp.com"),
2259            ("c.txt", b"carol@corp.com"),
2260            ("d.txt", b"dave@corp.com"),
2261        ]);
2262
2263        let reader = Cursor::new(zip_data);
2264        let mut writer = Cursor::new(Vec::new());
2265        let stats = proc.process_zip(reader, &mut writer).unwrap();
2266
2267        assert_eq!(stats.files_processed, 4);
2268
2269        let originals = [
2270            "alice@corp.com",
2271            "bob@corp.com",
2272            "carol@corp.com",
2273            "dave@corp.com",
2274        ];
2275        let out_data = writer.into_inner();
2276        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
2277        for i in 0..zip_out.len() {
2278            let mut entry = zip_out.by_index(i).unwrap();
2279            let mut content = String::new();
2280            entry.read_to_string(&mut content).unwrap();
2281            for orig in &originals {
2282                assert!(
2283                    !content.contains(orig),
2284                    "original secret leaked in entry {i}"
2285                );
2286            }
2287        }
2288    }
2289
2290    #[test]
2291    fn parallel_tar_mixed_structured_and_scanner() {
2292        let proc = make_archive_processor().with_parallel_threshold(0);
2293        let tar_data = build_test_tar(&[
2294            ("config.json", br#"{"email":"alice@corp.com","port":5432}"#),
2295            ("notes.txt", b"contact bob@corp.com for help"),
2296            ("data.json", br#"{"email":"carol@corp.com"}"#),
2297            ("readme.txt", b"dave@corp.com is the owner"),
2298        ]);
2299
2300        let mut output = Vec::new();
2301        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2302
2303        assert_eq!(stats.files_processed, 4);
2304        assert_eq!(stats.structured_hits, 2); // two JSON files
2305        assert_eq!(stats.scanner_fallback, 2); // two plain text files
2306
2307        let originals = [
2308            "alice@corp.com",
2309            "bob@corp.com",
2310            "carol@corp.com",
2311            "dave@corp.com",
2312        ];
2313        let mut archive = tar::Archive::new(&output[..]);
2314        for entry in archive.entries().unwrap() {
2315            let mut e = entry.unwrap();
2316            let mut content = String::new();
2317            e.read_to_string(&mut content).unwrap();
2318            for orig in &originals {
2319                assert!(!content.contains(orig), "original secret leaked");
2320            }
2321        }
2322    }
2323
2324    // -- Nested archive tests -----------------------------------------------
2325
2326    #[test]
2327    fn tar_in_tar_secrets_sanitized() {
2328        // Build inner tar with a secret.
2329        let inner_tar = build_test_tar(&[("inner.txt", b"alice@corp.com")]);
2330
2331        // Embed the inner tar as an entry in the outer tar.
2332        let outer_tar = build_test_tar(&[("nested.tar", &inner_tar)]);
2333
2334        let proc = make_archive_processor();
2335        let mut output = Vec::new();
2336        let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
2337
2338        assert_eq!(stats.nested_archives, 1);
2339
2340        // Unpack the outer tar and read the inner tar's content.
2341        let mut outer = tar::Archive::new(&output[..]);
2342        for entry in outer.entries().unwrap() {
2343            let mut e = entry.unwrap();
2344            let mut inner_bytes = Vec::new();
2345            e.read_to_end(&mut inner_bytes).unwrap();
2346            let mut inner = tar::Archive::new(&inner_bytes[..]);
2347            for inner_entry in inner.entries().unwrap() {
2348                let mut ie = inner_entry.unwrap();
2349                let mut content = String::new();
2350                ie.read_to_string(&mut content).unwrap();
2351                assert!(
2352                    !content.contains("alice@corp.com"),
2353                    "secret survived nested tar"
2354                );
2355            }
2356        }
2357    }
2358
2359    #[test]
2360    fn zip_in_tar_secrets_sanitized() {
2361        let inner_zip = build_test_zip(&[("inner.txt", b"SUPERSECRET")]);
2362        let outer_tar = build_test_tar(&[("nested.zip", &inner_zip)]);
2363
2364        let proc = make_archive_processor();
2365        let mut output = Vec::new();
2366        let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
2367
2368        assert_eq!(stats.nested_archives, 1);
2369
2370        let mut outer = tar::Archive::new(&output[..]);
2371        for entry in outer.entries().unwrap() {
2372            let mut e = entry.unwrap();
2373            let mut zip_bytes = Vec::new();
2374            e.read_to_end(&mut zip_bytes).unwrap();
2375            let mut zip_out = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
2376            for i in 0..zip_out.len() {
2377                let mut ze = zip_out.by_index(i).unwrap();
2378                let mut content = String::new();
2379                ze.read_to_string(&mut content).unwrap();
2380                assert!(
2381                    !content.contains("SUPERSECRET"),
2382                    "secret survived zip-in-tar"
2383                );
2384            }
2385        }
2386    }
2387
2388    #[test]
2389    fn zip_in_zip_secrets_sanitized() {
2390        let inner_zip = build_test_zip(&[("secret.txt", b"alice@corp.com")]);
2391        let outer_zip = build_test_zip(&[("nested.zip", &inner_zip)]);
2392
2393        let proc = make_archive_processor();
2394        let reader = Cursor::new(outer_zip);
2395        let mut writer = Cursor::new(Vec::new());
2396        let stats = proc.process_zip(reader, &mut writer).unwrap();
2397
2398        assert_eq!(stats.nested_archives, 1);
2399
2400        let out_bytes = writer.into_inner();
2401        let mut outer = zip::ZipArchive::new(Cursor::new(out_bytes)).unwrap();
2402        let mut inner_bytes = Vec::new();
2403        outer
2404            .by_index(0)
2405            .unwrap()
2406            .read_to_end(&mut inner_bytes)
2407            .unwrap();
2408        let mut inner = zip::ZipArchive::new(Cursor::new(inner_bytes)).unwrap();
2409        let mut content = String::new();
2410        inner
2411            .by_index(0)
2412            .unwrap()
2413            .read_to_string(&mut content)
2414            .unwrap();
2415        assert!(
2416            !content.contains("alice@corp.com"),
2417            "secret survived zip-in-zip"
2418        );
2419    }
2420
2421    #[test]
2422    fn nested_archive_depth_limit_returns_error() {
2423        // Build an archive nested max_depth + 1 levels deep.
2424        // Default max_depth is DEFAULT_ARCHIVE_DEPTH (5); use a proc with depth=1.
2425        let proc = make_archive_processor().with_max_depth(1);
2426
2427        let innermost = build_test_tar(&[("file.txt", b"secret")]);
2428        let middle = build_test_tar(&[("inner.tar", &innermost)]);
2429        let outer = build_test_tar(&[("middle.tar", &middle)]);
2430
2431        let mut output = Vec::new();
2432        let err = proc.process_tar(&outer[..], &mut output).unwrap_err();
2433        assert!(matches!(err, SanitizeError::RecursionDepthExceeded(_)));
2434    }
2435
2436    #[test]
2437    fn force_text_skips_structured_processor() {
2438        let proc = make_archive_processor().with_force_text(true);
2439        let tar_data = build_test_tar(&[("config.json", br#"{"email":"alice@corp.com"}"#)]);
2440
2441        let mut output = Vec::new();
2442        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2443
2444        // With force_text, JSON is scanned as plain text — no structured hit.
2445        assert_eq!(stats.scanner_fallback, 1);
2446        assert_eq!(stats.structured_hits, 0);
2447    }
2448}