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::{ScanPattern, 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, OnceLock};
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    /// Lazily-built format-preserving scanner for structured entries: the
423    /// scanner with structure-corrupting key/value patterns stripped. Built
424    /// once on first use and reused across entries (and threads).
425    structured_scanner: OnceLock<Arc<StreamScanner>>,
426}
427
428impl ArchiveProcessor {
429    /// Create a new archive processor.
430    ///
431    /// # Arguments
432    ///
433    /// - `registry` — structured processor registry.
434    /// - `scanner` — streaming scanner for fallback.
435    /// - `store` — shared mapping store for one-way dedup replacements.
436    /// - `profiles` — file-type profiles for structured matching.
437    pub fn new(
438        registry: Arc<ProcessorRegistry>,
439        scanner: Arc<StreamScanner>,
440        store: Arc<MappingStore>,
441        profiles: Vec<FileTypeProfile>,
442    ) -> Self {
443        Self {
444            registry,
445            scanner,
446            store,
447            profiles,
448            max_depth: DEFAULT_ARCHIVE_DEPTH,
449            progress_callback: None,
450            parallel_threshold: PARALLEL_ENTRY_THRESHOLD,
451            filter: ArchiveFilter::default(),
452            force_text: false,
453            entry_callback: None,
454            structured_scanner: OnceLock::new(),
455        }
456    }
457
458    /// Format-preserving scanner for structured entries, built lazily from
459    /// `self.scanner` with structure-corrupting key/value patterns removed (see
460    /// [`StreamScanner::for_structured_pass`]). Applied to an entry's **original**
461    /// bytes so comments, key order, and whitespace are preserved while
462    /// discovered field values and non-structural patterns are still redacted.
463    fn structured_pass_scanner(&self) -> Result<&Arc<StreamScanner>> {
464        if let Some(scanner) = self.structured_scanner.get() {
465            return Ok(scanner);
466        }
467        let built = Arc::new(self.scanner.for_structured_pass(Vec::new())?);
468        // A racing thread may have set it first; either value is equivalent.
469        let _ = self.structured_scanner.set(built);
470        Ok(self
471            .structured_scanner
472            .get()
473            .expect("structured scanner was just set"))
474    }
475
476    /// Override the maximum nesting depth for recursive archive
477    /// processing.
478    ///
479    /// The default is [`DEFAULT_ARCHIVE_DEPTH`] (5). Values above
480    /// 10 are clamped.
481    #[must_use]
482    pub fn with_max_depth(mut self, depth: u32) -> Self {
483        self.max_depth = depth.min(MAX_ARCHIVE_DEPTH);
484        self
485    }
486
487    /// Override the minimum entry count required to enable parallel
488    /// entry sanitization. Set to `usize::MAX` to disable parallelism
489    /// entirely for this processor instance (e.g. when outer file-level
490    /// parallelism is already saturating the thread budget).
491    #[must_use]
492    pub fn with_parallel_threshold(mut self, threshold: usize) -> Self {
493        self.parallel_threshold = threshold;
494        self
495    }
496
497    /// Register a per-entry archive progress callback.
498    #[must_use]
499    pub fn with_progress_callback(mut self, callback: ArchiveProgressCallback) -> Self {
500        self.progress_callback = Some(callback);
501        self
502    }
503
504    /// Apply an [`ArchiveFilter`] that controls which file entries are
505    /// included in the output archive.
506    ///
507    /// Entries that do not pass the filter are **removed** from the
508    /// output entirely. Directory / symlink entries are never filtered.
509    #[must_use]
510    pub fn with_filter(mut self, filter: ArchiveFilter) -> Self {
511        self.filter = filter;
512        self
513    }
514
515    /// When set, bypass all structured processors and use only the
516    /// streaming scanner for every archive entry.
517    ///
518    /// Trades format preservation for maximum sanitization coverage.
519    /// Useful when the user is uncertain about field rules or wants a
520    /// belt-and-suspenders guarantee that every byte is scanned.
521    #[must_use]
522    pub fn with_force_text(mut self, force_text: bool) -> Self {
523        self.force_text = force_text;
524        self
525    }
526
527    /// Register a callback that is invoked with `(entry_name, sanitized_bytes)`
528    /// after each regular file entry is fully processed.
529    #[must_use]
530    pub fn with_entry_callback(mut self, callback: EntryCallback) -> Self {
531        self.entry_callback = Some(callback);
532        self
533    }
534
535    fn emit_entry_bytes(&self, name: &str, bytes: &[u8]) {
536        if let Some(cb) = &self.entry_callback {
537            cb(name, bytes);
538        }
539    }
540
541    /// Find the first profile matching a filename.
542    fn find_profile(&self, filename: &str) -> Option<&FileTypeProfile> {
543        self.profiles.iter().find(|p| p.matches_filename(filename))
544    }
545
546    fn emit_progress(&self, stats: &ArchiveStats, total_entries: Option<u64>, current_entry: &str) {
547        if let Some(callback) = &self.progress_callback {
548            callback(&ArchiveProgress {
549                entries_seen: stats.files_processed + stats.entries_skipped,
550                files_processed: stats.files_processed,
551                entries_skipped: stats.entries_skipped,
552                total_entries,
553                current_entry: current_entry.to_string(),
554            });
555        }
556    }
557
558    /// Sanitize a file entry given its raw bytes.
559    ///
560    /// Returns the sanitized bytes together with a fresh [`ArchiveStats`]
561    /// covering only this entry. This is the core work unit for parallel
562    /// entry processing in [`process_tar_at_depth`] and
563    /// [`process_zip_at_depth`].
564    fn sanitize_entry_bytes(
565        &self,
566        filename: &str,
567        data: &[u8],
568        entry_size_hint: Option<u64>,
569        depth: u32,
570    ) -> Result<(Vec<u8>, ArchiveStats)> {
571        let mut out: Vec<u8> = Vec::with_capacity(data.len());
572        let mut entry_stats = ArchiveStats::default();
573        let mut reader = io::Cursor::new(data);
574        self.sanitize_entry(
575            filename,
576            &mut reader,
577            &mut out,
578            &mut entry_stats,
579            entry_size_hint,
580            depth,
581        )?;
582        Ok((out, entry_stats))
583    }
584
585    /// Sanitize the content of a single file entry.
586    ///
587    /// If the entry is itself an archive (detected via extension), it is
588    /// recursively processed up to `self.max_depth`. Otherwise, tries a
589    /// structured processor first; falls back to the streaming scanner
590    /// if no processor matches.
591    ///
592    /// For the streaming scanner path, the content is piped through
593    /// `scan_reader` directly to the writer for memory-efficient
594    /// chunk-based processing (F-02 fix: no full output buffering).
595    #[allow(clippy::missing_errors_doc)] // private method
596    fn sanitize_entry(
597        &self,
598        filename: &str,
599        reader: &mut dyn Read,
600        writer: &mut dyn Write,
601        stats: &mut ArchiveStats,
602        entry_size_hint: Option<u64>,
603        depth: u32,
604    ) -> Result<()> {
605        // --- Nested archive detection ---
606        if let Some(nested_fmt) = ArchiveFormat::from_path(filename) {
607            return self.sanitize_nested_archive(
608                filename,
609                reader,
610                writer,
611                stats,
612                entry_size_hint,
613                nested_fmt,
614                depth,
615            );
616        }
617
618        // --- Structured / scanner processing ---
619
620        // Try structured processing first, but only if the entry is
621        // within the size cap and --force-text is not set.
622        // Oversized entries fall through to the streaming scanner (M-3 fix).
623        let within_size_cap = entry_size_hint.is_none_or(|sz| sz <= STRUCTURED_ENTRY_SIZE); // unknown size → allow (conservative)
624
625        if !self.force_text && within_size_cap {
626            if let Some(profile) = self.find_profile(filename) {
627                // Structured processors need the full content in memory.
628                let mut content = Vec::new();
629                reader.read_to_end(&mut content).map_err(|e| {
630                    SanitizeError::ArchiveError(format!("read entry '{filename}': {e}"))
631                })?;
632
633                stats.total_input_bytes += content.len() as u64;
634
635                // A parse error (e.g. binary content with a .yaml extension, like
636                // macOS resource-fork ._* files) falls through to the scanner
637                // rather than failing the whole archive.
638                // A parse error or heuristic rejection falls through to the scanner below.
639                let pre_snapshot = self.store.snapshot();
640                // Prefer span-based edit mode (field values replaced in place —
641                // exact, format-preserving, leak-free even for escaped values).
642                // Fall back to the literal structured pass, whose re-serialized
643                // output is discarded; either way the store is populated.
644                let structured_base: Option<Vec<u8>> =
645                    match self
646                        .registry
647                        .process_to_edits(&content, profile, &self.store)
648                    {
649                        Ok(Some((edited, _count))) => Some(edited),
650                        Ok(None) => match self.registry.process(&content, profile, &self.store) {
651                            Ok(Some(_)) => Some(content.clone()),
652                            _ => None,
653                        },
654                        Err(_) => None,
655                    };
656                if let Some(base) = structured_base {
657                    // Run the format-preserving scanner over the structured-redacted
658                    // bytes to catch the same values in comments / unstructured
659                    // regions. Field values discovered *for this entry* are added
660                    // as literal patterns so they are redacted even when the
661                    // caller's scanner does not already carry them (library usage
662                    // without a pre-discovery pass). When the delta is empty — the
663                    // common case in the CLI, where the augmented scanner already
664                    // holds every literal — a cached scanner is reused to avoid
665                    // rebuilding the automaton per entry.
666                    let extra: Vec<ScanPattern> = self
667                        .store
668                        .iter_since(pre_snapshot)
669                        .filter_map(|(category, original, _)| {
670                            // Label by category, never the value (labels surface
671                            // in report/findings/summary output — no secrets).
672                            let label = format!("field:{category}");
673                            ScanPattern::from_literal(original.as_str(), category, label).ok()
674                        })
675                        .collect();
676                    let (output, scan_stats) = if extra.is_empty() {
677                        self.structured_pass_scanner()?.scan_bytes(&base)?
678                    } else {
679                        self.scanner.for_structured_pass(extra)?.scan_bytes(&base)?
680                    };
681                    stats.structured_hits += 1;
682                    stats.total_output_bytes += output.len() as u64;
683                    stats.file_methods.insert(
684                        filename.to_string(),
685                        format!("structured+scan:{}", profile.processor),
686                    );
687                    stats
688                        .file_scan_stats
689                        .insert(filename.to_string(), scan_stats);
690                    writer.write_all(&output).map_err(|e| {
691                        SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
692                    })?;
693                    return Ok(());
694                }
695
696                // Processor didn't match or failed — fall back to
697                // scanner with the already-buffered content.
698                let (output, scan_stats) = self.scanner.scan_bytes(&content)?;
699                stats.scanner_fallback += 1;
700                stats.total_output_bytes += output.len() as u64;
701                stats
702                    .file_methods
703                    .insert(filename.to_string(), "scanner".to_string());
704                stats
705                    .file_scan_stats
706                    .insert(filename.to_string(), scan_stats);
707                writer.write_all(&output).map_err(|e| {
708                    SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
709                })?;
710                return Ok(());
711            }
712        }
713
714        // No profile (or entry too large) → streaming scanner.
715        // F-02 fix: stream directly from reader → scanner → writer
716        // without buffering the full output. We use a CountingWriter
717        // to track output bytes alongside the CountingReader for input.
718        let mut counting_r = CountingReader::new(reader);
719        let mut counting_w = CountingWriter::new(writer);
720        let scan_stats = self.scanner.scan_reader(&mut counting_r, &mut counting_w)?;
721
722        stats.scanner_fallback += 1;
723        stats.total_input_bytes += counting_r.bytes_read();
724        stats.total_output_bytes += counting_w.bytes_written();
725        stats
726            .file_methods
727            .insert(filename.to_string(), "scanner".to_string());
728        stats
729            .file_scan_stats
730            .insert(filename.to_string(), scan_stats);
731
732        Ok(())
733    }
734
735    /// Handle a nested archive entry: validate depth/size, buffer, recurse,
736    /// and write the sanitized output.
737    #[allow(clippy::too_many_arguments)]
738    fn sanitize_nested_archive(
739        &self,
740        filename: &str,
741        reader: &mut dyn Read,
742        writer: &mut dyn Write,
743        stats: &mut ArchiveStats,
744        entry_size_hint: Option<u64>,
745        nested_fmt: ArchiveFormat,
746        depth: u32,
747    ) -> Result<()> {
748        if depth >= self.max_depth {
749            return Err(SanitizeError::RecursionDepthExceeded(format!(
750                "nested archive '{}' at depth {} exceeds maximum nesting depth of {}",
751                filename, depth, self.max_depth,
752            )));
753        }
754
755        // Buffer the nested archive (always bounded by STRUCTURED_ENTRY_SIZE).
756        // The size hint (from a zip central-directory entry) is checked first for
757        // a fast early rejection; the bounded read enforces the cap even when the
758        // hint is absent (e.g. some tar entries) to prevent unbounded heap growth.
759        if let Some(sz) = entry_size_hint {
760            if sz > STRUCTURED_ENTRY_SIZE {
761                return Err(SanitizeError::ArchiveError(format!(
762                    "nested archive '{}' is too large ({} bytes, limit {} bytes)",
763                    filename, sz, STRUCTURED_ENTRY_SIZE,
764                )));
765            }
766        }
767
768        let content = read_bounded(reader, STRUCTURED_ENTRY_SIZE, filename)?;
769        stats.total_input_bytes += content.len() as u64;
770
771        // Recurse into the nested archive.
772        let mut output_buf: Vec<u8> = Vec::new();
773        let child_stats = match nested_fmt {
774            ArchiveFormat::Tar => {
775                self.process_tar_at_depth(&content[..], &mut output_buf, depth + 1)?
776            }
777            ArchiveFormat::TarGz => {
778                self.process_tar_gz_at_depth(&content[..], &mut output_buf, depth + 1)?
779            }
780            ArchiveFormat::Zip => {
781                let reader = io::Cursor::new(&content);
782                let mut writer = io::Cursor::new(Vec::new());
783                let s = self.process_zip_at_depth(reader, &mut writer, depth + 1)?;
784                output_buf = writer.into_inner();
785                s
786            }
787        };
788
789        stats.nested_archives += 1;
790        stats.merge(&child_stats);
791        stats.total_output_bytes += output_buf.len() as u64;
792        let fmt_name = match nested_fmt {
793            ArchiveFormat::Tar => "tar",
794            ArchiveFormat::TarGz => "tar.gz",
795            ArchiveFormat::Zip => "zip",
796        };
797        stats
798            .file_methods
799            .insert(filename.to_string(), format!("nested:{fmt_name}"));
800        writer.write_all(&output_buf).map_err(|e| {
801            SanitizeError::ArchiveError(format!("write nested archive '{filename}': {e}"))
802        })?;
803        Ok(())
804    }
805
806    // -----------------------------------------------------------------------
807    // Profile discovery passes (two-phase support)
808    // -----------------------------------------------------------------------
809    //
810    // These methods perform a read-only pre-pass over an archive, running the
811    // structured processor on every profile-matched entry and discarding the
812    // output.  The side-effect is that `self.store` is populated with the
813    // original→replacement mappings for those fields, so a subsequent call to
814    // `build_augmented_scanner` can inject those values as literals into the
815    // scanner used for the real processing pass.
816
817    /// Run the structured processor on every profile-matched entry in a
818    /// `.tar` archive, recording replacements into the store.  Output is
819    /// discarded; the archive is not modified.
820    ///
821    /// # Errors
822    ///
823    /// Returns an error if the archive cannot be read or an entry cannot be processed.
824    pub fn discover_profiles_tar<R: Read>(&self, reader: R) -> Result<()> {
825        if self.profiles.is_empty() {
826            return Ok(());
827        }
828        let mut archive = tar::Archive::new(reader);
829        let entries = archive
830            .entries()
831            .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entries: {e}")))?;
832        for entry_result in entries {
833            let mut entry = entry_result
834                .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entry: {e}")))?;
835            if !entry.header().entry_type().is_file() {
836                continue;
837            }
838            let path = entry
839                .path()
840                .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
841                .to_string_lossy()
842                .to_string();
843            let Some(profile) = self.find_profile(&path) else {
844                continue;
845            };
846            let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &path) {
847                Ok(c) => c,
848                Err(e) => {
849                    tracing::warn!(path = %path, error = %e, "discovery: skipping oversized entry");
850                    continue;
851                }
852            };
853            if let Err(e) = self.registry.process(&content, profile, &self.store) {
854                tracing::warn!(path = %path, error = %e, "discovery: structured processor failed; partial mappings may persist");
855            }
856        }
857        Ok(())
858    }
859
860    /// Run the structured processor on every profile-matched entry in a
861    /// `.tar.gz` archive, recording replacements into the store.  Output is
862    /// discarded; the archive is not modified.
863    ///
864    /// # Errors
865    ///
866    /// Returns an error if the archive cannot be read or an entry cannot be processed.
867    pub fn discover_profiles_tar_gz<R: Read>(&self, reader: R) -> Result<()> {
868        let gz = flate2::read::GzDecoder::new(reader);
869        self.discover_profiles_tar(gz)
870    }
871
872    /// Run the structured processor on every profile-matched entry in a
873    /// `.zip` archive, recording replacements into the store.  Output is
874    /// discarded; the archive is not modified.
875    ///
876    /// # Errors
877    ///
878    /// Returns an error if the archive cannot be read or an entry cannot be processed.
879    pub fn discover_profiles_zip<R: Read + Seek>(&self, reader: R) -> Result<()> {
880        if self.profiles.is_empty() {
881            return Ok(());
882        }
883        let mut zip = zip::ZipArchive::new(reader)
884            .map_err(|e| SanitizeError::ArchiveError(format!("open zip for discovery: {e}")))?;
885        for i in 0..zip.len() {
886            let mut entry = zip
887                .by_index(i)
888                .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {i}: {e}")))?;
889            if entry.is_dir() {
890                continue;
891            }
892            let name = sanitize_zip_entry_name(entry.name());
893            let Some(profile) = self.find_profile(&name) else {
894                continue;
895            };
896            let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &name) {
897                Ok(c) => c,
898                Err(e) => {
899                    tracing::warn!(name = %name, error = %e, "discovery: skipping oversized entry");
900                    continue;
901                }
902            };
903            if let Err(e) = self.registry.process(&content, profile, &self.store) {
904                tracing::warn!(name = %name, error = %e, "discovery: structured processor failed; partial mappings may persist");
905            }
906        }
907        Ok(())
908    }
909
910    // Tar processing
911    // -----------------------------------------------------------------------
912
913    /// Process a `.tar` archive, sanitizing each file entry and
914    /// rebuilding the archive with preserved metadata.
915    ///
916    /// Entries that are not regular files (directories, symlinks, etc.)
917    /// are copied through unchanged.
918    ///
919    /// # Errors
920    ///
921    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
922    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
923    pub fn process_tar<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
924        self.process_tar_at_depth(reader, writer, 0)
925    }
926
927    /// Internal: process a tar archive at a given nesting depth.
928    ///
929    /// Uses a speculative-buffer strategy to decide between parallel and
930    /// sequential processing:
931    ///
932    /// - **Parallel** (total buffered data ≤ `PARALLEL_TAR_DATA_SIZE` AND
933    ///   file count ≥ threshold AND not inside a rayon worker): buffer all
934    ///   entries, sanitize concurrently with rayon, write in source order.
935    /// - **Sequential — buffered** (threshold not met but data fits): process
936    ///   entries from the in-memory buffer one at a time.
937    /// - **Sequential — streaming** (data exceeds cap mid-stream): process
938    ///   already-buffered entries from memory, then continue streaming the
939    ///   remainder of the archive without additional buffering.
940    ///
941    /// Unlike zip, tar has no central directory so sizes cannot be known before
942    /// reading. The buffer cap (`PARALLEL_TAR_DATA_SIZE`) bounds peak memory to
943    /// cap + one entry overhead regardless of archive size.
944    #[allow(clippy::too_many_lines)]
945    fn process_tar_at_depth<R: Read, W: Write>(
946        &self,
947        reader: R,
948        writer: W,
949        depth: u32,
950    ) -> Result<ArchiveStats> {
951        struct TarEntry {
952            header: tar::Header,
953            path: String,
954            is_file: bool,
955            passes_filter: bool,
956            data: Vec<u8>,
957        }
958
959        let mut archive = tar::Archive::new(reader);
960        let mut builder = tar::Builder::new(writer);
961        let mut stats = ArchiveStats::default();
962
963        // --- Phase 1: speculative buffering ----------------------------------
964        // Stream entries into memory, tracking total file-data size.
965        // Stop buffering (but keep the last entry) if the cap is exceeded.
966        let mut entries_iter = archive
967            .entries()
968            .map_err(|e| SanitizeError::ArchiveError(format!("read tar entries: {e}")))?;
969
970        let mut buffered: Vec<TarEntry> = Vec::new();
971        let mut file_count: usize = 0;
972        let mut total_data: u64 = 0;
973        let mut overflowed = false;
974
975        for entry_result in entries_iter.by_ref() {
976            let mut entry = entry_result
977                .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
978
979            let header = entry.header().clone();
980            let path = entry
981                .path()
982                .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
983                .to_string_lossy()
984                .into_owned();
985            let is_file = header.entry_type().is_file();
986            let passes_filter = !is_file || self.filter.passes(&path);
987
988            let mut data = Vec::new();
989            entry
990                .read_to_end(&mut data)
991                .map_err(|e| SanitizeError::ArchiveError(format!("read entry '{path}': {e}")))?;
992            drop(entry);
993
994            if is_file && passes_filter {
995                file_count += 1;
996                total_data = total_data.saturating_add(data.len() as u64);
997            }
998
999            buffered.push(TarEntry {
1000                header,
1001                path,
1002                is_file,
1003                passes_filter,
1004                data,
1005            });
1006
1007            if total_data > PARALLEL_TAR_DATA_SIZE {
1008                overflowed = true;
1009                break;
1010            }
1011        }
1012
1013        // --- Phase 2: choose strategy ----------------------------------------
1014        let use_parallel = !overflowed
1015            && file_count >= self.parallel_threshold
1016            && rayon::current_thread_index().is_none();
1017
1018        if use_parallel {
1019            // --- Parallel path -----------------------------------------------
1020            // Sanitize all file entries concurrently; write in source order.
1021            let file_indices: Vec<usize> = buffered
1022                .iter()
1023                .enumerate()
1024                .filter(|(_, e)| e.is_file && e.passes_filter)
1025                .map(|(i, _)| i)
1026                .collect();
1027
1028            let results: Vec<ParEntryResult> = file_indices
1029                .into_par_iter()
1030                .map(|i| {
1031                    let e = &buffered[i];
1032                    let size_hint = e.header.size().ok();
1033                    (
1034                        i,
1035                        self.sanitize_entry_bytes(&e.path, &e.data, size_hint, depth),
1036                    )
1037                })
1038                .collect();
1039
1040            let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; buffered.len()];
1041            for (i, r) in results {
1042                sanitized[i] = Some(r?);
1043            }
1044
1045            for (i, entry) in buffered.iter().enumerate() {
1046                if !entry.is_file {
1047                    builder
1048                        .append(&entry.header, entry.data.as_slice())
1049                        .map_err(|e| {
1050                            SanitizeError::ArchiveError(format!("append '{}': {e}", entry.path))
1051                        })?;
1052                    stats.entries_skipped += 1;
1053                    self.emit_progress(&stats, None, &entry.path);
1054                    continue;
1055                }
1056                if !entry.passes_filter {
1057                    stats.entries_filtered += 1;
1058                    self.emit_progress(&stats, None, &entry.path);
1059                    continue;
1060                }
1061
1062                let (sanitized_buf, entry_stats) =
1063                    sanitized[i].take().expect("parallel result missing");
1064                stats.merge(&entry_stats);
1065                self.emit_entry_bytes(&entry.path, &sanitized_buf);
1066
1067                let mut new_header = entry.header.clone();
1068                let safe_path = sanitize_tar_entry_name(&entry.path);
1069                new_header.set_path(&safe_path).map_err(|e| {
1070                    SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1071                })?;
1072                new_header.set_size(sanitized_buf.len() as u64);
1073                new_header.set_cksum();
1074                builder
1075                    .append(&new_header, sanitized_buf.as_slice())
1076                    .map_err(|e| {
1077                        SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1078                    })?;
1079                stats.files_processed += 1;
1080                self.emit_progress(&stats, None, &entry.path);
1081            }
1082        } else {
1083            // --- Sequential path ---------------------------------------------
1084            // Process buffered entries first, then stream the remainder.
1085
1086            // Helper: write one buffered entry to the builder.
1087            let write_buffered = |entry: &TarEntry,
1088                                  builder: &mut tar::Builder<W>,
1089                                  stats: &mut ArchiveStats,
1090                                  processor: &ArchiveProcessor|
1091             -> Result<()> {
1092                if !entry.is_file {
1093                    builder
1094                        .append(&entry.header, entry.data.as_slice())
1095                        .map_err(|e| {
1096                            SanitizeError::ArchiveError(format!("append '{}': {e}", entry.path))
1097                        })?;
1098                    stats.entries_skipped += 1;
1099                    processor.emit_progress(stats, None, &entry.path);
1100                    return Ok(());
1101                }
1102                if !entry.passes_filter {
1103                    stats.entries_filtered += 1;
1104                    processor.emit_progress(stats, None, &entry.path);
1105                    return Ok(());
1106                }
1107                let size_hint = entry.header.size().ok();
1108                let (sanitized_buf, entry_stats) =
1109                    processor.sanitize_entry_bytes(&entry.path, &entry.data, size_hint, depth)?;
1110                stats.merge(&entry_stats);
1111                processor.emit_entry_bytes(&entry.path, &sanitized_buf);
1112                let mut new_header = entry.header.clone();
1113                let safe_path = sanitize_tar_entry_name(&entry.path);
1114                new_header.set_path(&safe_path).map_err(|e| {
1115                    SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1116                })?;
1117                new_header.set_size(sanitized_buf.len() as u64);
1118                new_header.set_cksum();
1119                builder
1120                    .append(&new_header, sanitized_buf.as_slice())
1121                    .map_err(|e| {
1122                        SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1123                    })?;
1124                stats.files_processed += 1;
1125                processor.emit_progress(stats, None, &entry.path);
1126                Ok(())
1127            };
1128
1129            for entry in &buffered {
1130                write_buffered(entry, &mut builder, &mut stats, self)?;
1131            }
1132            drop(buffered);
1133
1134            // Stream remaining entries when the buffer cap was exceeded.
1135            if overflowed {
1136                for entry_result in entries_iter {
1137                    let mut entry = entry_result
1138                        .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
1139
1140                    let header = entry.header().clone();
1141                    let path = entry
1142                        .path()
1143                        .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1144                        .to_string_lossy()
1145                        .into_owned();
1146                    let is_file = header.entry_type().is_file();
1147
1148                    if !is_file {
1149                        let mut data = Vec::new();
1150                        entry.read_to_end(&mut data).map_err(|e| {
1151                            SanitizeError::ArchiveError(format!("read '{path}': {e}"))
1152                        })?;
1153                        drop(entry);
1154                        builder.append(&header, data.as_slice()).map_err(|e| {
1155                            SanitizeError::ArchiveError(format!("append '{path}': {e}"))
1156                        })?;
1157                        stats.entries_skipped += 1;
1158                        self.emit_progress(&stats, None, &path);
1159                        continue;
1160                    }
1161
1162                    if !self.filter.passes(&path) {
1163                        stats.entries_filtered += 1;
1164                        continue;
1165                    }
1166
1167                    let size_hint = header.size().ok();
1168                    let mut sanitized_buf = Vec::new();
1169                    let mut entry_stats = ArchiveStats::default();
1170                    self.sanitize_entry(
1171                        &path,
1172                        &mut entry,
1173                        &mut sanitized_buf,
1174                        &mut entry_stats,
1175                        size_hint,
1176                        depth,
1177                    )?;
1178                    drop(entry);
1179                    self.emit_entry_bytes(&path, &sanitized_buf);
1180
1181                    let mut new_header = header.clone();
1182                    let safe_path = sanitize_tar_entry_name(&path);
1183                    new_header.set_path(&safe_path).map_err(|e| {
1184                        SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1185                    })?;
1186                    new_header.set_size(sanitized_buf.len() as u64);
1187                    new_header.set_cksum();
1188                    builder
1189                        .append(&new_header, sanitized_buf.as_slice())
1190                        .map_err(|e| {
1191                            SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1192                        })?;
1193
1194                    stats.merge(&entry_stats);
1195                    stats.files_processed += 1;
1196                    self.emit_progress(&stats, None, &path);
1197                }
1198            }
1199        }
1200
1201        builder
1202            .finish()
1203            .map_err(|e| SanitizeError::ArchiveError(format!("finalize tar: {e}")))?;
1204
1205        Ok(stats)
1206    }
1207
1208    /// Process a `.tar.gz` archive (gzip-compressed tar).
1209    ///
1210    /// Decompresses on the fly, processes each entry, and recompresses
1211    /// the output.
1212    ///
1213    /// # Errors
1214    ///
1215    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1216    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1217    pub fn process_tar_gz<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
1218        self.process_tar_gz_at_depth(reader, writer, 0)
1219    }
1220
1221    /// Internal: process a tar.gz archive at a given nesting depth.
1222    fn process_tar_gz_at_depth<R: Read, W: Write>(
1223        &self,
1224        reader: R,
1225        writer: W,
1226        depth: u32,
1227    ) -> Result<ArchiveStats> {
1228        let gz_reader = flate2::read::GzDecoder::new(reader);
1229        let gz_writer = flate2::write::GzEncoder::new(writer, flate2::Compression::fast());
1230
1231        let stats = self.process_tar_at_depth(gz_reader, gz_writer, depth)?;
1232        // GzEncoder is flushed when the tar builder finishes and the
1233        // encoder is dropped. The `finish()` call in `process_tar`
1234        // flushes the tar builder, which flushes writes to the
1235        // GzEncoder. When the GzEncoder is dropped it finalises the
1236        // gzip stream.
1237        Ok(stats)
1238    }
1239
1240    // -----------------------------------------------------------------------
1241    // Zip processing
1242    // -----------------------------------------------------------------------
1243
1244    /// Process a `.zip` archive, sanitizing each file entry and
1245    /// rebuilding the archive with preserved metadata.
1246    ///
1247    /// # Type Bounds
1248    ///
1249    /// Zip requires seekable I/O for both reading and writing.
1250    ///
1251    /// # Errors
1252    ///
1253    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1254    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1255    pub fn process_zip<R: Read + Seek, W: Write + Seek>(
1256        &self,
1257        reader: R,
1258        writer: W,
1259    ) -> Result<ArchiveStats> {
1260        self.process_zip_at_depth(reader, writer, 0)
1261    }
1262
1263    /// Internal: process a zip archive at a given nesting depth.
1264    ///
1265    /// Uses a lightweight metadata pre-pass (local-header reads, no data
1266    /// decompression) to decide between parallel and sequential strategies:
1267    ///
1268    /// - **Parallel** (total uncompressed ≤ `PARALLEL_ZIP_DATA_SIZE` AND
1269    ///   file count ≥ threshold AND depth == 0): load all entry data into
1270    ///   memory, sanitize with rayon, write in order.
1271    /// - **Sequential** (everything else): read → sanitize → write one entry
1272    ///   at a time.  Peak memory is bounded to 2 × largest single entry.
1273    #[allow(clippy::too_many_lines)]
1274    fn process_zip_at_depth<R: Read + Seek, W: Write + Seek>(
1275        &self,
1276        reader: R,
1277        writer: W,
1278        depth: u32,
1279    ) -> Result<ArchiveStats> {
1280        // --- Stage 0: metadata pre-pass (no data reads) ---------------------
1281        // Read local file headers to collect names, sizes, and options.
1282        // This does N seeks but decompresses nothing, keeping memory flat.
1283        struct ZipMeta {
1284            name: String,
1285            is_dir: bool,
1286            compression: zip::CompressionMethod,
1287            last_modified: Option<zip::DateTime>,
1288            unix_mode: Option<u32>,
1289            size: u64,
1290        }
1291
1292        let mut zip_in = zip::ZipArchive::new(reader)
1293            .map_err(|e| SanitizeError::ArchiveError(format!("open zip: {}", e)))?;
1294        let total_entries = zip_in.len();
1295        let total_entries_hint = Some(total_entries as u64);
1296
1297        let mut metas: Vec<ZipMeta> = Vec::with_capacity(total_entries);
1298        let mut file_count = 0usize;
1299        let mut total_uncompressed_size: u64 = 0;
1300
1301        for i in 0..total_entries {
1302            let entry = zip_in
1303                .by_index(i)
1304                .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1305            let is_dir = entry.is_dir();
1306            let size = entry.size();
1307            if !is_dir {
1308                file_count += 1;
1309                total_uncompressed_size = total_uncompressed_size.saturating_add(size);
1310            }
1311            metas.push(ZipMeta {
1312                name: sanitize_zip_entry_name(entry.name()),
1313                is_dir,
1314                compression: entry.compression(),
1315                last_modified: entry.last_modified(),
1316                unix_mode: entry.unix_mode(),
1317                size,
1318            });
1319            // entry dropped here — no data decompressed
1320        }
1321
1322        // Parallel only when the total data fits comfortably in memory.
1323        // Parallel when: enough entries, data fits in memory, and we are not
1324        // already running inside a rayon worker thread (nested parallelism
1325        // would over-subscribe the pool without proportional gains).
1326        let use_parallel = file_count >= self.parallel_threshold
1327            && rayon::current_thread_index().is_none()
1328            && total_uncompressed_size <= PARALLEL_ZIP_DATA_SIZE;
1329
1330        let mut stats = ArchiveStats::default();
1331
1332        // Helper: build SimpleFileOptions for a metadata entry.
1333        let make_options = |m: &ZipMeta| {
1334            let mut opts =
1335                zip::write::SimpleFileOptions::default().compression_method(m.compression);
1336            if let Some(dt) = m.last_modified {
1337                opts = opts.last_modified_time(dt);
1338            }
1339            if let Some(mode) = m.unix_mode {
1340                opts.unix_permissions(mode)
1341            } else {
1342                opts
1343            }
1344        };
1345
1346        if use_parallel {
1347            // --- Parallel path: load all data then sanitize concurrently ----
1348            struct ZipEntry {
1349                meta_idx: usize,
1350                data: Vec<u8>,
1351            }
1352
1353            let mut file_entries: Vec<ZipEntry> = Vec::with_capacity(file_count);
1354
1355            for (i, meta) in metas.iter().enumerate() {
1356                if meta.is_dir {
1357                    continue;
1358                }
1359                // Skip loading data for entries that will be filtered out.
1360                if !self.filter.passes(&meta.name) {
1361                    continue;
1362                }
1363                let mut entry = zip_in
1364                    .by_index(i)
1365                    .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1366                let mut data = Vec::new();
1367                entry.read_to_end(&mut data).map_err(|e| {
1368                    SanitizeError::ArchiveError(format!("read zip entry '{}': {}", meta.name, e))
1369                })?;
1370                file_entries.push(ZipEntry { meta_idx: i, data });
1371            }
1372
1373            let results: Vec<ParEntryResult> = file_entries
1374                .into_par_iter()
1375                .map(|e| {
1376                    let meta = &metas[e.meta_idx];
1377                    let result =
1378                        self.sanitize_entry_bytes(&meta.name, &e.data, Some(meta.size), depth);
1379                    (e.meta_idx, result)
1380                })
1381                .collect();
1382
1383            // Collect into a positional Vec (indexed by metas position) for
1384            // O(1) ordered writes, avoiding HashMap hashing overhead.
1385            let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; metas.len()];
1386            for (meta_idx, r) in results {
1387                sanitized[meta_idx] = Some(r?);
1388            }
1389
1390            let mut zip_out = zip::ZipWriter::new(writer);
1391            for (i, meta) in metas.iter().enumerate() {
1392                let options = make_options(meta);
1393                if meta.is_dir {
1394                    zip_out.add_directory(&meta.name, options).map_err(|e| {
1395                        SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1396                    })?;
1397                    stats.entries_skipped += 1;
1398                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1399                    continue;
1400                }
1401                // Filter: drop entries not matching --only/--exclude rules.
1402                if !self.filter.passes(&meta.name) {
1403                    stats.entries_filtered += 1;
1404                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1405                    continue;
1406                }
1407                let (sanitized_buf, entry_stats) = sanitized[i]
1408                    .take()
1409                    .expect("file entry sanitization result missing");
1410                stats.merge(&entry_stats);
1411                self.emit_entry_bytes(&meta.name, &sanitized_buf);
1412                zip_out.start_file(&meta.name, options).map_err(|e| {
1413                    SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1414                })?;
1415                zip_out.write_all(&sanitized_buf).map_err(|e| {
1416                    SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1417                })?;
1418                stats.files_processed += 1;
1419                self.emit_progress(&stats, total_entries_hint, &meta.name);
1420            }
1421            zip_out
1422                .finish()
1423                .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1424        } else {
1425            // --- Sequential path: one entry at a time -----------------------
1426            // Only one entry's data (input + sanitized output) is live at once.
1427            let mut zip_out = zip::ZipWriter::new(writer);
1428            for (i, meta) in metas.iter().enumerate() {
1429                let options = make_options(meta);
1430                if meta.is_dir {
1431                    zip_out.add_directory(&meta.name, options).map_err(|e| {
1432                        SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1433                    })?;
1434                    stats.entries_skipped += 1;
1435                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1436                    continue;
1437                }
1438
1439                // Filter: drop entries not matching --only/--exclude rules.
1440                if !self.filter.passes(&meta.name) {
1441                    stats.entries_filtered += 1;
1442                    self.emit_progress(&stats, total_entries_hint, &meta.name);
1443                    continue;
1444                }
1445
1446                let data = {
1447                    let mut entry = zip_in.by_index(i).map_err(|e| {
1448                        SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e))
1449                    })?;
1450                    let mut buf = Vec::new();
1451                    entry.read_to_end(&mut buf).map_err(|e| {
1452                        SanitizeError::ArchiveError(format!(
1453                            "read zip entry '{}': {}",
1454                            meta.name, e
1455                        ))
1456                    })?;
1457                    buf
1458                    // entry dropped here
1459                };
1460
1461                let (sanitized_buf, entry_stats) =
1462                    self.sanitize_entry_bytes(&meta.name, &data, Some(meta.size), depth)?;
1463                drop(data);
1464                self.emit_entry_bytes(&meta.name, &sanitized_buf);
1465
1466                zip_out.start_file(&meta.name, options).map_err(|e| {
1467                    SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1468                })?;
1469                zip_out.write_all(&sanitized_buf).map_err(|e| {
1470                    SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1471                })?;
1472                drop(sanitized_buf);
1473
1474                stats.merge(&entry_stats);
1475                stats.files_processed += 1;
1476                self.emit_progress(&stats, total_entries_hint, &meta.name);
1477            }
1478            zip_out
1479                .finish()
1480                .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1481        }
1482
1483        Ok(stats)
1484    }
1485
1486    // -----------------------------------------------------------------------
1487    // Format-aware dispatch
1488    // -----------------------------------------------------------------------
1489
1490    /// Auto-detect the archive format and process accordingly.
1491    ///
1492    /// For zip archives the reader must additionally implement `Seek`.
1493    /// This method accepts `Read + Seek` to cover all formats uniformly.
1494    /// Tar and tar.gz do not require seeking, but the bound is imposed
1495    /// for a single entry point.
1496    ///
1497    /// # Errors
1498    ///
1499    /// Returns [`SanitizeError::ArchiveError`] on I/O failures or
1500    /// [`SanitizeError::RecursionDepthExceeded`] for nested archives.
1501    pub fn process<R: Read + Seek, W: Write + Seek>(
1502        &self,
1503        reader: R,
1504        writer: W,
1505        format: ArchiveFormat,
1506    ) -> Result<ArchiveStats> {
1507        match format {
1508            ArchiveFormat::Zip => self.process_zip(reader, writer),
1509            ArchiveFormat::Tar => self.process_tar(reader, writer),
1510            ArchiveFormat::TarGz => self.process_tar_gz(reader, writer),
1511        }
1512    }
1513}
1514
1515// ---------------------------------------------------------------------------
1516// Counting reader wrapper (for input byte tracking)
1517// ---------------------------------------------------------------------------
1518
1519/// A thin wrapper around a reader that counts bytes read.
1520struct CountingReader<'a> {
1521    inner: &'a mut dyn Read,
1522    count: u64,
1523}
1524
1525impl<'a> CountingReader<'a> {
1526    fn new(inner: &'a mut dyn Read) -> Self {
1527        Self { inner, count: 0 }
1528    }
1529
1530    fn bytes_read(&self) -> u64 {
1531        self.count
1532    }
1533}
1534
1535impl Read for CountingReader<'_> {
1536    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1537        let n = self.inner.read(buf)?;
1538        self.count += n as u64;
1539        Ok(n)
1540    }
1541}
1542
1543/// A thin wrapper around a writer that counts bytes written (F-02 fix).
1544struct CountingWriter<'a> {
1545    inner: &'a mut dyn Write,
1546    count: u64,
1547}
1548
1549impl<'a> CountingWriter<'a> {
1550    fn new(inner: &'a mut dyn Write) -> Self {
1551        Self { inner, count: 0 }
1552    }
1553
1554    fn bytes_written(&self) -> u64 {
1555        self.count
1556    }
1557}
1558
1559impl Write for CountingWriter<'_> {
1560    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1561        let n = self.inner.write(buf)?;
1562        self.count += n as u64;
1563        Ok(n)
1564    }
1565
1566    fn flush(&mut self) -> io::Result<()> {
1567        self.inner.flush()
1568    }
1569}
1570
1571// ---------------------------------------------------------------------------
1572// Tests
1573// ---------------------------------------------------------------------------
1574
1575#[cfg(test)]
1576mod tests {
1577    use super::*;
1578    use crate::category::Category;
1579    use crate::generator::HmacGenerator;
1580    use crate::processor::profile::{FieldRule, FileTypeProfile};
1581    use crate::processor::registry::ProcessorRegistry;
1582    use crate::scanner::{ScanConfig, ScanPattern};
1583    use std::io::Cursor;
1584    use std::sync::Mutex;
1585
1586    /// Build a test archive processor with an email pattern and a JSON profile.
1587    fn make_archive_processor() -> ArchiveProcessor {
1588        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1589        let store = Arc::new(MappingStore::new(gen, None));
1590
1591        let patterns = vec![
1592            ScanPattern::from_regex(
1593                r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
1594                Category::Email,
1595                "email",
1596            )
1597            .unwrap(),
1598            ScanPattern::from_literal("SUPERSECRET", Category::Custom("api_key".into()), "api_key")
1599                .unwrap(),
1600        ];
1601
1602        let scanner = Arc::new(
1603            StreamScanner::new(patterns, Arc::clone(&store), ScanConfig::default()).unwrap(),
1604        );
1605
1606        let registry = Arc::new(ProcessorRegistry::with_builtins());
1607
1608        let profiles = vec![FileTypeProfile::new(
1609            "json",
1610            vec![FieldRule::new("*").with_category(Category::Custom("field".into()))],
1611        )
1612        .with_extension(".json")];
1613
1614        ArchiveProcessor::new(registry, scanner, store, profiles)
1615    }
1616
1617    // -- Tar tests ----------------------------------------------------------
1618
1619    fn build_test_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
1620        let mut buf = Vec::new();
1621        {
1622            let mut builder = tar::Builder::new(&mut buf);
1623            for (name, data) in entries {
1624                let mut header = tar::Header::new_gnu();
1625                header.set_size(data.len() as u64);
1626                header.set_mode(0o644);
1627                header.set_mtime(1_700_000_000);
1628                header.set_cksum();
1629                builder.append_data(&mut header, *name, *data).unwrap();
1630            }
1631            builder.finish().unwrap();
1632        }
1633        buf
1634    }
1635
1636    #[test]
1637    fn tar_sanitizes_plaintext_with_scanner() {
1638        let proc = make_archive_processor();
1639        let input = build_test_tar(&[("readme.txt", b"Contact alice@corp.com for help.")]);
1640
1641        let mut output = Vec::new();
1642        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1643
1644        assert_eq!(stats.files_processed, 1);
1645        assert_eq!(stats.scanner_fallback, 1);
1646        assert_eq!(stats.structured_hits, 0);
1647
1648        // Verify the output is a valid tar and the secret is gone.
1649        let mut archive = tar::Archive::new(&output[..]);
1650        for entry in archive.entries().unwrap() {
1651            let mut e = entry.unwrap();
1652            let mut content = String::new();
1653            e.read_to_string(&mut content).unwrap();
1654            assert!(
1655                !content.contains("alice@corp.com"),
1656                "email should be sanitized: {content}"
1657            );
1658        }
1659    }
1660
1661    #[test]
1662    fn tar_sanitizes_json_with_structured_processor() {
1663        let proc = make_archive_processor();
1664        let json_content = br#"{"email": "bob@example.org", "name": "Bob"}"#;
1665        let input = build_test_tar(&[("config.json", json_content)]);
1666
1667        let mut output = Vec::new();
1668        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1669
1670        assert_eq!(stats.files_processed, 1);
1671        assert_eq!(stats.structured_hits, 1);
1672        assert_eq!(stats.scanner_fallback, 0);
1673        assert_eq!(
1674            stats.file_methods.get("config.json").unwrap(),
1675            "structured+scan:json"
1676        );
1677
1678        // Verify sanitized output.
1679        let mut archive = tar::Archive::new(&output[..]);
1680        for entry in archive.entries().unwrap() {
1681            let mut e = entry.unwrap();
1682            let mut content = String::new();
1683            e.read_to_string(&mut content).unwrap();
1684            assert!(
1685                !content.contains("bob@example.org"),
1686                "email should be sanitized"
1687            );
1688            assert!(!content.contains("Bob"), "name should be sanitized");
1689        }
1690    }
1691
1692    /// Regression: structured entries inside archives must preserve comments,
1693    /// key order, and whitespace (byte-level), redacting only field values —
1694    /// not re-serialize the parsed tree. Uses a scanner with NO pre-loaded
1695    /// literals, so the field value is redacted purely via the per-entry
1696    /// discovery delta; the same value embedded in a comment is redacted too.
1697    #[test]
1698    fn archive_structured_entry_preserves_comments_and_formatting() {
1699        let gen = Arc::new(HmacGenerator::new([7u8; 32]));
1700        let store = Arc::new(MappingStore::new(gen, None));
1701        // Empty pattern set: redaction of the email can only come from the
1702        // structured field discovery, not from a base regex.
1703        let scanner = Arc::new(
1704            StreamScanner::new(vec![], Arc::clone(&store), ScanConfig::default()).unwrap(),
1705        );
1706        let registry = Arc::new(ProcessorRegistry::with_builtins());
1707        let profiles = vec![FileTypeProfile::new(
1708            "yaml",
1709            vec![FieldRule::new("owner_email").with_category(Category::Email)],
1710        )
1711        .with_extension(".yaml")];
1712        let proc = ArchiveProcessor::new(registry, scanner, store, profiles);
1713
1714        let yaml = b"# owner was secret.person@corp.test  (keep this comment)\nowner_email: secret.person@corp.test\nport: 8080\n";
1715        let input = build_test_tar(&[("settings.yaml", yaml)]);
1716
1717        let mut output = Vec::new();
1718        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1719        assert_eq!(stats.structured_hits, 1);
1720
1721        let mut archive = tar::Archive::new(&output[..]);
1722        let mut content = String::new();
1723        for entry in archive.entries().unwrap() {
1724            entry.unwrap().read_to_string(&mut content).unwrap();
1725        }
1726
1727        // Secret email gone from both the field and the comment.
1728        assert!(
1729            !content.contains("secret.person@corp.test"),
1730            "email must be redacted everywhere: {content}"
1731        );
1732        // Formatting preserved byte-for-byte around the redactions: the comment
1733        // line (with its trailing text) and the unrelated `port` line survive,
1734        // and the file was not re-serialized into a different shape.
1735        assert!(
1736            content.starts_with("# owner was "),
1737            "leading comment must be preserved: {content}"
1738        );
1739        assert!(
1740            content.contains("(keep this comment)"),
1741            "comment trailing text must be preserved: {content}"
1742        );
1743        assert!(
1744            content.contains("\nport: 8080\n"),
1745            "unrelated line and surrounding whitespace must be untouched: {content}"
1746        );
1747        assert!(
1748            content.contains("owner_email: "),
1749            "key and `: ` separator must be preserved: {content}"
1750        );
1751    }
1752
1753    #[test]
1754    fn tar_preserves_metadata() {
1755        let proc = make_archive_processor();
1756        let input = build_test_tar(&[("data.txt", b"SUPERSECRET token here")]);
1757
1758        let mut output = Vec::new();
1759        proc.process_tar(&input[..], &mut output).unwrap();
1760
1761        let mut archive = tar::Archive::new(&output[..]);
1762        for entry in archive.entries().unwrap() {
1763            let e = entry.unwrap();
1764            let hdr = e.header();
1765            assert_eq!(hdr.mode().unwrap(), 0o644);
1766            assert_eq!(hdr.mtime().unwrap(), 1_700_000_000);
1767        }
1768    }
1769
1770    #[test]
1771    fn tar_handles_multiple_files() {
1772        let proc = make_archive_processor();
1773        let input = build_test_tar(&[
1774            ("a.txt", b"alice@corp.com"),
1775            ("b.json", br#"{"key":"value"}"#),
1776            ("c.log", b"no secrets here"),
1777        ]);
1778
1779        let mut output = Vec::new();
1780        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1781
1782        assert_eq!(stats.files_processed, 3);
1783        // b.json matched the JSON profile
1784        assert_eq!(stats.structured_hits, 1);
1785        // a.txt and c.log fall back to scanner
1786        assert_eq!(stats.scanner_fallback, 2);
1787    }
1788
1789    #[test]
1790    fn tar_passes_through_directories() {
1791        let mut buf = Vec::new();
1792        {
1793            let mut builder = tar::Builder::new(&mut buf);
1794
1795            // Add a directory entry.
1796            let mut dir_header = tar::Header::new_gnu();
1797            dir_header.set_entry_type(tar::EntryType::Directory);
1798            dir_header.set_size(0);
1799            dir_header.set_mode(0o755);
1800            dir_header.set_cksum();
1801            builder
1802                .append_data(&mut dir_header, "mydir/", &b""[..])
1803                .unwrap();
1804
1805            // Add a file.
1806            let mut file_header = tar::Header::new_gnu();
1807            file_header.set_size(5);
1808            file_header.set_mode(0o644);
1809            file_header.set_cksum();
1810            builder
1811                .append_data(&mut file_header, "mydir/hello.txt", &b"hello"[..])
1812                .unwrap();
1813
1814            builder.finish().unwrap();
1815        }
1816
1817        let proc = make_archive_processor();
1818        let mut output = Vec::new();
1819        let stats = proc.process_tar(&buf[..], &mut output).unwrap();
1820
1821        assert_eq!(stats.entries_skipped, 1);
1822        assert_eq!(stats.files_processed, 1);
1823    }
1824
1825    // -- Tar.gz tests -------------------------------------------------------
1826
1827    #[test]
1828    fn tar_gz_round_trip() {
1829        let proc = make_archive_processor();
1830
1831        // Build a tar and gzip it.
1832        let tar_data = build_test_tar(&[("secret.txt", b"Key is SUPERSECRET okay")]);
1833        let mut gz_input = Vec::new();
1834        {
1835            let mut encoder =
1836                flate2::write::GzEncoder::new(&mut gz_input, flate2::Compression::fast());
1837            encoder.write_all(&tar_data).unwrap();
1838            encoder.finish().unwrap();
1839        }
1840
1841        let mut gz_output = Vec::new();
1842        let stats = proc.process_tar_gz(&gz_input[..], &mut gz_output).unwrap();
1843
1844        assert_eq!(stats.files_processed, 1);
1845        assert_eq!(stats.scanner_fallback, 1);
1846
1847        // Decompress and verify.
1848        let decoder = flate2::read::GzDecoder::new(&gz_output[..]);
1849        let mut archive = tar::Archive::new(decoder);
1850        for entry in archive.entries().unwrap() {
1851            let mut e = entry.unwrap();
1852            let mut content = String::new();
1853            e.read_to_string(&mut content).unwrap();
1854            assert!(
1855                !content.contains("SUPERSECRET"),
1856                "secret should be sanitized: {content}"
1857            );
1858        }
1859    }
1860
1861    // -- Zip tests ----------------------------------------------------------
1862
1863    fn build_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
1864        let mut buf = Cursor::new(Vec::new());
1865        {
1866            let mut zip = zip::ZipWriter::new(&mut buf);
1867            for (name, data) in entries {
1868                let options = zip::write::SimpleFileOptions::default()
1869                    .compression_method(zip::CompressionMethod::Deflated);
1870                zip.start_file(*name, options).unwrap();
1871                zip.write_all(data).unwrap();
1872            }
1873            zip.finish().unwrap();
1874        }
1875        buf.into_inner()
1876    }
1877
1878    #[test]
1879    fn zip_sanitizes_plaintext_with_scanner() {
1880        let proc = make_archive_processor();
1881        let zip_data = build_test_zip(&[("notes.txt", b"Reach alice@corp.com for info.")]);
1882
1883        let reader = Cursor::new(&zip_data);
1884        let mut writer = Cursor::new(Vec::new());
1885        let stats = proc.process_zip(reader, &mut writer).unwrap();
1886
1887        assert_eq!(stats.files_processed, 1);
1888        assert_eq!(stats.scanner_fallback, 1);
1889
1890        // Verify the output zip.
1891        let out_data = writer.into_inner();
1892        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
1893        let mut entry = zip_out.by_index(0).unwrap();
1894        let mut content = String::new();
1895        entry.read_to_string(&mut content).unwrap();
1896        assert!(
1897            !content.contains("alice@corp.com"),
1898            "email should be sanitized: {content}"
1899        );
1900    }
1901
1902    #[test]
1903    fn zip_sanitizes_json_with_structured_processor() {
1904        let proc = make_archive_processor();
1905        let json_content = br#"{"password": "hunter2", "host": "db.internal"}"#;
1906        let zip_data = build_test_zip(&[("settings.json", json_content)]);
1907
1908        let reader = Cursor::new(&zip_data);
1909        let mut writer = Cursor::new(Vec::new());
1910        let stats = proc.process_zip(reader, &mut writer).unwrap();
1911
1912        assert_eq!(stats.files_processed, 1);
1913        assert_eq!(stats.structured_hits, 1);
1914
1915        let out_data = writer.into_inner();
1916        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
1917        let mut entry = zip_out.by_index(0).unwrap();
1918        let mut content = String::new();
1919        entry.read_to_string(&mut content).unwrap();
1920        assert!(!content.contains("hunter2"), "password should be sanitized");
1921        assert!(!content.contains("db.internal"), "host should be sanitized");
1922    }
1923
1924    #[test]
1925    fn zip_preserves_directory_entries() {
1926        let mut buf = Cursor::new(Vec::new());
1927        {
1928            let mut zip = zip::ZipWriter::new(&mut buf);
1929
1930            let dir_options = zip::write::SimpleFileOptions::default();
1931            zip.add_directory("subdir/", dir_options).unwrap();
1932
1933            let file_options = zip::write::SimpleFileOptions::default()
1934                .compression_method(zip::CompressionMethod::Stored);
1935            zip.start_file("subdir/data.txt", file_options).unwrap();
1936            zip.write_all(b"SUPERSECRET value").unwrap();
1937
1938            zip.finish().unwrap();
1939        }
1940
1941        let zip_data = buf.into_inner();
1942        let proc = make_archive_processor();
1943        let reader = Cursor::new(&zip_data);
1944        let mut writer = Cursor::new(Vec::new());
1945        let stats = proc.process_zip(reader, &mut writer).unwrap();
1946
1947        assert_eq!(stats.entries_skipped, 1); // directory
1948        assert_eq!(stats.files_processed, 1);
1949    }
1950
1951    #[test]
1952    fn zip_handles_multiple_files() {
1953        let proc = make_archive_processor();
1954        let zip_data = build_test_zip(&[
1955            ("file1.txt", b"alice@corp.com"),
1956            ("file2.json", br#"{"secret":"SUPERSECRET"}"#),
1957            ("file3.log", b"nothing to see"),
1958        ]);
1959
1960        let reader = Cursor::new(&zip_data);
1961        let mut writer = Cursor::new(Vec::new());
1962        let stats = proc.process_zip(reader, &mut writer).unwrap();
1963
1964        assert_eq!(stats.files_processed, 3);
1965        assert_eq!(stats.structured_hits, 1); // JSON
1966        assert_eq!(stats.scanner_fallback, 2); // .txt + .log
1967    }
1968
1969    #[test]
1970    fn tar_progress_callback_receives_updates() {
1971        let updates = Arc::new(Mutex::new(Vec::new()));
1972        let proc = make_archive_processor().with_progress_callback({
1973            let updates = Arc::clone(&updates);
1974            Arc::new(move |progress| {
1975                updates
1976                    .lock()
1977                    .expect("archive progress lock")
1978                    .push(progress.clone());
1979            })
1980        });
1981        let input = build_test_tar(&[("a.txt", b"alice@corp.com"), ("b.txt", b"SUPERSECRET")]);
1982
1983        let mut output = Vec::new();
1984        let stats = proc.process_tar(&input[..], &mut output).unwrap();
1985        let updates = updates.lock().unwrap();
1986
1987        assert_eq!(updates.len(), 2);
1988        assert_eq!(updates.last().unwrap().entries_seen, 2);
1989        assert_eq!(
1990            updates.last().unwrap().files_processed,
1991            stats.files_processed
1992        );
1993        assert_eq!(updates.last().unwrap().total_entries, None);
1994    }
1995
1996    #[test]
1997    fn zip_progress_callback_reports_total_entries() {
1998        let updates = Arc::new(Mutex::new(Vec::new()));
1999        let proc = make_archive_processor().with_progress_callback({
2000            let updates = Arc::clone(&updates);
2001            Arc::new(move |progress| {
2002                updates
2003                    .lock()
2004                    .expect("archive progress lock")
2005                    .push(progress.clone());
2006            })
2007        });
2008        let zip_data = build_test_zip(&[
2009            ("file1.txt", b"alice@corp.com"),
2010            ("file2.log", b"nothing to see"),
2011        ]);
2012
2013        let reader = Cursor::new(&zip_data);
2014        let mut writer = Cursor::new(Vec::new());
2015        let stats = proc.process_zip(reader, &mut writer).unwrap();
2016        let updates = updates.lock().unwrap();
2017
2018        assert_eq!(updates.len(), 2);
2019        assert_eq!(
2020            updates.last().unwrap().files_processed,
2021            stats.files_processed
2022        );
2023        assert_eq!(updates.last().unwrap().total_entries, Some(2));
2024        assert_eq!(updates.last().unwrap().current_entry, "file2.log");
2025    }
2026
2027    // -- Format detection tests ---------------------------------------------
2028
2029    #[test]
2030    fn format_detection_from_path() {
2031        assert_eq!(
2032            ArchiveFormat::from_path("data.tar"),
2033            Some(ArchiveFormat::Tar)
2034        );
2035        assert_eq!(
2036            ArchiveFormat::from_path("data.tar.gz"),
2037            Some(ArchiveFormat::TarGz)
2038        );
2039        assert_eq!(
2040            ArchiveFormat::from_path("data.tgz"),
2041            Some(ArchiveFormat::TarGz)
2042        );
2043        assert_eq!(
2044            ArchiveFormat::from_path("data.zip"),
2045            Some(ArchiveFormat::Zip)
2046        );
2047        assert_eq!(
2048            ArchiveFormat::from_path("DATA.ZIP"),
2049            Some(ArchiveFormat::Zip)
2050        );
2051        assert_eq!(ArchiveFormat::from_path("photo.png"), None);
2052    }
2053
2054    // -- Determinism / dedup tests ------------------------------------------
2055
2056    #[test]
2057    fn same_secret_gets_same_replacement_across_entries() {
2058        let proc = make_archive_processor();
2059        let input = build_test_tar(&[
2060            ("a.txt", b"contact alice@corp.com"),
2061            ("b.txt", b"reach alice@corp.com"),
2062        ]);
2063
2064        let mut output = Vec::new();
2065        proc.process_tar(&input[..], &mut output).unwrap();
2066
2067        let mut archive = tar::Archive::new(&output[..]);
2068        let mut contents: Vec<String> = Vec::new();
2069        for entry in archive.entries().unwrap() {
2070            let mut e = entry.unwrap();
2071            let mut s = String::new();
2072            e.read_to_string(&mut s).unwrap();
2073            contents.push(s);
2074        }
2075
2076        // Both files should have the *same* replacement for alice@corp.com.
2077        // Extract the replacement by removing the prefix.
2078        let replacement_a = contents[0].strip_prefix("contact ").unwrap();
2079        let replacement_b = contents[1].strip_prefix("reach ").unwrap();
2080        assert_eq!(
2081            replacement_a, replacement_b,
2082            "dedup should produce identical replacements"
2083        );
2084        assert!(!replacement_a.contains("alice@corp.com"));
2085    }
2086
2087    // -- Auto-dispatch test -------------------------------------------------
2088
2089    #[test]
2090    fn process_auto_dispatch_tar() {
2091        let proc = make_archive_processor();
2092        let tar_data = build_test_tar(&[("f.txt", b"SUPERSECRET")]);
2093
2094        let reader = Cursor::new(tar_data);
2095        let writer = Cursor::new(Vec::new());
2096        let stats = proc.process(reader, writer, ArchiveFormat::Tar).unwrap();
2097
2098        assert_eq!(stats.files_processed, 1);
2099    }
2100
2101    #[test]
2102    fn process_auto_dispatch_zip() {
2103        let proc = make_archive_processor();
2104        let zip_data = build_test_zip(&[("f.txt", b"SUPERSECRET")]);
2105
2106        let reader = Cursor::new(zip_data);
2107        let mut writer = Cursor::new(Vec::new());
2108        let stats = proc
2109            .process(reader, &mut writer, ArchiveFormat::Zip)
2110            .unwrap();
2111
2112        assert_eq!(stats.files_processed, 1);
2113    }
2114
2115    // -- Empty archive tests ------------------------------------------------
2116
2117    #[test]
2118    fn tar_empty_archive() {
2119        let proc = make_archive_processor();
2120        let tar_data = build_test_tar(&[]);
2121
2122        let mut output = Vec::new();
2123        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2124
2125        assert_eq!(stats.files_processed, 0);
2126        assert_eq!(stats.entries_skipped, 0);
2127    }
2128
2129    #[test]
2130    fn zip_empty_archive() {
2131        let proc = make_archive_processor();
2132        let zip_data = build_test_zip(&[]);
2133
2134        let reader = Cursor::new(zip_data);
2135        let mut writer = Cursor::new(Vec::new());
2136        let stats = proc.process_zip(reader, &mut writer).unwrap();
2137
2138        assert_eq!(stats.files_processed, 0);
2139    }
2140
2141    // sanitize_zip_entry_name
2142
2143    #[test]
2144    fn zip_entry_name_clean_passthrough() {
2145        assert_eq!(sanitize_zip_entry_name("logs/app.log"), "logs/app.log");
2146        assert_eq!(sanitize_zip_entry_name("config.yaml"), "config.yaml");
2147        assert_eq!(sanitize_zip_entry_name("a/b/c.txt"), "a/b/c.txt");
2148    }
2149
2150    #[test]
2151    fn zip_entry_name_strips_leading_slash() {
2152        assert_eq!(sanitize_zip_entry_name("/etc/passwd"), "etc/passwd");
2153        assert_eq!(sanitize_zip_entry_name("///etc/passwd"), "etc/passwd");
2154    }
2155
2156    #[test]
2157    fn zip_entry_name_strips_dotdot() {
2158        assert_eq!(sanitize_zip_entry_name("../etc/passwd"), "etc/passwd");
2159        assert_eq!(
2160            sanitize_zip_entry_name("a/../../etc/passwd"),
2161            "a/etc/passwd"
2162        );
2163        assert_eq!(
2164            sanitize_zip_entry_name("../../root/.ssh/id_rsa"),
2165            "root/.ssh/id_rsa"
2166        );
2167    }
2168
2169    #[test]
2170    fn zip_entry_name_strips_leading_dot_slash() {
2171        assert_eq!(sanitize_zip_entry_name("./config.yaml"), "config.yaml");
2172        assert_eq!(sanitize_zip_entry_name("././config.yaml"), "config.yaml");
2173    }
2174
2175    #[test]
2176    fn zip_entry_name_backslash_normalised() {
2177        assert_eq!(sanitize_zip_entry_name("a\\b\\c.txt"), "a/b/c.txt");
2178        assert_eq!(sanitize_zip_entry_name("..\\etc\\passwd"), "etc/passwd");
2179    }
2180
2181    #[test]
2182    fn zip_entry_name_empty_result_replaced() {
2183        assert_eq!(sanitize_zip_entry_name("../.."), "_");
2184        assert_eq!(sanitize_zip_entry_name(""), "_");
2185        assert_eq!(sanitize_zip_entry_name("/"), "_");
2186    }
2187
2188    #[test]
2189    fn zip_entry_name_absolute_dotdot_combo() {
2190        assert_eq!(sanitize_zip_entry_name("/../etc/passwd"), "etc/passwd");
2191    }
2192
2193    // -- ArchiveFilter tests ------------------------------------------------
2194
2195    #[test]
2196    fn filter_empty_passes_everything() {
2197        let f = ArchiveFilter::new(vec![], vec![]).unwrap();
2198        assert!(f.is_empty());
2199        assert!(f.passes("config/app.yaml"));
2200        assert!(f.passes("logs/server.log"));
2201    }
2202
2203    #[test]
2204    fn filter_only_glob_includes_match() {
2205        let f = ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap();
2206        assert!(!f.is_empty());
2207        assert!(f.passes("config/settings.json"));
2208        assert!(f.passes("deep/nested/file.json"));
2209        assert!(!f.passes("config/settings.yaml"));
2210    }
2211
2212    #[test]
2213    fn filter_only_dir_prefix_includes_subtree() {
2214        let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2215        assert!(f.passes("config/app.yaml"));
2216        assert!(f.passes("config/nested/db.yaml"));
2217        assert!(!f.passes("logs/server.log"));
2218    }
2219
2220    #[test]
2221    fn filter_dir_prefix_exact_match() {
2222        let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2223        // Exact prefix without trailing separator should also match.
2224        assert!(f.passes("config"));
2225    }
2226
2227    #[test]
2228    fn filter_exclude_removes_match() {
2229        let f = ArchiveFilter::new(vec![], vec!["**/*.log".into()]).unwrap();
2230        assert!(!f.passes("logs/server.log"));
2231        assert!(f.passes("config/app.yaml"));
2232    }
2233
2234    #[test]
2235    fn filter_only_and_exclude_combined() {
2236        let f =
2237            ArchiveFilter::new(vec!["config/".into()], vec!["config/secrets.yaml".into()]).unwrap();
2238        assert!(f.passes("config/app.yaml"));
2239        assert!(!f.passes("config/secrets.yaml"));
2240        assert!(!f.passes("logs/server.log"));
2241    }
2242
2243    #[test]
2244    fn filter_invalid_glob_returns_error() {
2245        assert!(ArchiveFilter::new(vec!["[invalid".into()], vec![]).is_err());
2246        assert!(ArchiveFilter::new(vec![], vec!["[bad".into()]).is_err());
2247    }
2248
2249    // -- ArchiveProcessor builder methods -----------------------------------
2250
2251    #[test]
2252    fn builder_with_max_depth_clamps_at_max() {
2253        let proc = make_archive_processor().with_max_depth(999);
2254        assert_eq!(proc.max_depth, MAX_ARCHIVE_DEPTH);
2255    }
2256
2257    #[test]
2258    fn builder_with_max_depth_sets_value() {
2259        let proc = make_archive_processor().with_max_depth(2);
2260        assert_eq!(proc.max_depth, 2);
2261    }
2262
2263    #[test]
2264    fn builder_with_parallel_threshold_sets_value() {
2265        let proc = make_archive_processor().with_parallel_threshold(usize::MAX);
2266        assert_eq!(proc.parallel_threshold, usize::MAX);
2267    }
2268
2269    #[test]
2270    fn builder_with_force_text_enables_flag() {
2271        let proc = make_archive_processor().with_force_text(true);
2272        assert!(proc.force_text);
2273    }
2274
2275    #[test]
2276    fn builder_with_filter_applied_to_zip() {
2277        let proc = make_archive_processor()
2278            .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2279
2280        let zip_data = build_test_zip(&[
2281            ("config.json", br#"{"email":"alice@corp.com"}"#),
2282            ("notes.txt", b"alice@corp.com"),
2283        ]);
2284
2285        let reader = Cursor::new(zip_data);
2286        let mut writer = Cursor::new(Vec::new());
2287        let stats = proc.process_zip(reader, &mut writer).unwrap();
2288
2289        // notes.txt is excluded by the filter — only config.json processed.
2290        assert_eq!(stats.files_processed, 1);
2291        assert_eq!(stats.entries_filtered, 1);
2292    }
2293
2294    #[test]
2295    fn builder_with_filter_applied_to_tar() {
2296        let proc = make_archive_processor()
2297            .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2298
2299        let tar_data = build_test_tar(&[
2300            ("config.json", br#"{"email":"alice@corp.com"}"#),
2301            ("notes.txt", b"alice@corp.com"),
2302        ]);
2303
2304        let mut output = Vec::new();
2305        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2306
2307        assert_eq!(stats.files_processed, 1);
2308        assert_eq!(stats.entries_filtered, 1);
2309    }
2310
2311    // -- Parallel path tests ------------------------------------------------
2312
2313    #[test]
2314    fn parallel_tar_sanitizes_all_entries() {
2315        // parallel_threshold(0) forces parallel execution regardless of entry count.
2316        let proc = make_archive_processor().with_parallel_threshold(0);
2317        let tar_data = build_test_tar(&[
2318            ("a.txt", b"alice@corp.com"),
2319            ("b.txt", b"bob@corp.com"),
2320            ("c.txt", b"carol@corp.com"),
2321            ("d.txt", b"dave@corp.com"),
2322        ]);
2323
2324        let mut output = Vec::new();
2325        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2326
2327        assert_eq!(stats.files_processed, 4);
2328
2329        // Verify originals are gone (domain is preserved by email strategy, full addresses must not appear).
2330        let originals = [
2331            "alice@corp.com",
2332            "bob@corp.com",
2333            "carol@corp.com",
2334            "dave@corp.com",
2335        ];
2336        let mut archive = tar::Archive::new(&output[..]);
2337        for entry in archive.entries().unwrap() {
2338            let mut e = entry.unwrap();
2339            let mut content = String::new();
2340            e.read_to_string(&mut content).unwrap();
2341            for orig in &originals {
2342                assert!(
2343                    !content.contains(orig),
2344                    "original secret leaked in {:?}",
2345                    e.path()
2346                );
2347            }
2348        }
2349    }
2350
2351    #[test]
2352    fn parallel_tar_preserves_entry_order() {
2353        let proc = make_archive_processor().with_parallel_threshold(0);
2354        let tar_data = build_test_tar(&[
2355            ("first.txt", b"alice@corp.com"),
2356            ("second.txt", b"hello"),
2357            ("third.txt", b"bob@corp.com"),
2358        ]);
2359
2360        let mut output = Vec::new();
2361        proc.process_tar(&tar_data[..], &mut output).unwrap();
2362
2363        let mut archive = tar::Archive::new(&output[..]);
2364        let names: Vec<String> = archive
2365            .entries()
2366            .unwrap()
2367            .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
2368            .collect();
2369
2370        assert_eq!(names, vec!["first.txt", "second.txt", "third.txt"]);
2371    }
2372
2373    #[test]
2374    fn parallel_zip_sanitizes_all_entries() {
2375        let proc = make_archive_processor().with_parallel_threshold(0);
2376        let zip_data = build_test_zip(&[
2377            ("a.txt", b"alice@corp.com"),
2378            ("b.txt", b"bob@corp.com"),
2379            ("c.txt", b"carol@corp.com"),
2380            ("d.txt", b"dave@corp.com"),
2381        ]);
2382
2383        let reader = Cursor::new(zip_data);
2384        let mut writer = Cursor::new(Vec::new());
2385        let stats = proc.process_zip(reader, &mut writer).unwrap();
2386
2387        assert_eq!(stats.files_processed, 4);
2388
2389        let originals = [
2390            "alice@corp.com",
2391            "bob@corp.com",
2392            "carol@corp.com",
2393            "dave@corp.com",
2394        ];
2395        let out_data = writer.into_inner();
2396        let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
2397        for i in 0..zip_out.len() {
2398            let mut entry = zip_out.by_index(i).unwrap();
2399            let mut content = String::new();
2400            entry.read_to_string(&mut content).unwrap();
2401            for orig in &originals {
2402                assert!(
2403                    !content.contains(orig),
2404                    "original secret leaked in entry {i}"
2405                );
2406            }
2407        }
2408    }
2409
2410    #[test]
2411    fn parallel_tar_mixed_structured_and_scanner() {
2412        let proc = make_archive_processor().with_parallel_threshold(0);
2413        let tar_data = build_test_tar(&[
2414            ("config.json", br#"{"email":"alice@corp.com","port":5432}"#),
2415            ("notes.txt", b"contact bob@corp.com for help"),
2416            ("data.json", br#"{"email":"carol@corp.com"}"#),
2417            ("readme.txt", b"dave@corp.com is the owner"),
2418        ]);
2419
2420        let mut output = Vec::new();
2421        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2422
2423        assert_eq!(stats.files_processed, 4);
2424        assert_eq!(stats.structured_hits, 2); // two JSON files
2425        assert_eq!(stats.scanner_fallback, 2); // two plain text files
2426
2427        let originals = [
2428            "alice@corp.com",
2429            "bob@corp.com",
2430            "carol@corp.com",
2431            "dave@corp.com",
2432        ];
2433        let mut archive = tar::Archive::new(&output[..]);
2434        for entry in archive.entries().unwrap() {
2435            let mut e = entry.unwrap();
2436            let mut content = String::new();
2437            e.read_to_string(&mut content).unwrap();
2438            for orig in &originals {
2439                assert!(!content.contains(orig), "original secret leaked");
2440            }
2441        }
2442    }
2443
2444    // -- Nested archive tests -----------------------------------------------
2445
2446    #[test]
2447    fn tar_in_tar_secrets_sanitized() {
2448        // Build inner tar with a secret.
2449        let inner_tar = build_test_tar(&[("inner.txt", b"alice@corp.com")]);
2450
2451        // Embed the inner tar as an entry in the outer tar.
2452        let outer_tar = build_test_tar(&[("nested.tar", &inner_tar)]);
2453
2454        let proc = make_archive_processor();
2455        let mut output = Vec::new();
2456        let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
2457
2458        assert_eq!(stats.nested_archives, 1);
2459
2460        // Unpack the outer tar and read the inner tar's content.
2461        let mut outer = tar::Archive::new(&output[..]);
2462        for entry in outer.entries().unwrap() {
2463            let mut e = entry.unwrap();
2464            let mut inner_bytes = Vec::new();
2465            e.read_to_end(&mut inner_bytes).unwrap();
2466            let mut inner = tar::Archive::new(&inner_bytes[..]);
2467            for inner_entry in inner.entries().unwrap() {
2468                let mut ie = inner_entry.unwrap();
2469                let mut content = String::new();
2470                ie.read_to_string(&mut content).unwrap();
2471                assert!(
2472                    !content.contains("alice@corp.com"),
2473                    "secret survived nested tar"
2474                );
2475            }
2476        }
2477    }
2478
2479    #[test]
2480    fn zip_in_tar_secrets_sanitized() {
2481        let inner_zip = build_test_zip(&[("inner.txt", b"SUPERSECRET")]);
2482        let outer_tar = build_test_tar(&[("nested.zip", &inner_zip)]);
2483
2484        let proc = make_archive_processor();
2485        let mut output = Vec::new();
2486        let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
2487
2488        assert_eq!(stats.nested_archives, 1);
2489
2490        let mut outer = tar::Archive::new(&output[..]);
2491        for entry in outer.entries().unwrap() {
2492            let mut e = entry.unwrap();
2493            let mut zip_bytes = Vec::new();
2494            e.read_to_end(&mut zip_bytes).unwrap();
2495            let mut zip_out = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
2496            for i in 0..zip_out.len() {
2497                let mut ze = zip_out.by_index(i).unwrap();
2498                let mut content = String::new();
2499                ze.read_to_string(&mut content).unwrap();
2500                assert!(
2501                    !content.contains("SUPERSECRET"),
2502                    "secret survived zip-in-tar"
2503                );
2504            }
2505        }
2506    }
2507
2508    #[test]
2509    fn zip_in_zip_secrets_sanitized() {
2510        let inner_zip = build_test_zip(&[("secret.txt", b"alice@corp.com")]);
2511        let outer_zip = build_test_zip(&[("nested.zip", &inner_zip)]);
2512
2513        let proc = make_archive_processor();
2514        let reader = Cursor::new(outer_zip);
2515        let mut writer = Cursor::new(Vec::new());
2516        let stats = proc.process_zip(reader, &mut writer).unwrap();
2517
2518        assert_eq!(stats.nested_archives, 1);
2519
2520        let out_bytes = writer.into_inner();
2521        let mut outer = zip::ZipArchive::new(Cursor::new(out_bytes)).unwrap();
2522        let mut inner_bytes = Vec::new();
2523        outer
2524            .by_index(0)
2525            .unwrap()
2526            .read_to_end(&mut inner_bytes)
2527            .unwrap();
2528        let mut inner = zip::ZipArchive::new(Cursor::new(inner_bytes)).unwrap();
2529        let mut content = String::new();
2530        inner
2531            .by_index(0)
2532            .unwrap()
2533            .read_to_string(&mut content)
2534            .unwrap();
2535        assert!(
2536            !content.contains("alice@corp.com"),
2537            "secret survived zip-in-zip"
2538        );
2539    }
2540
2541    #[test]
2542    fn nested_archive_depth_limit_returns_error() {
2543        // Build an archive nested max_depth + 1 levels deep.
2544        // Default max_depth is DEFAULT_ARCHIVE_DEPTH (5); use a proc with depth=1.
2545        let proc = make_archive_processor().with_max_depth(1);
2546
2547        let innermost = build_test_tar(&[("file.txt", b"secret")]);
2548        let middle = build_test_tar(&[("inner.tar", &innermost)]);
2549        let outer = build_test_tar(&[("middle.tar", &middle)]);
2550
2551        let mut output = Vec::new();
2552        let err = proc.process_tar(&outer[..], &mut output).unwrap_err();
2553        assert!(matches!(err, SanitizeError::RecursionDepthExceeded(_)));
2554    }
2555
2556    #[test]
2557    fn force_text_skips_structured_processor() {
2558        let proc = make_archive_processor().with_force_text(true);
2559        let tar_data = build_test_tar(&[("config.json", br#"{"email":"alice@corp.com"}"#)]);
2560
2561        let mut output = Vec::new();
2562        let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2563
2564        // With force_text, JSON is scanned as plain text — no structured hit.
2565        assert_eq!(stats.scanner_fallback, 1);
2566        assert_eq!(stats.structured_hits, 0);
2567    }
2568}