1use 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
59fn 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 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
111fn read_bounded(reader: &mut dyn Read, limit: u64, label: &str) -> Result<Vec<u8>> {
116 let mut content = Vec::new();
117 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
130type ParEntryResult = (usize, Result<(Vec<u8>, ArchiveStats)>);
136
137pub type EntryCallback = Arc<dyn Fn(&str, &[u8]) + Send + Sync>;
142
143#[derive(Default, Clone)]
170pub struct ArchiveFilter {
171 only: Vec<CompiledPattern>,
172 exclude: Vec<CompiledPattern>,
173}
174
175#[derive(Clone)]
176enum CompiledPattern {
177 DirPrefix(String),
180 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 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 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 pub fn is_empty(&self) -> bool {
234 self.only.is_empty() && self.exclude.is_empty()
235 }
236
237 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum ArchiveFormat {
256 Zip,
258 Tar,
260 TarGz,
262}
263
264impl ArchiveFormat {
265 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#[derive(Debug, Clone, Default)]
298pub struct ArchiveStats {
299 pub files_processed: u64,
301 pub entries_skipped: u64,
303 pub structured_hits: u64,
305 pub scanner_fallback: u64,
307 pub nested_archives: u64,
310 pub total_input_bytes: u64,
312 pub total_output_bytes: u64,
314 pub file_methods: HashMap<String, String>,
317 pub file_scan_stats: HashMap<String, ScanStats>,
319 pub entries_filtered: u64,
321}
322
323#[derive(Debug, Clone, Eq, PartialEq)]
325pub struct ArchiveProgress {
326 pub entries_seen: u64,
328 pub files_processed: u64,
330 pub entries_skipped: u64,
332 pub total_entries: Option<u64>,
334 pub current_entry: String,
336}
337
338type ArchiveProgressCallback = Arc<dyn Fn(&ArchiveProgress) + Send + Sync>;
339
340impl ArchiveStats {
341 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
366pub struct ArchiveProcessor {
397 registry: Arc<ProcessorRegistry>,
399 scanner: Arc<StreamScanner>,
401 store: Arc<MappingStore>,
403 profiles: Vec<FileTypeProfile>,
405 max_depth: u32,
407 progress_callback: Option<ArchiveProgressCallback>,
409 parallel_threshold: usize,
412 filter: ArchiveFilter,
415 force_text: bool,
419 entry_callback: Option<EntryCallback>,
422 structured_scanner: OnceLock<Arc<StreamScanner>>,
426}
427
428impl ArchiveProcessor {
429 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 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 let _ = self.structured_scanner.set(built);
470 Ok(self
471 .structured_scanner
472 .get()
473 .expect("structured scanner was just set"))
474 }
475
476 #[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 #[must_use]
492 pub fn with_parallel_threshold(mut self, threshold: usize) -> Self {
493 self.parallel_threshold = threshold;
494 self
495 }
496
497 #[must_use]
499 pub fn with_progress_callback(mut self, callback: ArchiveProgressCallback) -> Self {
500 self.progress_callback = Some(callback);
501 self
502 }
503
504 #[must_use]
510 pub fn with_filter(mut self, filter: ArchiveFilter) -> Self {
511 self.filter = filter;
512 self
513 }
514
515 #[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 #[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 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 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 #[allow(clippy::missing_errors_doc)] 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 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 let within_size_cap = entry_size_hint.is_none_or(|sz| sz <= STRUCTURED_ENTRY_SIZE); if !self.force_text && within_size_cap {
626 if let Some(profile) = self.find_profile(filename) {
627 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 let pre_snapshot = self.store.snapshot();
640 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 let extra: Vec<ScanPattern> = self
667 .store
668 .iter_since(pre_snapshot)
669 .filter_map(|(category, original, _)| {
670 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 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 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 #[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 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 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 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 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 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 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 #[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 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 let use_parallel = !overflowed
1015 && file_count >= self.parallel_threshold
1016 && rayon::current_thread_index().is_none();
1017
1018 if use_parallel {
1019 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 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 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 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 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 Ok(stats)
1238 }
1239
1240 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 #[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 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 }
1321
1322 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 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 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 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 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 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 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 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 };
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 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
1515struct 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
1543struct 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#[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 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 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 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 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 #[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 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 assert!(
1729 !content.contains("secret.person@corp.test"),
1730 "email must be redacted everywhere: {content}"
1731 );
1732 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 assert_eq!(stats.structured_hits, 1);
1785 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 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 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 #[test]
1828 fn tar_gz_round_trip() {
1829 let proc = make_archive_processor();
1830
1831 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 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 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 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); 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); assert_eq!(stats.scanner_fallback, 2); }
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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[test]
2314 fn parallel_tar_sanitizes_all_entries() {
2315 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 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); assert_eq!(stats.scanner_fallback, 2); 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 #[test]
2447 fn tar_in_tar_secrets_sanitized() {
2448 let inner_tar = build_test_tar(&[("inner.txt", b"alice@corp.com")]);
2450
2451 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 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 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 assert_eq!(stats.scanner_fallback, 1);
2566 assert_eq!(stats.structured_hits, 0);
2567 }
2568}