1use crate::error::{Result, SanitizeError};
54use crate::processor::profile::FileTypeProfile;
55use crate::processor::registry::ProcessorRegistry;
56use crate::scanner::{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;
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}
423
424impl ArchiveProcessor {
425 pub fn new(
434 registry: Arc<ProcessorRegistry>,
435 scanner: Arc<StreamScanner>,
436 store: Arc<MappingStore>,
437 profiles: Vec<FileTypeProfile>,
438 ) -> Self {
439 Self {
440 registry,
441 scanner,
442 store,
443 profiles,
444 max_depth: DEFAULT_ARCHIVE_DEPTH,
445 progress_callback: None,
446 parallel_threshold: PARALLEL_ENTRY_THRESHOLD,
447 filter: ArchiveFilter::default(),
448 force_text: false,
449 entry_callback: None,
450 }
451 }
452
453 #[must_use]
459 pub fn with_max_depth(mut self, depth: u32) -> Self {
460 self.max_depth = depth.min(MAX_ARCHIVE_DEPTH);
461 self
462 }
463
464 #[must_use]
469 pub fn with_parallel_threshold(mut self, threshold: usize) -> Self {
470 self.parallel_threshold = threshold;
471 self
472 }
473
474 #[must_use]
476 pub fn with_progress_callback(mut self, callback: ArchiveProgressCallback) -> Self {
477 self.progress_callback = Some(callback);
478 self
479 }
480
481 #[must_use]
487 pub fn with_filter(mut self, filter: ArchiveFilter) -> Self {
488 self.filter = filter;
489 self
490 }
491
492 #[must_use]
499 pub fn with_force_text(mut self, force_text: bool) -> Self {
500 self.force_text = force_text;
501 self
502 }
503
504 #[must_use]
507 pub fn with_entry_callback(mut self, callback: EntryCallback) -> Self {
508 self.entry_callback = Some(callback);
509 self
510 }
511
512 fn emit_entry_bytes(&self, name: &str, bytes: &[u8]) {
513 if let Some(cb) = &self.entry_callback {
514 cb(name, bytes);
515 }
516 }
517
518 fn find_profile(&self, filename: &str) -> Option<&FileTypeProfile> {
520 self.profiles.iter().find(|p| p.matches_filename(filename))
521 }
522
523 fn emit_progress(&self, stats: &ArchiveStats, total_entries: Option<u64>, current_entry: &str) {
524 if let Some(callback) = &self.progress_callback {
525 callback(&ArchiveProgress {
526 entries_seen: stats.files_processed + stats.entries_skipped,
527 files_processed: stats.files_processed,
528 entries_skipped: stats.entries_skipped,
529 total_entries,
530 current_entry: current_entry.to_string(),
531 });
532 }
533 }
534
535 fn sanitize_entry_bytes(
542 &self,
543 filename: &str,
544 data: &[u8],
545 entry_size_hint: Option<u64>,
546 depth: u32,
547 ) -> Result<(Vec<u8>, ArchiveStats)> {
548 let mut out: Vec<u8> = Vec::with_capacity(data.len());
549 let mut entry_stats = ArchiveStats::default();
550 let mut reader = io::Cursor::new(data);
551 self.sanitize_entry(
552 filename,
553 &mut reader,
554 &mut out,
555 &mut entry_stats,
556 entry_size_hint,
557 depth,
558 )?;
559 Ok((out, entry_stats))
560 }
561
562 #[allow(clippy::missing_errors_doc)] fn sanitize_entry(
574 &self,
575 filename: &str,
576 reader: &mut dyn Read,
577 writer: &mut dyn Write,
578 stats: &mut ArchiveStats,
579 entry_size_hint: Option<u64>,
580 depth: u32,
581 ) -> Result<()> {
582 if let Some(nested_fmt) = ArchiveFormat::from_path(filename) {
584 return self.sanitize_nested_archive(
585 filename,
586 reader,
587 writer,
588 stats,
589 entry_size_hint,
590 nested_fmt,
591 depth,
592 );
593 }
594
595 let within_size_cap = entry_size_hint.is_none_or(|sz| sz <= STRUCTURED_ENTRY_SIZE); if !self.force_text && within_size_cap {
603 if let Some(profile) = self.find_profile(filename) {
604 let mut content = Vec::new();
606 reader.read_to_end(&mut content).map_err(|e| {
607 SanitizeError::ArchiveError(format!("read entry '{filename}': {e}"))
608 })?;
609
610 stats.total_input_bytes += content.len() as u64;
611
612 if let Ok(Some(structured_out)) =
617 self.registry.process(&content, profile, &self.store)
618 {
619 let (output, scan_stats) = self.scanner.scan_bytes(&structured_out)?;
622 stats.structured_hits += 1;
623 stats.total_output_bytes += output.len() as u64;
624 stats.file_methods.insert(
625 filename.to_string(),
626 format!("structured+scan:{}", profile.processor),
627 );
628 stats
629 .file_scan_stats
630 .insert(filename.to_string(), scan_stats);
631 writer.write_all(&output).map_err(|e| {
632 SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
633 })?;
634 return Ok(());
635 }
636
637 let (output, scan_stats) = self.scanner.scan_bytes(&content)?;
640 stats.scanner_fallback += 1;
641 stats.total_output_bytes += output.len() as u64;
642 stats
643 .file_methods
644 .insert(filename.to_string(), "scanner".to_string());
645 stats
646 .file_scan_stats
647 .insert(filename.to_string(), scan_stats);
648 writer.write_all(&output).map_err(|e| {
649 SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
650 })?;
651 return Ok(());
652 }
653 }
654
655 let mut counting_r = CountingReader::new(reader);
660 let mut counting_w = CountingWriter::new(writer);
661 let scan_stats = self.scanner.scan_reader(&mut counting_r, &mut counting_w)?;
662
663 stats.scanner_fallback += 1;
664 stats.total_input_bytes += counting_r.bytes_read();
665 stats.total_output_bytes += counting_w.bytes_written();
666 stats
667 .file_methods
668 .insert(filename.to_string(), "scanner".to_string());
669 stats
670 .file_scan_stats
671 .insert(filename.to_string(), scan_stats);
672
673 Ok(())
674 }
675
676 #[allow(clippy::too_many_arguments)]
679 fn sanitize_nested_archive(
680 &self,
681 filename: &str,
682 reader: &mut dyn Read,
683 writer: &mut dyn Write,
684 stats: &mut ArchiveStats,
685 entry_size_hint: Option<u64>,
686 nested_fmt: ArchiveFormat,
687 depth: u32,
688 ) -> Result<()> {
689 if depth >= self.max_depth {
690 return Err(SanitizeError::RecursionDepthExceeded(format!(
691 "nested archive '{}' at depth {} exceeds maximum nesting depth of {}",
692 filename, depth, self.max_depth,
693 )));
694 }
695
696 if let Some(sz) = entry_size_hint {
701 if sz > STRUCTURED_ENTRY_SIZE {
702 return Err(SanitizeError::ArchiveError(format!(
703 "nested archive '{}' is too large ({} bytes, limit {} bytes)",
704 filename, sz, STRUCTURED_ENTRY_SIZE,
705 )));
706 }
707 }
708
709 let content = read_bounded(reader, STRUCTURED_ENTRY_SIZE, filename)?;
710 stats.total_input_bytes += content.len() as u64;
711
712 let mut output_buf: Vec<u8> = Vec::new();
714 let child_stats = match nested_fmt {
715 ArchiveFormat::Tar => {
716 self.process_tar_at_depth(&content[..], &mut output_buf, depth + 1)?
717 }
718 ArchiveFormat::TarGz => {
719 self.process_tar_gz_at_depth(&content[..], &mut output_buf, depth + 1)?
720 }
721 ArchiveFormat::Zip => {
722 let reader = io::Cursor::new(&content);
723 let mut writer = io::Cursor::new(Vec::new());
724 let s = self.process_zip_at_depth(reader, &mut writer, depth + 1)?;
725 output_buf = writer.into_inner();
726 s
727 }
728 };
729
730 stats.nested_archives += 1;
731 stats.merge(&child_stats);
732 stats.total_output_bytes += output_buf.len() as u64;
733 let fmt_name = match nested_fmt {
734 ArchiveFormat::Tar => "tar",
735 ArchiveFormat::TarGz => "tar.gz",
736 ArchiveFormat::Zip => "zip",
737 };
738 stats
739 .file_methods
740 .insert(filename.to_string(), format!("nested:{fmt_name}"));
741 writer.write_all(&output_buf).map_err(|e| {
742 SanitizeError::ArchiveError(format!("write nested archive '{filename}': {e}"))
743 })?;
744 Ok(())
745 }
746
747 pub fn discover_profiles_tar<R: Read>(&self, reader: R) -> Result<()> {
766 if self.profiles.is_empty() {
767 return Ok(());
768 }
769 let mut archive = tar::Archive::new(reader);
770 let entries = archive
771 .entries()
772 .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entries: {e}")))?;
773 for entry_result in entries {
774 let mut entry = entry_result
775 .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entry: {e}")))?;
776 if !entry.header().entry_type().is_file() {
777 continue;
778 }
779 let path = entry
780 .path()
781 .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
782 .to_string_lossy()
783 .to_string();
784 let Some(profile) = self.find_profile(&path) else {
785 continue;
786 };
787 let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &path) {
788 Ok(c) => c,
789 Err(e) => {
790 tracing::warn!(path = %path, error = %e, "discovery: skipping oversized entry");
791 continue;
792 }
793 };
794 if let Err(e) = self.registry.process(&content, profile, &self.store) {
795 tracing::warn!(path = %path, error = %e, "discovery: structured processor failed; partial mappings may persist");
796 }
797 }
798 Ok(())
799 }
800
801 pub fn discover_profiles_tar_gz<R: Read>(&self, reader: R) -> Result<()> {
809 let gz = flate2::read::GzDecoder::new(reader);
810 self.discover_profiles_tar(gz)
811 }
812
813 pub fn discover_profiles_zip<R: Read + Seek>(&self, reader: R) -> Result<()> {
821 if self.profiles.is_empty() {
822 return Ok(());
823 }
824 let mut zip = zip::ZipArchive::new(reader)
825 .map_err(|e| SanitizeError::ArchiveError(format!("open zip for discovery: {e}")))?;
826 for i in 0..zip.len() {
827 let mut entry = zip
828 .by_index(i)
829 .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {i}: {e}")))?;
830 if entry.is_dir() {
831 continue;
832 }
833 let name = sanitize_zip_entry_name(entry.name());
834 let Some(profile) = self.find_profile(&name) else {
835 continue;
836 };
837 let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &name) {
838 Ok(c) => c,
839 Err(e) => {
840 tracing::warn!(name = %name, error = %e, "discovery: skipping oversized entry");
841 continue;
842 }
843 };
844 if let Err(e) = self.registry.process(&content, profile, &self.store) {
845 tracing::warn!(name = %name, error = %e, "discovery: structured processor failed; partial mappings may persist");
846 }
847 }
848 Ok(())
849 }
850
851 pub fn process_tar<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
865 self.process_tar_at_depth(reader, writer, 0)
866 }
867
868 #[allow(clippy::too_many_lines)]
886 fn process_tar_at_depth<R: Read, W: Write>(
887 &self,
888 reader: R,
889 writer: W,
890 depth: u32,
891 ) -> Result<ArchiveStats> {
892 struct TarEntry {
893 header: tar::Header,
894 path: String,
895 is_file: bool,
896 passes_filter: bool,
897 data: Vec<u8>,
898 }
899
900 let mut archive = tar::Archive::new(reader);
901 let mut builder = tar::Builder::new(writer);
902 let mut stats = ArchiveStats::default();
903
904 let mut entries_iter = archive
908 .entries()
909 .map_err(|e| SanitizeError::ArchiveError(format!("read tar entries: {e}")))?;
910
911 let mut buffered: Vec<TarEntry> = Vec::new();
912 let mut file_count: usize = 0;
913 let mut total_data: u64 = 0;
914 let mut overflowed = false;
915
916 for entry_result in entries_iter.by_ref() {
917 let mut entry = entry_result
918 .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
919
920 let header = entry.header().clone();
921 let path = entry
922 .path()
923 .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
924 .to_string_lossy()
925 .into_owned();
926 let is_file = header.entry_type().is_file();
927 let passes_filter = !is_file || self.filter.passes(&path);
928
929 let mut data = Vec::new();
930 entry
931 .read_to_end(&mut data)
932 .map_err(|e| SanitizeError::ArchiveError(format!("read entry '{path}': {e}")))?;
933 drop(entry);
934
935 if is_file && passes_filter {
936 file_count += 1;
937 total_data = total_data.saturating_add(data.len() as u64);
938 }
939
940 buffered.push(TarEntry {
941 header,
942 path,
943 is_file,
944 passes_filter,
945 data,
946 });
947
948 if total_data > PARALLEL_TAR_DATA_SIZE {
949 overflowed = true;
950 break;
951 }
952 }
953
954 let use_parallel = !overflowed
956 && file_count >= self.parallel_threshold
957 && rayon::current_thread_index().is_none();
958
959 if use_parallel {
960 let file_indices: Vec<usize> = buffered
963 .iter()
964 .enumerate()
965 .filter(|(_, e)| e.is_file && e.passes_filter)
966 .map(|(i, _)| i)
967 .collect();
968
969 let results: Vec<ParEntryResult> = file_indices
970 .into_par_iter()
971 .map(|i| {
972 let e = &buffered[i];
973 let size_hint = e.header.size().ok();
974 (
975 i,
976 self.sanitize_entry_bytes(&e.path, &e.data, size_hint, depth),
977 )
978 })
979 .collect();
980
981 let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; buffered.len()];
982 for (i, r) in results {
983 sanitized[i] = Some(r?);
984 }
985
986 for (i, entry) in buffered.iter().enumerate() {
987 if !entry.is_file {
988 builder
989 .append(&entry.header, entry.data.as_slice())
990 .map_err(|e| {
991 SanitizeError::ArchiveError(format!("append '{}': {e}", entry.path))
992 })?;
993 stats.entries_skipped += 1;
994 self.emit_progress(&stats, None, &entry.path);
995 continue;
996 }
997 if !entry.passes_filter {
998 stats.entries_filtered += 1;
999 self.emit_progress(&stats, None, &entry.path);
1000 continue;
1001 }
1002
1003 let (sanitized_buf, entry_stats) =
1004 sanitized[i].take().expect("parallel result missing");
1005 stats.merge(&entry_stats);
1006 self.emit_entry_bytes(&entry.path, &sanitized_buf);
1007
1008 let mut new_header = entry.header.clone();
1009 let safe_path = sanitize_tar_entry_name(&entry.path);
1010 new_header.set_path(&safe_path).map_err(|e| {
1011 SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1012 })?;
1013 new_header.set_size(sanitized_buf.len() as u64);
1014 new_header.set_cksum();
1015 builder
1016 .append(&new_header, sanitized_buf.as_slice())
1017 .map_err(|e| {
1018 SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1019 })?;
1020 stats.files_processed += 1;
1021 self.emit_progress(&stats, None, &entry.path);
1022 }
1023 } else {
1024 let write_buffered = |entry: &TarEntry,
1029 builder: &mut tar::Builder<W>,
1030 stats: &mut ArchiveStats,
1031 processor: &ArchiveProcessor|
1032 -> Result<()> {
1033 if !entry.is_file {
1034 builder
1035 .append(&entry.header, entry.data.as_slice())
1036 .map_err(|e| {
1037 SanitizeError::ArchiveError(format!("append '{}': {e}", entry.path))
1038 })?;
1039 stats.entries_skipped += 1;
1040 processor.emit_progress(stats, None, &entry.path);
1041 return Ok(());
1042 }
1043 if !entry.passes_filter {
1044 stats.entries_filtered += 1;
1045 processor.emit_progress(stats, None, &entry.path);
1046 return Ok(());
1047 }
1048 let size_hint = entry.header.size().ok();
1049 let (sanitized_buf, entry_stats) =
1050 processor.sanitize_entry_bytes(&entry.path, &entry.data, size_hint, depth)?;
1051 stats.merge(&entry_stats);
1052 processor.emit_entry_bytes(&entry.path, &sanitized_buf);
1053 let mut new_header = entry.header.clone();
1054 let safe_path = sanitize_tar_entry_name(&entry.path);
1055 new_header.set_path(&safe_path).map_err(|e| {
1056 SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1057 })?;
1058 new_header.set_size(sanitized_buf.len() as u64);
1059 new_header.set_cksum();
1060 builder
1061 .append(&new_header, sanitized_buf.as_slice())
1062 .map_err(|e| {
1063 SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1064 })?;
1065 stats.files_processed += 1;
1066 processor.emit_progress(stats, None, &entry.path);
1067 Ok(())
1068 };
1069
1070 for entry in &buffered {
1071 write_buffered(entry, &mut builder, &mut stats, self)?;
1072 }
1073 drop(buffered);
1074
1075 if overflowed {
1077 for entry_result in entries_iter {
1078 let mut entry = entry_result
1079 .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
1080
1081 let header = entry.header().clone();
1082 let path = entry
1083 .path()
1084 .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1085 .to_string_lossy()
1086 .into_owned();
1087 let is_file = header.entry_type().is_file();
1088
1089 if !is_file {
1090 let mut data = Vec::new();
1091 entry.read_to_end(&mut data).map_err(|e| {
1092 SanitizeError::ArchiveError(format!("read '{path}': {e}"))
1093 })?;
1094 drop(entry);
1095 builder.append(&header, data.as_slice()).map_err(|e| {
1096 SanitizeError::ArchiveError(format!("append '{path}': {e}"))
1097 })?;
1098 stats.entries_skipped += 1;
1099 self.emit_progress(&stats, None, &path);
1100 continue;
1101 }
1102
1103 if !self.filter.passes(&path) {
1104 stats.entries_filtered += 1;
1105 continue;
1106 }
1107
1108 let size_hint = header.size().ok();
1109 let mut sanitized_buf = Vec::new();
1110 let mut entry_stats = ArchiveStats::default();
1111 self.sanitize_entry(
1112 &path,
1113 &mut entry,
1114 &mut sanitized_buf,
1115 &mut entry_stats,
1116 size_hint,
1117 depth,
1118 )?;
1119 drop(entry);
1120 self.emit_entry_bytes(&path, &sanitized_buf);
1121
1122 let mut new_header = header.clone();
1123 let safe_path = sanitize_tar_entry_name(&path);
1124 new_header.set_path(&safe_path).map_err(|e| {
1125 SanitizeError::ArchiveError(format!("set path '{safe_path}': {e}"))
1126 })?;
1127 new_header.set_size(sanitized_buf.len() as u64);
1128 new_header.set_cksum();
1129 builder
1130 .append(&new_header, sanitized_buf.as_slice())
1131 .map_err(|e| {
1132 SanitizeError::ArchiveError(format!("append '{safe_path}': {e}"))
1133 })?;
1134
1135 stats.merge(&entry_stats);
1136 stats.files_processed += 1;
1137 self.emit_progress(&stats, None, &path);
1138 }
1139 }
1140 }
1141
1142 builder
1143 .finish()
1144 .map_err(|e| SanitizeError::ArchiveError(format!("finalize tar: {e}")))?;
1145
1146 Ok(stats)
1147 }
1148
1149 pub fn process_tar_gz<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
1159 self.process_tar_gz_at_depth(reader, writer, 0)
1160 }
1161
1162 fn process_tar_gz_at_depth<R: Read, W: Write>(
1164 &self,
1165 reader: R,
1166 writer: W,
1167 depth: u32,
1168 ) -> Result<ArchiveStats> {
1169 let gz_reader = flate2::read::GzDecoder::new(reader);
1170 let gz_writer = flate2::write::GzEncoder::new(writer, flate2::Compression::fast());
1171
1172 let stats = self.process_tar_at_depth(gz_reader, gz_writer, depth)?;
1173 Ok(stats)
1179 }
1180
1181 pub fn process_zip<R: Read + Seek, W: Write + Seek>(
1197 &self,
1198 reader: R,
1199 writer: W,
1200 ) -> Result<ArchiveStats> {
1201 self.process_zip_at_depth(reader, writer, 0)
1202 }
1203
1204 #[allow(clippy::too_many_lines)]
1215 fn process_zip_at_depth<R: Read + Seek, W: Write + Seek>(
1216 &self,
1217 reader: R,
1218 writer: W,
1219 depth: u32,
1220 ) -> Result<ArchiveStats> {
1221 struct ZipMeta {
1225 name: String,
1226 is_dir: bool,
1227 compression: zip::CompressionMethod,
1228 last_modified: Option<zip::DateTime>,
1229 unix_mode: Option<u32>,
1230 size: u64,
1231 }
1232
1233 let mut zip_in = zip::ZipArchive::new(reader)
1234 .map_err(|e| SanitizeError::ArchiveError(format!("open zip: {}", e)))?;
1235 let total_entries = zip_in.len();
1236 let total_entries_hint = Some(total_entries as u64);
1237
1238 let mut metas: Vec<ZipMeta> = Vec::with_capacity(total_entries);
1239 let mut file_count = 0usize;
1240 let mut total_uncompressed_size: u64 = 0;
1241
1242 for i in 0..total_entries {
1243 let entry = zip_in
1244 .by_index(i)
1245 .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1246 let is_dir = entry.is_dir();
1247 let size = entry.size();
1248 if !is_dir {
1249 file_count += 1;
1250 total_uncompressed_size = total_uncompressed_size.saturating_add(size);
1251 }
1252 metas.push(ZipMeta {
1253 name: sanitize_zip_entry_name(entry.name()),
1254 is_dir,
1255 compression: entry.compression(),
1256 last_modified: entry.last_modified(),
1257 unix_mode: entry.unix_mode(),
1258 size,
1259 });
1260 }
1262
1263 let use_parallel = file_count >= self.parallel_threshold
1268 && rayon::current_thread_index().is_none()
1269 && total_uncompressed_size <= PARALLEL_ZIP_DATA_SIZE;
1270
1271 let mut stats = ArchiveStats::default();
1272
1273 let make_options = |m: &ZipMeta| {
1275 let mut opts =
1276 zip::write::SimpleFileOptions::default().compression_method(m.compression);
1277 if let Some(dt) = m.last_modified {
1278 opts = opts.last_modified_time(dt);
1279 }
1280 if let Some(mode) = m.unix_mode {
1281 opts.unix_permissions(mode)
1282 } else {
1283 opts
1284 }
1285 };
1286
1287 if use_parallel {
1288 struct ZipEntry {
1290 meta_idx: usize,
1291 data: Vec<u8>,
1292 }
1293
1294 let mut file_entries: Vec<ZipEntry> = Vec::with_capacity(file_count);
1295
1296 for (i, meta) in metas.iter().enumerate() {
1297 if meta.is_dir {
1298 continue;
1299 }
1300 if !self.filter.passes(&meta.name) {
1302 continue;
1303 }
1304 let mut entry = zip_in
1305 .by_index(i)
1306 .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1307 let mut data = Vec::new();
1308 entry.read_to_end(&mut data).map_err(|e| {
1309 SanitizeError::ArchiveError(format!("read zip entry '{}': {}", meta.name, e))
1310 })?;
1311 file_entries.push(ZipEntry { meta_idx: i, data });
1312 }
1313
1314 let results: Vec<ParEntryResult> = file_entries
1315 .into_par_iter()
1316 .map(|e| {
1317 let meta = &metas[e.meta_idx];
1318 let result =
1319 self.sanitize_entry_bytes(&meta.name, &e.data, Some(meta.size), depth);
1320 (e.meta_idx, result)
1321 })
1322 .collect();
1323
1324 let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; metas.len()];
1327 for (meta_idx, r) in results {
1328 sanitized[meta_idx] = Some(r?);
1329 }
1330
1331 let mut zip_out = zip::ZipWriter::new(writer);
1332 for (i, meta) in metas.iter().enumerate() {
1333 let options = make_options(meta);
1334 if meta.is_dir {
1335 zip_out.add_directory(&meta.name, options).map_err(|e| {
1336 SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1337 })?;
1338 stats.entries_skipped += 1;
1339 self.emit_progress(&stats, total_entries_hint, &meta.name);
1340 continue;
1341 }
1342 if !self.filter.passes(&meta.name) {
1344 stats.entries_filtered += 1;
1345 self.emit_progress(&stats, total_entries_hint, &meta.name);
1346 continue;
1347 }
1348 let (sanitized_buf, entry_stats) = sanitized[i]
1349 .take()
1350 .expect("file entry sanitization result missing");
1351 stats.merge(&entry_stats);
1352 self.emit_entry_bytes(&meta.name, &sanitized_buf);
1353 zip_out.start_file(&meta.name, options).map_err(|e| {
1354 SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1355 })?;
1356 zip_out.write_all(&sanitized_buf).map_err(|e| {
1357 SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1358 })?;
1359 stats.files_processed += 1;
1360 self.emit_progress(&stats, total_entries_hint, &meta.name);
1361 }
1362 zip_out
1363 .finish()
1364 .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1365 } else {
1366 let mut zip_out = zip::ZipWriter::new(writer);
1369 for (i, meta) in metas.iter().enumerate() {
1370 let options = make_options(meta);
1371 if meta.is_dir {
1372 zip_out.add_directory(&meta.name, options).map_err(|e| {
1373 SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1374 })?;
1375 stats.entries_skipped += 1;
1376 self.emit_progress(&stats, total_entries_hint, &meta.name);
1377 continue;
1378 }
1379
1380 if !self.filter.passes(&meta.name) {
1382 stats.entries_filtered += 1;
1383 self.emit_progress(&stats, total_entries_hint, &meta.name);
1384 continue;
1385 }
1386
1387 let data = {
1388 let mut entry = zip_in.by_index(i).map_err(|e| {
1389 SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e))
1390 })?;
1391 let mut buf = Vec::new();
1392 entry.read_to_end(&mut buf).map_err(|e| {
1393 SanitizeError::ArchiveError(format!(
1394 "read zip entry '{}': {}",
1395 meta.name, e
1396 ))
1397 })?;
1398 buf
1399 };
1401
1402 let (sanitized_buf, entry_stats) =
1403 self.sanitize_entry_bytes(&meta.name, &data, Some(meta.size), depth)?;
1404 drop(data);
1405 self.emit_entry_bytes(&meta.name, &sanitized_buf);
1406
1407 zip_out.start_file(&meta.name, options).map_err(|e| {
1408 SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1409 })?;
1410 zip_out.write_all(&sanitized_buf).map_err(|e| {
1411 SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1412 })?;
1413 drop(sanitized_buf);
1414
1415 stats.merge(&entry_stats);
1416 stats.files_processed += 1;
1417 self.emit_progress(&stats, total_entries_hint, &meta.name);
1418 }
1419 zip_out
1420 .finish()
1421 .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1422 }
1423
1424 Ok(stats)
1425 }
1426
1427 pub fn process<R: Read + Seek, W: Write + Seek>(
1443 &self,
1444 reader: R,
1445 writer: W,
1446 format: ArchiveFormat,
1447 ) -> Result<ArchiveStats> {
1448 match format {
1449 ArchiveFormat::Zip => self.process_zip(reader, writer),
1450 ArchiveFormat::Tar => self.process_tar(reader, writer),
1451 ArchiveFormat::TarGz => self.process_tar_gz(reader, writer),
1452 }
1453 }
1454}
1455
1456struct CountingReader<'a> {
1462 inner: &'a mut dyn Read,
1463 count: u64,
1464}
1465
1466impl<'a> CountingReader<'a> {
1467 fn new(inner: &'a mut dyn Read) -> Self {
1468 Self { inner, count: 0 }
1469 }
1470
1471 fn bytes_read(&self) -> u64 {
1472 self.count
1473 }
1474}
1475
1476impl Read for CountingReader<'_> {
1477 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1478 let n = self.inner.read(buf)?;
1479 self.count += n as u64;
1480 Ok(n)
1481 }
1482}
1483
1484struct CountingWriter<'a> {
1486 inner: &'a mut dyn Write,
1487 count: u64,
1488}
1489
1490impl<'a> CountingWriter<'a> {
1491 fn new(inner: &'a mut dyn Write) -> Self {
1492 Self { inner, count: 0 }
1493 }
1494
1495 fn bytes_written(&self) -> u64 {
1496 self.count
1497 }
1498}
1499
1500impl Write for CountingWriter<'_> {
1501 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1502 let n = self.inner.write(buf)?;
1503 self.count += n as u64;
1504 Ok(n)
1505 }
1506
1507 fn flush(&mut self) -> io::Result<()> {
1508 self.inner.flush()
1509 }
1510}
1511
1512#[cfg(test)]
1517mod tests {
1518 use super::*;
1519 use crate::category::Category;
1520 use crate::generator::HmacGenerator;
1521 use crate::processor::profile::{FieldRule, FileTypeProfile};
1522 use crate::processor::registry::ProcessorRegistry;
1523 use crate::scanner::{ScanConfig, ScanPattern};
1524 use std::io::Cursor;
1525 use std::sync::Mutex;
1526
1527 fn make_archive_processor() -> ArchiveProcessor {
1529 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1530 let store = Arc::new(MappingStore::new(gen, None));
1531
1532 let patterns = vec![
1533 ScanPattern::from_regex(
1534 r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
1535 Category::Email,
1536 "email",
1537 )
1538 .unwrap(),
1539 ScanPattern::from_literal("SUPERSECRET", Category::Custom("api_key".into()), "api_key")
1540 .unwrap(),
1541 ];
1542
1543 let scanner = Arc::new(
1544 StreamScanner::new(patterns, Arc::clone(&store), ScanConfig::default()).unwrap(),
1545 );
1546
1547 let registry = Arc::new(ProcessorRegistry::with_builtins());
1548
1549 let profiles = vec![FileTypeProfile::new(
1550 "json",
1551 vec![FieldRule::new("*").with_category(Category::Custom("field".into()))],
1552 )
1553 .with_extension(".json")];
1554
1555 ArchiveProcessor::new(registry, scanner, store, profiles)
1556 }
1557
1558 fn build_test_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
1561 let mut buf = Vec::new();
1562 {
1563 let mut builder = tar::Builder::new(&mut buf);
1564 for (name, data) in entries {
1565 let mut header = tar::Header::new_gnu();
1566 header.set_size(data.len() as u64);
1567 header.set_mode(0o644);
1568 header.set_mtime(1_700_000_000);
1569 header.set_cksum();
1570 builder.append_data(&mut header, *name, *data).unwrap();
1571 }
1572 builder.finish().unwrap();
1573 }
1574 buf
1575 }
1576
1577 #[test]
1578 fn tar_sanitizes_plaintext_with_scanner() {
1579 let proc = make_archive_processor();
1580 let input = build_test_tar(&[("readme.txt", b"Contact alice@corp.com for help.")]);
1581
1582 let mut output = Vec::new();
1583 let stats = proc.process_tar(&input[..], &mut output).unwrap();
1584
1585 assert_eq!(stats.files_processed, 1);
1586 assert_eq!(stats.scanner_fallback, 1);
1587 assert_eq!(stats.structured_hits, 0);
1588
1589 let mut archive = tar::Archive::new(&output[..]);
1591 for entry in archive.entries().unwrap() {
1592 let mut e = entry.unwrap();
1593 let mut content = String::new();
1594 e.read_to_string(&mut content).unwrap();
1595 assert!(
1596 !content.contains("alice@corp.com"),
1597 "email should be sanitized: {content}"
1598 );
1599 }
1600 }
1601
1602 #[test]
1603 fn tar_sanitizes_json_with_structured_processor() {
1604 let proc = make_archive_processor();
1605 let json_content = br#"{"email": "bob@example.org", "name": "Bob"}"#;
1606 let input = build_test_tar(&[("config.json", json_content)]);
1607
1608 let mut output = Vec::new();
1609 let stats = proc.process_tar(&input[..], &mut output).unwrap();
1610
1611 assert_eq!(stats.files_processed, 1);
1612 assert_eq!(stats.structured_hits, 1);
1613 assert_eq!(stats.scanner_fallback, 0);
1614 assert_eq!(
1615 stats.file_methods.get("config.json").unwrap(),
1616 "structured+scan:json"
1617 );
1618
1619 let mut archive = tar::Archive::new(&output[..]);
1621 for entry in archive.entries().unwrap() {
1622 let mut e = entry.unwrap();
1623 let mut content = String::new();
1624 e.read_to_string(&mut content).unwrap();
1625 assert!(
1626 !content.contains("bob@example.org"),
1627 "email should be sanitized"
1628 );
1629 assert!(!content.contains("Bob"), "name should be sanitized");
1630 }
1631 }
1632
1633 #[test]
1634 fn tar_preserves_metadata() {
1635 let proc = make_archive_processor();
1636 let input = build_test_tar(&[("data.txt", b"SUPERSECRET token here")]);
1637
1638 let mut output = Vec::new();
1639 proc.process_tar(&input[..], &mut output).unwrap();
1640
1641 let mut archive = tar::Archive::new(&output[..]);
1642 for entry in archive.entries().unwrap() {
1643 let e = entry.unwrap();
1644 let hdr = e.header();
1645 assert_eq!(hdr.mode().unwrap(), 0o644);
1646 assert_eq!(hdr.mtime().unwrap(), 1_700_000_000);
1647 }
1648 }
1649
1650 #[test]
1651 fn tar_handles_multiple_files() {
1652 let proc = make_archive_processor();
1653 let input = build_test_tar(&[
1654 ("a.txt", b"alice@corp.com"),
1655 ("b.json", br#"{"key":"value"}"#),
1656 ("c.log", b"no secrets here"),
1657 ]);
1658
1659 let mut output = Vec::new();
1660 let stats = proc.process_tar(&input[..], &mut output).unwrap();
1661
1662 assert_eq!(stats.files_processed, 3);
1663 assert_eq!(stats.structured_hits, 1);
1665 assert_eq!(stats.scanner_fallback, 2);
1667 }
1668
1669 #[test]
1670 fn tar_passes_through_directories() {
1671 let mut buf = Vec::new();
1672 {
1673 let mut builder = tar::Builder::new(&mut buf);
1674
1675 let mut dir_header = tar::Header::new_gnu();
1677 dir_header.set_entry_type(tar::EntryType::Directory);
1678 dir_header.set_size(0);
1679 dir_header.set_mode(0o755);
1680 dir_header.set_cksum();
1681 builder
1682 .append_data(&mut dir_header, "mydir/", &b""[..])
1683 .unwrap();
1684
1685 let mut file_header = tar::Header::new_gnu();
1687 file_header.set_size(5);
1688 file_header.set_mode(0o644);
1689 file_header.set_cksum();
1690 builder
1691 .append_data(&mut file_header, "mydir/hello.txt", &b"hello"[..])
1692 .unwrap();
1693
1694 builder.finish().unwrap();
1695 }
1696
1697 let proc = make_archive_processor();
1698 let mut output = Vec::new();
1699 let stats = proc.process_tar(&buf[..], &mut output).unwrap();
1700
1701 assert_eq!(stats.entries_skipped, 1);
1702 assert_eq!(stats.files_processed, 1);
1703 }
1704
1705 #[test]
1708 fn tar_gz_round_trip() {
1709 let proc = make_archive_processor();
1710
1711 let tar_data = build_test_tar(&[("secret.txt", b"Key is SUPERSECRET okay")]);
1713 let mut gz_input = Vec::new();
1714 {
1715 let mut encoder =
1716 flate2::write::GzEncoder::new(&mut gz_input, flate2::Compression::fast());
1717 encoder.write_all(&tar_data).unwrap();
1718 encoder.finish().unwrap();
1719 }
1720
1721 let mut gz_output = Vec::new();
1722 let stats = proc.process_tar_gz(&gz_input[..], &mut gz_output).unwrap();
1723
1724 assert_eq!(stats.files_processed, 1);
1725 assert_eq!(stats.scanner_fallback, 1);
1726
1727 let decoder = flate2::read::GzDecoder::new(&gz_output[..]);
1729 let mut archive = tar::Archive::new(decoder);
1730 for entry in archive.entries().unwrap() {
1731 let mut e = entry.unwrap();
1732 let mut content = String::new();
1733 e.read_to_string(&mut content).unwrap();
1734 assert!(
1735 !content.contains("SUPERSECRET"),
1736 "secret should be sanitized: {content}"
1737 );
1738 }
1739 }
1740
1741 fn build_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
1744 let mut buf = Cursor::new(Vec::new());
1745 {
1746 let mut zip = zip::ZipWriter::new(&mut buf);
1747 for (name, data) in entries {
1748 let options = zip::write::SimpleFileOptions::default()
1749 .compression_method(zip::CompressionMethod::Deflated);
1750 zip.start_file(*name, options).unwrap();
1751 zip.write_all(data).unwrap();
1752 }
1753 zip.finish().unwrap();
1754 }
1755 buf.into_inner()
1756 }
1757
1758 #[test]
1759 fn zip_sanitizes_plaintext_with_scanner() {
1760 let proc = make_archive_processor();
1761 let zip_data = build_test_zip(&[("notes.txt", b"Reach alice@corp.com for info.")]);
1762
1763 let reader = Cursor::new(&zip_data);
1764 let mut writer = Cursor::new(Vec::new());
1765 let stats = proc.process_zip(reader, &mut writer).unwrap();
1766
1767 assert_eq!(stats.files_processed, 1);
1768 assert_eq!(stats.scanner_fallback, 1);
1769
1770 let out_data = writer.into_inner();
1772 let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
1773 let mut entry = zip_out.by_index(0).unwrap();
1774 let mut content = String::new();
1775 entry.read_to_string(&mut content).unwrap();
1776 assert!(
1777 !content.contains("alice@corp.com"),
1778 "email should be sanitized: {content}"
1779 );
1780 }
1781
1782 #[test]
1783 fn zip_sanitizes_json_with_structured_processor() {
1784 let proc = make_archive_processor();
1785 let json_content = br#"{"password": "hunter2", "host": "db.internal"}"#;
1786 let zip_data = build_test_zip(&[("settings.json", json_content)]);
1787
1788 let reader = Cursor::new(&zip_data);
1789 let mut writer = Cursor::new(Vec::new());
1790 let stats = proc.process_zip(reader, &mut writer).unwrap();
1791
1792 assert_eq!(stats.files_processed, 1);
1793 assert_eq!(stats.structured_hits, 1);
1794
1795 let out_data = writer.into_inner();
1796 let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
1797 let mut entry = zip_out.by_index(0).unwrap();
1798 let mut content = String::new();
1799 entry.read_to_string(&mut content).unwrap();
1800 assert!(!content.contains("hunter2"), "password should be sanitized");
1801 assert!(!content.contains("db.internal"), "host should be sanitized");
1802 }
1803
1804 #[test]
1805 fn zip_preserves_directory_entries() {
1806 let mut buf = Cursor::new(Vec::new());
1807 {
1808 let mut zip = zip::ZipWriter::new(&mut buf);
1809
1810 let dir_options = zip::write::SimpleFileOptions::default();
1811 zip.add_directory("subdir/", dir_options).unwrap();
1812
1813 let file_options = zip::write::SimpleFileOptions::default()
1814 .compression_method(zip::CompressionMethod::Stored);
1815 zip.start_file("subdir/data.txt", file_options).unwrap();
1816 zip.write_all(b"SUPERSECRET value").unwrap();
1817
1818 zip.finish().unwrap();
1819 }
1820
1821 let zip_data = buf.into_inner();
1822 let proc = make_archive_processor();
1823 let reader = Cursor::new(&zip_data);
1824 let mut writer = Cursor::new(Vec::new());
1825 let stats = proc.process_zip(reader, &mut writer).unwrap();
1826
1827 assert_eq!(stats.entries_skipped, 1); assert_eq!(stats.files_processed, 1);
1829 }
1830
1831 #[test]
1832 fn zip_handles_multiple_files() {
1833 let proc = make_archive_processor();
1834 let zip_data = build_test_zip(&[
1835 ("file1.txt", b"alice@corp.com"),
1836 ("file2.json", br#"{"secret":"SUPERSECRET"}"#),
1837 ("file3.log", b"nothing to see"),
1838 ]);
1839
1840 let reader = Cursor::new(&zip_data);
1841 let mut writer = Cursor::new(Vec::new());
1842 let stats = proc.process_zip(reader, &mut writer).unwrap();
1843
1844 assert_eq!(stats.files_processed, 3);
1845 assert_eq!(stats.structured_hits, 1); assert_eq!(stats.scanner_fallback, 2); }
1848
1849 #[test]
1850 fn tar_progress_callback_receives_updates() {
1851 let updates = Arc::new(Mutex::new(Vec::new()));
1852 let proc = make_archive_processor().with_progress_callback({
1853 let updates = Arc::clone(&updates);
1854 Arc::new(move |progress| {
1855 updates
1856 .lock()
1857 .expect("archive progress lock")
1858 .push(progress.clone());
1859 })
1860 });
1861 let input = build_test_tar(&[("a.txt", b"alice@corp.com"), ("b.txt", b"SUPERSECRET")]);
1862
1863 let mut output = Vec::new();
1864 let stats = proc.process_tar(&input[..], &mut output).unwrap();
1865 let updates = updates.lock().unwrap();
1866
1867 assert_eq!(updates.len(), 2);
1868 assert_eq!(updates.last().unwrap().entries_seen, 2);
1869 assert_eq!(
1870 updates.last().unwrap().files_processed,
1871 stats.files_processed
1872 );
1873 assert_eq!(updates.last().unwrap().total_entries, None);
1874 }
1875
1876 #[test]
1877 fn zip_progress_callback_reports_total_entries() {
1878 let updates = Arc::new(Mutex::new(Vec::new()));
1879 let proc = make_archive_processor().with_progress_callback({
1880 let updates = Arc::clone(&updates);
1881 Arc::new(move |progress| {
1882 updates
1883 .lock()
1884 .expect("archive progress lock")
1885 .push(progress.clone());
1886 })
1887 });
1888 let zip_data = build_test_zip(&[
1889 ("file1.txt", b"alice@corp.com"),
1890 ("file2.log", b"nothing to see"),
1891 ]);
1892
1893 let reader = Cursor::new(&zip_data);
1894 let mut writer = Cursor::new(Vec::new());
1895 let stats = proc.process_zip(reader, &mut writer).unwrap();
1896 let updates = updates.lock().unwrap();
1897
1898 assert_eq!(updates.len(), 2);
1899 assert_eq!(
1900 updates.last().unwrap().files_processed,
1901 stats.files_processed
1902 );
1903 assert_eq!(updates.last().unwrap().total_entries, Some(2));
1904 assert_eq!(updates.last().unwrap().current_entry, "file2.log");
1905 }
1906
1907 #[test]
1910 fn format_detection_from_path() {
1911 assert_eq!(
1912 ArchiveFormat::from_path("data.tar"),
1913 Some(ArchiveFormat::Tar)
1914 );
1915 assert_eq!(
1916 ArchiveFormat::from_path("data.tar.gz"),
1917 Some(ArchiveFormat::TarGz)
1918 );
1919 assert_eq!(
1920 ArchiveFormat::from_path("data.tgz"),
1921 Some(ArchiveFormat::TarGz)
1922 );
1923 assert_eq!(
1924 ArchiveFormat::from_path("data.zip"),
1925 Some(ArchiveFormat::Zip)
1926 );
1927 assert_eq!(
1928 ArchiveFormat::from_path("DATA.ZIP"),
1929 Some(ArchiveFormat::Zip)
1930 );
1931 assert_eq!(ArchiveFormat::from_path("photo.png"), None);
1932 }
1933
1934 #[test]
1937 fn same_secret_gets_same_replacement_across_entries() {
1938 let proc = make_archive_processor();
1939 let input = build_test_tar(&[
1940 ("a.txt", b"contact alice@corp.com"),
1941 ("b.txt", b"reach alice@corp.com"),
1942 ]);
1943
1944 let mut output = Vec::new();
1945 proc.process_tar(&input[..], &mut output).unwrap();
1946
1947 let mut archive = tar::Archive::new(&output[..]);
1948 let mut contents: Vec<String> = Vec::new();
1949 for entry in archive.entries().unwrap() {
1950 let mut e = entry.unwrap();
1951 let mut s = String::new();
1952 e.read_to_string(&mut s).unwrap();
1953 contents.push(s);
1954 }
1955
1956 let replacement_a = contents[0].strip_prefix("contact ").unwrap();
1959 let replacement_b = contents[1].strip_prefix("reach ").unwrap();
1960 assert_eq!(
1961 replacement_a, replacement_b,
1962 "dedup should produce identical replacements"
1963 );
1964 assert!(!replacement_a.contains("alice@corp.com"));
1965 }
1966
1967 #[test]
1970 fn process_auto_dispatch_tar() {
1971 let proc = make_archive_processor();
1972 let tar_data = build_test_tar(&[("f.txt", b"SUPERSECRET")]);
1973
1974 let reader = Cursor::new(tar_data);
1975 let writer = Cursor::new(Vec::new());
1976 let stats = proc.process(reader, writer, ArchiveFormat::Tar).unwrap();
1977
1978 assert_eq!(stats.files_processed, 1);
1979 }
1980
1981 #[test]
1982 fn process_auto_dispatch_zip() {
1983 let proc = make_archive_processor();
1984 let zip_data = build_test_zip(&[("f.txt", b"SUPERSECRET")]);
1985
1986 let reader = Cursor::new(zip_data);
1987 let mut writer = Cursor::new(Vec::new());
1988 let stats = proc
1989 .process(reader, &mut writer, ArchiveFormat::Zip)
1990 .unwrap();
1991
1992 assert_eq!(stats.files_processed, 1);
1993 }
1994
1995 #[test]
1998 fn tar_empty_archive() {
1999 let proc = make_archive_processor();
2000 let tar_data = build_test_tar(&[]);
2001
2002 let mut output = Vec::new();
2003 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2004
2005 assert_eq!(stats.files_processed, 0);
2006 assert_eq!(stats.entries_skipped, 0);
2007 }
2008
2009 #[test]
2010 fn zip_empty_archive() {
2011 let proc = make_archive_processor();
2012 let zip_data = build_test_zip(&[]);
2013
2014 let reader = Cursor::new(zip_data);
2015 let mut writer = Cursor::new(Vec::new());
2016 let stats = proc.process_zip(reader, &mut writer).unwrap();
2017
2018 assert_eq!(stats.files_processed, 0);
2019 }
2020
2021 #[test]
2024 fn zip_entry_name_clean_passthrough() {
2025 assert_eq!(sanitize_zip_entry_name("logs/app.log"), "logs/app.log");
2026 assert_eq!(sanitize_zip_entry_name("config.yaml"), "config.yaml");
2027 assert_eq!(sanitize_zip_entry_name("a/b/c.txt"), "a/b/c.txt");
2028 }
2029
2030 #[test]
2031 fn zip_entry_name_strips_leading_slash() {
2032 assert_eq!(sanitize_zip_entry_name("/etc/passwd"), "etc/passwd");
2033 assert_eq!(sanitize_zip_entry_name("///etc/passwd"), "etc/passwd");
2034 }
2035
2036 #[test]
2037 fn zip_entry_name_strips_dotdot() {
2038 assert_eq!(sanitize_zip_entry_name("../etc/passwd"), "etc/passwd");
2039 assert_eq!(
2040 sanitize_zip_entry_name("a/../../etc/passwd"),
2041 "a/etc/passwd"
2042 );
2043 assert_eq!(
2044 sanitize_zip_entry_name("../../root/.ssh/id_rsa"),
2045 "root/.ssh/id_rsa"
2046 );
2047 }
2048
2049 #[test]
2050 fn zip_entry_name_strips_leading_dot_slash() {
2051 assert_eq!(sanitize_zip_entry_name("./config.yaml"), "config.yaml");
2052 assert_eq!(sanitize_zip_entry_name("././config.yaml"), "config.yaml");
2053 }
2054
2055 #[test]
2056 fn zip_entry_name_backslash_normalised() {
2057 assert_eq!(sanitize_zip_entry_name("a\\b\\c.txt"), "a/b/c.txt");
2058 assert_eq!(sanitize_zip_entry_name("..\\etc\\passwd"), "etc/passwd");
2059 }
2060
2061 #[test]
2062 fn zip_entry_name_empty_result_replaced() {
2063 assert_eq!(sanitize_zip_entry_name("../.."), "_");
2064 assert_eq!(sanitize_zip_entry_name(""), "_");
2065 assert_eq!(sanitize_zip_entry_name("/"), "_");
2066 }
2067
2068 #[test]
2069 fn zip_entry_name_absolute_dotdot_combo() {
2070 assert_eq!(sanitize_zip_entry_name("/../etc/passwd"), "etc/passwd");
2071 }
2072
2073 #[test]
2076 fn filter_empty_passes_everything() {
2077 let f = ArchiveFilter::new(vec![], vec![]).unwrap();
2078 assert!(f.is_empty());
2079 assert!(f.passes("config/app.yaml"));
2080 assert!(f.passes("logs/server.log"));
2081 }
2082
2083 #[test]
2084 fn filter_only_glob_includes_match() {
2085 let f = ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap();
2086 assert!(!f.is_empty());
2087 assert!(f.passes("config/settings.json"));
2088 assert!(f.passes("deep/nested/file.json"));
2089 assert!(!f.passes("config/settings.yaml"));
2090 }
2091
2092 #[test]
2093 fn filter_only_dir_prefix_includes_subtree() {
2094 let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2095 assert!(f.passes("config/app.yaml"));
2096 assert!(f.passes("config/nested/db.yaml"));
2097 assert!(!f.passes("logs/server.log"));
2098 }
2099
2100 #[test]
2101 fn filter_dir_prefix_exact_match() {
2102 let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2103 assert!(f.passes("config"));
2105 }
2106
2107 #[test]
2108 fn filter_exclude_removes_match() {
2109 let f = ArchiveFilter::new(vec![], vec!["**/*.log".into()]).unwrap();
2110 assert!(!f.passes("logs/server.log"));
2111 assert!(f.passes("config/app.yaml"));
2112 }
2113
2114 #[test]
2115 fn filter_only_and_exclude_combined() {
2116 let f =
2117 ArchiveFilter::new(vec!["config/".into()], vec!["config/secrets.yaml".into()]).unwrap();
2118 assert!(f.passes("config/app.yaml"));
2119 assert!(!f.passes("config/secrets.yaml"));
2120 assert!(!f.passes("logs/server.log"));
2121 }
2122
2123 #[test]
2124 fn filter_invalid_glob_returns_error() {
2125 assert!(ArchiveFilter::new(vec!["[invalid".into()], vec![]).is_err());
2126 assert!(ArchiveFilter::new(vec![], vec!["[bad".into()]).is_err());
2127 }
2128
2129 #[test]
2132 fn builder_with_max_depth_clamps_at_max() {
2133 let proc = make_archive_processor().with_max_depth(999);
2134 assert_eq!(proc.max_depth, MAX_ARCHIVE_DEPTH);
2135 }
2136
2137 #[test]
2138 fn builder_with_max_depth_sets_value() {
2139 let proc = make_archive_processor().with_max_depth(2);
2140 assert_eq!(proc.max_depth, 2);
2141 }
2142
2143 #[test]
2144 fn builder_with_parallel_threshold_sets_value() {
2145 let proc = make_archive_processor().with_parallel_threshold(usize::MAX);
2146 assert_eq!(proc.parallel_threshold, usize::MAX);
2147 }
2148
2149 #[test]
2150 fn builder_with_force_text_enables_flag() {
2151 let proc = make_archive_processor().with_force_text(true);
2152 assert!(proc.force_text);
2153 }
2154
2155 #[test]
2156 fn builder_with_filter_applied_to_zip() {
2157 let proc = make_archive_processor()
2158 .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2159
2160 let zip_data = build_test_zip(&[
2161 ("config.json", br#"{"email":"alice@corp.com"}"#),
2162 ("notes.txt", b"alice@corp.com"),
2163 ]);
2164
2165 let reader = Cursor::new(zip_data);
2166 let mut writer = Cursor::new(Vec::new());
2167 let stats = proc.process_zip(reader, &mut writer).unwrap();
2168
2169 assert_eq!(stats.files_processed, 1);
2171 assert_eq!(stats.entries_filtered, 1);
2172 }
2173
2174 #[test]
2175 fn builder_with_filter_applied_to_tar() {
2176 let proc = make_archive_processor()
2177 .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2178
2179 let tar_data = build_test_tar(&[
2180 ("config.json", br#"{"email":"alice@corp.com"}"#),
2181 ("notes.txt", b"alice@corp.com"),
2182 ]);
2183
2184 let mut output = Vec::new();
2185 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2186
2187 assert_eq!(stats.files_processed, 1);
2188 assert_eq!(stats.entries_filtered, 1);
2189 }
2190
2191 #[test]
2194 fn parallel_tar_sanitizes_all_entries() {
2195 let proc = make_archive_processor().with_parallel_threshold(0);
2197 let tar_data = build_test_tar(&[
2198 ("a.txt", b"alice@corp.com"),
2199 ("b.txt", b"bob@corp.com"),
2200 ("c.txt", b"carol@corp.com"),
2201 ("d.txt", b"dave@corp.com"),
2202 ]);
2203
2204 let mut output = Vec::new();
2205 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2206
2207 assert_eq!(stats.files_processed, 4);
2208
2209 let originals = [
2211 "alice@corp.com",
2212 "bob@corp.com",
2213 "carol@corp.com",
2214 "dave@corp.com",
2215 ];
2216 let mut archive = tar::Archive::new(&output[..]);
2217 for entry in archive.entries().unwrap() {
2218 let mut e = entry.unwrap();
2219 let mut content = String::new();
2220 e.read_to_string(&mut content).unwrap();
2221 for orig in &originals {
2222 assert!(
2223 !content.contains(orig),
2224 "original secret leaked in {:?}",
2225 e.path()
2226 );
2227 }
2228 }
2229 }
2230
2231 #[test]
2232 fn parallel_tar_preserves_entry_order() {
2233 let proc = make_archive_processor().with_parallel_threshold(0);
2234 let tar_data = build_test_tar(&[
2235 ("first.txt", b"alice@corp.com"),
2236 ("second.txt", b"hello"),
2237 ("third.txt", b"bob@corp.com"),
2238 ]);
2239
2240 let mut output = Vec::new();
2241 proc.process_tar(&tar_data[..], &mut output).unwrap();
2242
2243 let mut archive = tar::Archive::new(&output[..]);
2244 let names: Vec<String> = archive
2245 .entries()
2246 .unwrap()
2247 .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
2248 .collect();
2249
2250 assert_eq!(names, vec!["first.txt", "second.txt", "third.txt"]);
2251 }
2252
2253 #[test]
2254 fn parallel_zip_sanitizes_all_entries() {
2255 let proc = make_archive_processor().with_parallel_threshold(0);
2256 let zip_data = build_test_zip(&[
2257 ("a.txt", b"alice@corp.com"),
2258 ("b.txt", b"bob@corp.com"),
2259 ("c.txt", b"carol@corp.com"),
2260 ("d.txt", b"dave@corp.com"),
2261 ]);
2262
2263 let reader = Cursor::new(zip_data);
2264 let mut writer = Cursor::new(Vec::new());
2265 let stats = proc.process_zip(reader, &mut writer).unwrap();
2266
2267 assert_eq!(stats.files_processed, 4);
2268
2269 let originals = [
2270 "alice@corp.com",
2271 "bob@corp.com",
2272 "carol@corp.com",
2273 "dave@corp.com",
2274 ];
2275 let out_data = writer.into_inner();
2276 let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
2277 for i in 0..zip_out.len() {
2278 let mut entry = zip_out.by_index(i).unwrap();
2279 let mut content = String::new();
2280 entry.read_to_string(&mut content).unwrap();
2281 for orig in &originals {
2282 assert!(
2283 !content.contains(orig),
2284 "original secret leaked in entry {i}"
2285 );
2286 }
2287 }
2288 }
2289
2290 #[test]
2291 fn parallel_tar_mixed_structured_and_scanner() {
2292 let proc = make_archive_processor().with_parallel_threshold(0);
2293 let tar_data = build_test_tar(&[
2294 ("config.json", br#"{"email":"alice@corp.com","port":5432}"#),
2295 ("notes.txt", b"contact bob@corp.com for help"),
2296 ("data.json", br#"{"email":"carol@corp.com"}"#),
2297 ("readme.txt", b"dave@corp.com is the owner"),
2298 ]);
2299
2300 let mut output = Vec::new();
2301 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2302
2303 assert_eq!(stats.files_processed, 4);
2304 assert_eq!(stats.structured_hits, 2); assert_eq!(stats.scanner_fallback, 2); let originals = [
2308 "alice@corp.com",
2309 "bob@corp.com",
2310 "carol@corp.com",
2311 "dave@corp.com",
2312 ];
2313 let mut archive = tar::Archive::new(&output[..]);
2314 for entry in archive.entries().unwrap() {
2315 let mut e = entry.unwrap();
2316 let mut content = String::new();
2317 e.read_to_string(&mut content).unwrap();
2318 for orig in &originals {
2319 assert!(!content.contains(orig), "original secret leaked");
2320 }
2321 }
2322 }
2323
2324 #[test]
2327 fn tar_in_tar_secrets_sanitized() {
2328 let inner_tar = build_test_tar(&[("inner.txt", b"alice@corp.com")]);
2330
2331 let outer_tar = build_test_tar(&[("nested.tar", &inner_tar)]);
2333
2334 let proc = make_archive_processor();
2335 let mut output = Vec::new();
2336 let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
2337
2338 assert_eq!(stats.nested_archives, 1);
2339
2340 let mut outer = tar::Archive::new(&output[..]);
2342 for entry in outer.entries().unwrap() {
2343 let mut e = entry.unwrap();
2344 let mut inner_bytes = Vec::new();
2345 e.read_to_end(&mut inner_bytes).unwrap();
2346 let mut inner = tar::Archive::new(&inner_bytes[..]);
2347 for inner_entry in inner.entries().unwrap() {
2348 let mut ie = inner_entry.unwrap();
2349 let mut content = String::new();
2350 ie.read_to_string(&mut content).unwrap();
2351 assert!(
2352 !content.contains("alice@corp.com"),
2353 "secret survived nested tar"
2354 );
2355 }
2356 }
2357 }
2358
2359 #[test]
2360 fn zip_in_tar_secrets_sanitized() {
2361 let inner_zip = build_test_zip(&[("inner.txt", b"SUPERSECRET")]);
2362 let outer_tar = build_test_tar(&[("nested.zip", &inner_zip)]);
2363
2364 let proc = make_archive_processor();
2365 let mut output = Vec::new();
2366 let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
2367
2368 assert_eq!(stats.nested_archives, 1);
2369
2370 let mut outer = tar::Archive::new(&output[..]);
2371 for entry in outer.entries().unwrap() {
2372 let mut e = entry.unwrap();
2373 let mut zip_bytes = Vec::new();
2374 e.read_to_end(&mut zip_bytes).unwrap();
2375 let mut zip_out = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
2376 for i in 0..zip_out.len() {
2377 let mut ze = zip_out.by_index(i).unwrap();
2378 let mut content = String::new();
2379 ze.read_to_string(&mut content).unwrap();
2380 assert!(
2381 !content.contains("SUPERSECRET"),
2382 "secret survived zip-in-tar"
2383 );
2384 }
2385 }
2386 }
2387
2388 #[test]
2389 fn zip_in_zip_secrets_sanitized() {
2390 let inner_zip = build_test_zip(&[("secret.txt", b"alice@corp.com")]);
2391 let outer_zip = build_test_zip(&[("nested.zip", &inner_zip)]);
2392
2393 let proc = make_archive_processor();
2394 let reader = Cursor::new(outer_zip);
2395 let mut writer = Cursor::new(Vec::new());
2396 let stats = proc.process_zip(reader, &mut writer).unwrap();
2397
2398 assert_eq!(stats.nested_archives, 1);
2399
2400 let out_bytes = writer.into_inner();
2401 let mut outer = zip::ZipArchive::new(Cursor::new(out_bytes)).unwrap();
2402 let mut inner_bytes = Vec::new();
2403 outer
2404 .by_index(0)
2405 .unwrap()
2406 .read_to_end(&mut inner_bytes)
2407 .unwrap();
2408 let mut inner = zip::ZipArchive::new(Cursor::new(inner_bytes)).unwrap();
2409 let mut content = String::new();
2410 inner
2411 .by_index(0)
2412 .unwrap()
2413 .read_to_string(&mut content)
2414 .unwrap();
2415 assert!(
2416 !content.contains("alice@corp.com"),
2417 "secret survived zip-in-zip"
2418 );
2419 }
2420
2421 #[test]
2422 fn nested_archive_depth_limit_returns_error() {
2423 let proc = make_archive_processor().with_max_depth(1);
2426
2427 let innermost = build_test_tar(&[("file.txt", b"secret")]);
2428 let middle = build_test_tar(&[("inner.tar", &innermost)]);
2429 let outer = build_test_tar(&[("middle.tar", &middle)]);
2430
2431 let mut output = Vec::new();
2432 let err = proc.process_tar(&outer[..], &mut output).unwrap_err();
2433 assert!(matches!(err, SanitizeError::RecursionDepthExceeded(_)));
2434 }
2435
2436 #[test]
2437 fn force_text_skips_structured_processor() {
2438 let proc = make_archive_processor().with_force_text(true);
2439 let tar_data = build_test_tar(&[("config.json", br#"{"email":"alice@corp.com"}"#)]);
2440
2441 let mut output = Vec::new();
2442 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2443
2444 assert_eq!(stats.scanner_fallback, 1);
2446 assert_eq!(stats.structured_hits, 0);
2447 }
2448}