1use crate::error::{Result, SanitizeError};
57use crate::processor::profile::FileTypeProfile;
58use crate::processor::registry::ProcessorRegistry;
59use crate::scanner::{ScanPattern, ScanStats, StreamScanner};
60use crate::store::MappingStore;
61
62fn sanitize_archive_entry_name(name: &str) -> String {
69 let name = name.replace('\\', "/");
70 let name = name.trim_start_matches('/');
71 let safe: Vec<&str> = name
72 .split('/')
73 .filter(|s| {
74 if s.is_empty() || *s == "." || *s == ".." {
75 return false;
76 }
77 if s.len() == 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
80 return false;
81 }
82 true
83 })
84 .collect();
85 let result = safe.join("/");
86 if result.is_empty() {
87 "_".to_string()
88 } else {
89 result
90 }
91}
92
93#[inline]
94fn sanitize_zip_entry_name(name: &str) -> String {
95 sanitize_archive_entry_name(name)
96}
97
98#[inline]
99fn sanitize_tar_entry_name(name: &str) -> String {
100 sanitize_archive_entry_name(name)
101}
102
103use glob::MatchOptions;
104use rayon::prelude::*;
105use std::collections::HashMap;
106use std::io::{self, Read, Seek, Write};
107use std::path::{Path, PathBuf};
108use std::sync::{Arc, OnceLock};
109
110fn append_tar_entry<W: Write>(
118 builder: &mut tar::Builder<W>,
119 mut header: tar::Header,
120 path: &str,
121 link_target: Option<&Path>,
122 data: &[u8],
123) -> Result<()> {
124 let mut safe_path = sanitize_tar_entry_name(path);
125 if path.ends_with('/') && !safe_path.ends_with('/') {
126 safe_path.push('/');
127 }
128 let entry_type = header.entry_type();
129 if entry_type.is_symlink() || entry_type.is_hard_link() {
130 if let Some(target) = link_target {
131 return builder
132 .append_link(&mut header, &safe_path, target)
133 .map_err(|e| {
134 SanitizeError::ArchiveError(format!("append link '{safe_path}': {e}"))
135 });
136 }
137 }
138 header.set_size(data.len() as u64);
139 builder
140 .append_data(&mut header, &safe_path, data)
141 .map_err(|e| SanitizeError::ArchiveError(format!("append '{safe_path}': {e}")))
142}
143
144use crate::entropy::{entropy_scan_bytes, merge_entropy_counts, EntropyConfig};
145use crate::processor::limits::{
146 DEFAULT_ARCHIVE_DEPTH, MAX_ARCHIVE_DEPTH, PARALLEL_ENTRY_THRESHOLD, PARALLEL_TAR_DATA_SIZE,
147 PARALLEL_ZIP_DATA_SIZE, STRUCTURED_ENTRY_SIZE,
148};
149
150const GZIP_MAGIC: &[u8] = &[0x1f, 0x8b];
152
153fn strip_gz_suffix(filename: &str) -> Option<&str> {
157 let lower = filename.to_ascii_lowercase();
158 if lower.strip_suffix(".tar.gz").is_some() || lower.strip_suffix(".tgz").is_some() {
159 return None;
160 }
161 let stem_len = lower.strip_suffix(".gz")?.len();
162 (stem_len > 0).then(|| &filename[..stem_len])
163}
164
165fn nested_archive_format(filename: &str) -> Option<ArchiveFormat> {
169 ArchiveFormat::from_path(filename).filter(|f| *f != ArchiveFormat::Gz)
170}
171
172fn read_bounded(reader: &mut dyn Read, limit: u64, label: &str) -> Result<Vec<u8>> {
177 let mut content = Vec::new();
178 reader
180 .take(limit + 1)
181 .read_to_end(&mut content)
182 .map_err(|e| SanitizeError::ArchiveError(format!("read '{label}': {e}")))?;
183 if content.len() as u64 > limit {
184 return Err(SanitizeError::ArchiveError(format!(
185 "entry '{label}' exceeds the {limit}-byte size limit",
186 )));
187 }
188 Ok(content)
189}
190
191type ParEntryResult = (usize, Result<(Vec<u8>, ArchiveStats)>);
197
198pub type EntryCallback = Arc<dyn Fn(&str, &[u8]) + Send + Sync>;
203
204#[derive(Default, Clone)]
231pub struct ArchiveFilter {
232 only: Vec<CompiledPattern>,
233 exclude: Vec<CompiledPattern>,
234}
235
236#[derive(Clone)]
237enum CompiledPattern {
238 DirPrefix(String),
241 Glob(glob::Pattern),
243}
244
245const GLOB_OPTS: MatchOptions = MatchOptions {
246 case_sensitive: true,
247 require_literal_separator: true,
248 require_literal_leading_dot: false,
249};
250
251impl CompiledPattern {
252 fn compile(raw: &str) -> std::result::Result<Self, String> {
253 if raw.ends_with('/') {
254 Ok(CompiledPattern::DirPrefix(
256 raw.trim_end_matches('/').to_string(),
257 ))
258 } else {
259 glob::Pattern::new(raw)
260 .map(CompiledPattern::Glob)
261 .map_err(|e| format!("invalid glob pattern '{raw}': {e}"))
262 }
263 }
264
265 fn matches(&self, path: &str) -> bool {
266 match self {
267 CompiledPattern::DirPrefix(prefix) => {
268 path == prefix || path.starts_with(&format!("{prefix}/"))
269 }
270 CompiledPattern::Glob(pat) => pat.matches_with(path, GLOB_OPTS),
271 }
272 }
273}
274
275impl ArchiveFilter {
276 pub fn new(only: Vec<String>, exclude: Vec<String>) -> std::result::Result<Self, String> {
282 let only = only
283 .into_iter()
284 .map(|p| CompiledPattern::compile(&p))
285 .collect::<std::result::Result<Vec<_>, _>>()?;
286 let exclude = exclude
287 .into_iter()
288 .map(|p| CompiledPattern::compile(&p))
289 .collect::<std::result::Result<Vec<_>, _>>()?;
290 Ok(Self { only, exclude })
291 }
292
293 pub fn is_empty(&self) -> bool {
295 self.only.is_empty() && self.exclude.is_empty()
296 }
297
298 pub fn passes(&self, path: &str) -> bool {
302 if !self.only.is_empty() && !self.only.iter().any(|p| p.matches(path)) {
303 return false;
304 }
305 if self.exclude.iter().any(|p| p.matches(path)) {
306 return false;
307 }
308 true
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316#[non_exhaustive]
317pub enum ArchiveFormat {
318 Zip,
320 Tar,
322 TarGz,
324 Gz,
326}
327
328impl ArchiveFormat {
329 pub fn from_path(path: &str) -> Option<Self> {
333 let lower = path.to_ascii_lowercase();
334 if lower.ends_with(".tar.gz")
335 || std::path::Path::new(&lower)
336 .extension()
337 .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
338 {
339 Some(Self::TarGz)
340 } else if std::path::Path::new(&lower)
341 .extension()
342 .is_some_and(|ext| ext.eq_ignore_ascii_case("tar"))
343 {
344 Some(Self::Tar)
345 } else if std::path::Path::new(&lower)
346 .extension()
347 .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
348 {
349 Some(Self::Zip)
350 } else if strip_gz_suffix(&lower).is_some() {
351 Some(Self::Gz)
352 } else {
353 None
354 }
355 }
356}
357
358#[derive(Debug, Clone, Default)]
364#[non_exhaustive]
365pub struct ArchiveStats {
366 pub files_processed: u64,
368 pub entries_skipped: u64,
370 pub structured_hits: u64,
372 pub scanner_fallback: u64,
374 pub nested_archives: u64,
377 pub total_input_bytes: u64,
379 pub total_output_bytes: u64,
381 pub file_methods: HashMap<String, String>,
384 pub file_scan_stats: HashMap<String, ScanStats>,
386 pub entries_filtered: u64,
388}
389
390#[derive(Debug, Clone, Eq, PartialEq)]
392#[non_exhaustive]
393pub struct ArchiveProgress {
394 pub entries_seen: u64,
396 pub files_processed: u64,
398 pub entries_skipped: u64,
400 pub total_entries: Option<u64>,
402 pub current_entry: String,
404}
405
406type ArchiveProgressCallback = Arc<dyn Fn(&ArchiveProgress) + Send + Sync>;
407
408impl ArchiveStats {
409 fn merge(&mut self, child: &ArchiveStats) {
411 self.files_processed += child.files_processed;
412 self.entries_skipped += child.entries_skipped;
413 self.structured_hits += child.structured_hits;
414 self.scanner_fallback += child.scanner_fallback;
415 self.nested_archives += child.nested_archives;
416 self.total_input_bytes += child.total_input_bytes;
417 self.total_output_bytes += child.total_output_bytes;
418 self.entries_filtered += child.entries_filtered;
419 self.file_methods.extend(
420 child
421 .file_methods
422 .iter()
423 .map(|(k, v)| (k.clone(), v.clone())),
424 );
425 self.file_scan_stats.extend(
426 child
427 .file_scan_stats
428 .iter()
429 .map(|(k, v)| (k.clone(), v.clone())),
430 );
431 }
432}
433
434pub struct ArchiveProcessor {
465 registry: Arc<ProcessorRegistry>,
467 scanner: Arc<StreamScanner>,
469 store: Arc<MappingStore>,
471 profiles: Vec<FileTypeProfile>,
473 max_depth: u32,
475 progress_callback: Option<ArchiveProgressCallback>,
477 parallel_threshold: usize,
480 filter: ArchiveFilter,
483 force_text: bool,
487 entry_callback: Option<EntryCallback>,
490 entropy_configs: Vec<EntropyConfig>,
496 structured_scanner: OnceLock<Arc<StreamScanner>>,
500}
501
502impl ArchiveProcessor {
503 pub fn new(
512 registry: Arc<ProcessorRegistry>,
513 scanner: Arc<StreamScanner>,
514 store: Arc<MappingStore>,
515 profiles: Vec<FileTypeProfile>,
516 ) -> Self {
517 Self {
518 registry,
519 scanner,
520 store,
521 profiles,
522 max_depth: DEFAULT_ARCHIVE_DEPTH,
523 progress_callback: None,
524 parallel_threshold: PARALLEL_ENTRY_THRESHOLD,
525 filter: ArchiveFilter::default(),
526 force_text: false,
527 entry_callback: None,
528 entropy_configs: Vec::new(),
529 structured_scanner: OnceLock::new(),
530 }
531 }
532
533 fn structured_pass_scanner(&self) -> Result<&Arc<StreamScanner>> {
539 if let Some(scanner) = self.structured_scanner.get() {
540 return Ok(scanner);
541 }
542 let built = Arc::new(self.scanner.for_structured_pass(Vec::new())?);
543 let _ = self.structured_scanner.set(built);
545 Ok(self
546 .structured_scanner
547 .get()
548 .expect("structured scanner was just set"))
549 }
550
551 #[must_use]
557 pub fn with_max_depth(mut self, depth: u32) -> Self {
558 self.max_depth = depth.min(MAX_ARCHIVE_DEPTH);
559 self
560 }
561
562 #[must_use]
567 pub fn with_parallel_threshold(mut self, threshold: usize) -> Self {
568 self.parallel_threshold = threshold;
569 self
570 }
571
572 #[must_use]
574 pub fn with_progress_callback(mut self, callback: ArchiveProgressCallback) -> Self {
575 self.progress_callback = Some(callback);
576 self
577 }
578
579 #[must_use]
585 pub fn with_filter(mut self, filter: ArchiveFilter) -> Self {
586 self.filter = filter;
587 self
588 }
589
590 #[must_use]
597 pub fn with_force_text(mut self, force_text: bool) -> Self {
598 self.force_text = force_text;
599 self
600 }
601
602 #[must_use]
605 pub fn with_entry_callback(mut self, callback: EntryCallback) -> Self {
606 self.entry_callback = Some(callback);
607 self
608 }
609
610 #[must_use]
614 pub fn with_entropy_configs(mut self, configs: Vec<EntropyConfig>) -> Self {
615 self.entropy_configs = configs;
616 self
617 }
618
619 fn apply_entropy(&self, bytes: Vec<u8>, scan_stats: &mut ScanStats) -> Vec<u8> {
622 if self.entropy_configs.is_empty() {
623 return bytes;
624 }
625 let (out, counts) = entropy_scan_bytes(&bytes, &self.entropy_configs, &self.store);
626 merge_entropy_counts(scan_stats, counts);
627 out
628 }
629
630 fn emit_entry_bytes(&self, name: &str, bytes: &[u8]) {
631 if let Some(cb) = &self.entry_callback {
632 cb(name, bytes);
633 }
634 }
635
636 fn find_profile(&self, filename: &str) -> Option<&FileTypeProfile> {
638 self.profiles.iter().find(|p| p.matches_filename(filename))
639 }
640
641 fn emit_progress(&self, stats: &ArchiveStats, total_entries: Option<u64>, current_entry: &str) {
642 if let Some(callback) = &self.progress_callback {
643 callback(&ArchiveProgress {
644 entries_seen: stats.files_processed + stats.entries_skipped,
645 files_processed: stats.files_processed,
646 entries_skipped: stats.entries_skipped,
647 total_entries,
648 current_entry: current_entry.to_string(),
649 });
650 }
651 }
652
653 fn sanitize_entry_bytes(
660 &self,
661 filename: &str,
662 data: &[u8],
663 entry_size_hint: Option<u64>,
664 depth: u32,
665 ) -> Result<(Vec<u8>, ArchiveStats)> {
666 let mut out: Vec<u8> = Vec::with_capacity(data.len());
667 let mut entry_stats = ArchiveStats::default();
668 let mut reader = io::Cursor::new(data);
669 self.sanitize_entry(
670 filename,
671 &mut reader,
672 &mut out,
673 &mut entry_stats,
674 entry_size_hint,
675 depth,
676 )?;
677 Ok((out, entry_stats))
678 }
679
680 #[allow(clippy::missing_errors_doc)] fn sanitize_entry(
692 &self,
693 filename: &str,
694 reader: &mut dyn Read,
695 writer: &mut dyn Write,
696 stats: &mut ArchiveStats,
697 entry_size_hint: Option<u64>,
698 depth: u32,
699 ) -> Result<()> {
700 if let Some(nested_fmt) = nested_archive_format(filename) {
702 return self.sanitize_nested_archive(
703 filename,
704 reader,
705 writer,
706 stats,
707 entry_size_hint,
708 nested_fmt,
709 depth,
710 );
711 }
712
713 if !self.force_text {
718 if let Some(inner_name) = strip_gz_suffix(filename) {
719 return self.sanitize_gz_entry(
720 filename,
721 inner_name,
722 reader,
723 writer,
724 stats,
725 entry_size_hint,
726 depth,
727 );
728 }
729 }
730
731 let within_size_cap = entry_size_hint.is_none_or(|sz| sz <= STRUCTURED_ENTRY_SIZE); if !self.force_text && within_size_cap {
739 if let Some(profile) = self.find_profile(filename) {
740 let mut content = Vec::new();
742 reader.read_to_end(&mut content).map_err(|e| {
743 SanitizeError::ArchiveError(format!("read entry '{filename}': {e}"))
744 })?;
745
746 stats.total_input_bytes += content.len() as u64;
747
748 let pre_snapshot = self.store.snapshot();
753 let structured_base: Option<Vec<u8>> =
758 match self
759 .registry
760 .process_to_edits(&content, profile, &self.store)
761 {
762 Ok(Some((edited, _count))) => Some(edited),
763 Ok(None) => match self.registry.process(&content, profile, &self.store) {
764 Ok(Some(_)) => Some(content.clone()),
765 _ => None,
766 },
767 Err(_) => None,
768 };
769 if let Some(base) = structured_base {
770 let extra: Vec<ScanPattern> = self
780 .store
781 .iter_since(pre_snapshot)
782 .filter(|(_, original, _)| {
783 original.len() >= crate::scanner::MIN_DISCOVERED_LITERAL_LEN
784 })
785 .filter_map(|(category, original, _)| {
786 let label = format!("field:{category}");
789 ScanPattern::from_literal(original.as_str(), category, label).ok()
790 })
791 .collect();
792 let (output, mut scan_stats) = if extra.is_empty() {
793 self.structured_pass_scanner()?.scan_bytes(&base)?
794 } else {
795 self.scanner.for_structured_pass(extra)?.scan_bytes(&base)?
796 };
797 let output = self.apply_entropy(output, &mut scan_stats);
798 stats.structured_hits += 1;
799 stats.total_output_bytes += output.len() as u64;
800 stats.file_methods.insert(
801 filename.to_string(),
802 format!("structured+scan:{}", profile.processor),
803 );
804 stats
805 .file_scan_stats
806 .insert(filename.to_string(), scan_stats);
807 writer.write_all(&output).map_err(|e| {
808 SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
809 })?;
810 return Ok(());
811 }
812
813 let (output, mut scan_stats) = self.scanner.scan_bytes(&content)?;
816 let output = self.apply_entropy(output, &mut scan_stats);
817 stats.scanner_fallback += 1;
818 stats.total_output_bytes += output.len() as u64;
819 stats
820 .file_methods
821 .insert(filename.to_string(), "scanner".to_string());
822 stats
823 .file_scan_stats
824 .insert(filename.to_string(), scan_stats);
825 writer.write_all(&output).map_err(|e| {
826 SanitizeError::ArchiveError(format!("write entry '{filename}': {e}"))
827 })?;
828 return Ok(());
829 }
830 }
831
832 if !self.entropy_configs.is_empty()
840 && entry_size_hint.is_some_and(|sz| sz <= STRUCTURED_ENTRY_SIZE)
841 {
842 return self.scan_buffered_with_entropy(filename, reader, writer, stats);
843 }
844
845 self.scan_streaming(filename, reader, writer, stats)
848 }
849
850 fn scan_streaming(
853 &self,
854 filename: &str,
855 reader: &mut dyn Read,
856 writer: &mut dyn Write,
857 stats: &mut ArchiveStats,
858 ) -> Result<()> {
859 let mut counting_r = CountingReader::new(reader);
860 let mut counting_w = CountingWriter::new(writer);
861 let scan_stats = self.scanner.scan_reader(&mut counting_r, &mut counting_w)?;
862
863 stats.scanner_fallback += 1;
864 stats.total_input_bytes += counting_r.bytes_read();
865 stats.total_output_bytes += counting_w.bytes_written();
866 stats
867 .file_methods
868 .insert(filename.to_string(), "scanner".to_string());
869 stats
870 .file_scan_stats
871 .insert(filename.to_string(), scan_stats);
872
873 Ok(())
874 }
875
876 #[allow(clippy::too_many_arguments)]
888 fn sanitize_gz_entry(
889 &self,
890 filename: &str,
891 inner_name: &str,
892 reader: &mut dyn Read,
893 writer: &mut dyn Write,
894 stats: &mut ArchiveStats,
895 entry_size_hint: Option<u64>,
896 depth: u32,
897 ) -> Result<()> {
898 let mut compressed = Vec::new();
902 let oversized_hint = entry_size_hint.is_some_and(|sz| sz > STRUCTURED_ENTRY_SIZE);
903 if !oversized_hint {
904 reader
905 .take(STRUCTURED_ENTRY_SIZE + 1)
906 .read_to_end(&mut compressed)
907 .map_err(|e| {
908 SanitizeError::ArchiveError(format!("read entry '{filename}': {e}"))
909 })?;
910 }
911 if oversized_hint || compressed.len() as u64 > STRUCTURED_ENTRY_SIZE {
912 let mut chained = io::Cursor::new(compressed).chain(reader);
913 return self.scan_streaming(filename, &mut chained, writer, stats);
914 }
915
916 let decoded = if compressed.starts_with(GZIP_MAGIC) {
920 let mut dec = flate2::read::GzDecoder::new(&compressed[..]);
921 read_bounded(&mut dec, STRUCTURED_ENTRY_SIZE, filename).ok()
922 } else {
923 None
924 };
925 let Some(decoded) = decoded else {
926 return self.scan_streaming(filename, &mut &compressed[..], writer, stats);
927 };
928
929 if depth >= self.max_depth {
930 return Err(SanitizeError::RecursionDepthExceeded(format!(
931 "compressed entry '{}' at depth {} exceeds maximum nesting depth of {}",
932 filename, depth, self.max_depth,
933 )));
934 }
935
936 stats.total_input_bytes += compressed.len() as u64;
937
938 let mut enc = flate2::write::GzEncoder::new(&mut *writer, flate2::Compression::fast());
939 let inner_len = decoded.len() as u64;
940 self.sanitize_entry(
941 inner_name,
942 &mut &decoded[..],
943 &mut enc,
944 stats,
945 Some(inner_len),
946 depth + 1,
947 )?;
948 enc.finish().map_err(|e| {
949 SanitizeError::ArchiveError(format!("recompress entry '{filename}': {e}"))
950 })?;
951
952 let inner_method = stats
954 .file_methods
955 .remove(inner_name)
956 .unwrap_or_else(|| "scanner".to_string());
957 stats
958 .file_methods
959 .insert(filename.to_string(), format!("gzip:{inner_method}"));
960 if let Some(ss) = stats.file_scan_stats.remove(inner_name) {
961 stats.file_scan_stats.insert(filename.to_string(), ss);
962 }
963 Ok(())
964 }
965
966 fn scan_buffered_with_entropy(
969 &self,
970 filename: &str,
971 reader: &mut dyn Read,
972 writer: &mut dyn Write,
973 stats: &mut ArchiveStats,
974 ) -> Result<()> {
975 let content = read_bounded(reader, STRUCTURED_ENTRY_SIZE, filename)?;
976 let (output, mut scan_stats) = self.scanner.scan_bytes(&content)?;
977 let output = self.apply_entropy(output, &mut scan_stats);
978 stats.scanner_fallback += 1;
979 stats.total_input_bytes += content.len() as u64;
980 stats.total_output_bytes += output.len() as u64;
981 stats
982 .file_methods
983 .insert(filename.to_string(), "scanner".to_string());
984 stats
985 .file_scan_stats
986 .insert(filename.to_string(), scan_stats);
987 writer
988 .write_all(&output)
989 .map_err(|e| SanitizeError::ArchiveError(format!("write entry '{filename}': {e}")))
990 }
991
992 #[allow(clippy::too_many_arguments)]
995 fn sanitize_nested_archive(
996 &self,
997 filename: &str,
998 reader: &mut dyn Read,
999 writer: &mut dyn Write,
1000 stats: &mut ArchiveStats,
1001 entry_size_hint: Option<u64>,
1002 nested_fmt: ArchiveFormat,
1003 depth: u32,
1004 ) -> Result<()> {
1005 if depth >= self.max_depth {
1006 return Err(SanitizeError::RecursionDepthExceeded(format!(
1007 "nested archive '{}' at depth {} exceeds maximum nesting depth of {}",
1008 filename, depth, self.max_depth,
1009 )));
1010 }
1011
1012 if let Some(sz) = entry_size_hint {
1017 if sz > STRUCTURED_ENTRY_SIZE {
1018 return Err(SanitizeError::ArchiveError(format!(
1019 "nested archive '{}' is too large ({} bytes, limit {} bytes)",
1020 filename, sz, STRUCTURED_ENTRY_SIZE,
1021 )));
1022 }
1023 }
1024
1025 let content = read_bounded(reader, STRUCTURED_ENTRY_SIZE, filename)?;
1026 stats.total_input_bytes += content.len() as u64;
1027
1028 let mut output_buf: Vec<u8> = Vec::new();
1030 let child_stats = match nested_fmt {
1031 ArchiveFormat::Tar => {
1032 self.process_tar_at_depth(&content[..], &mut output_buf, depth + 1)?
1033 }
1034 ArchiveFormat::TarGz => {
1035 self.process_tar_gz_at_depth(&content[..], &mut output_buf, depth + 1)?
1036 }
1037 ArchiveFormat::Zip => {
1038 let reader = io::Cursor::new(&content);
1039 let mut writer = io::Cursor::new(Vec::new());
1040 let s = self.process_zip_at_depth(reader, &mut writer, depth + 1)?;
1041 output_buf = writer.into_inner();
1042 s
1043 }
1044 ArchiveFormat::Gz => unreachable!(
1045 "single-file gzip entries are routed to sanitize_gz_entry by sanitize_entry"
1046 ),
1047 };
1048
1049 stats.nested_archives += 1;
1050 stats.merge(&child_stats);
1051 stats.total_output_bytes += output_buf.len() as u64;
1052 let fmt_name = match nested_fmt {
1053 ArchiveFormat::Tar => "tar",
1054 ArchiveFormat::TarGz => "tar.gz",
1055 ArchiveFormat::Zip => "zip",
1056 ArchiveFormat::Gz => "gz",
1057 };
1058 stats
1059 .file_methods
1060 .insert(filename.to_string(), format!("nested:{fmt_name}"));
1061 writer.write_all(&output_buf).map_err(|e| {
1062 SanitizeError::ArchiveError(format!("write nested archive '{filename}': {e}"))
1063 })?;
1064 Ok(())
1065 }
1066
1067 pub fn discover_profiles_tar<R: Read>(&self, reader: R) -> Result<()> {
1086 if self.profiles.is_empty() {
1087 return Ok(());
1088 }
1089 let mut archive = tar::Archive::new(reader);
1090 let entries = archive
1091 .entries()
1092 .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entries: {e}")))?;
1093 for entry_result in entries {
1094 let mut entry = entry_result
1095 .map_err(|e| SanitizeError::ArchiveError(format!("discover tar entry: {e}")))?;
1096 if !entry.header().entry_type().is_file() {
1097 continue;
1098 }
1099 let path = entry
1100 .path()
1101 .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1102 .to_string_lossy()
1103 .to_string();
1104 let Some(profile) = self.find_profile(&path) else {
1105 continue;
1106 };
1107 let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &path) {
1108 Ok(c) => c,
1109 Err(e) => {
1110 tracing::warn!(path = %path, error = %e, "discovery: skipping oversized entry");
1111 continue;
1112 }
1113 };
1114 if let Err(e) = self.registry.process(&content, profile, &self.store) {
1115 tracing::warn!(path = %path, error = %e, "discovery: structured processor failed; partial mappings may persist");
1116 }
1117 }
1118 Ok(())
1119 }
1120
1121 pub fn discover_profiles_tar_gz<R: Read>(&self, reader: R) -> Result<()> {
1129 let gz = flate2::read::GzDecoder::new(reader);
1130 self.discover_profiles_tar(gz)
1131 }
1132
1133 pub fn discover_profiles_zip<R: Read + Seek>(&self, reader: R) -> Result<()> {
1141 if self.profiles.is_empty() {
1142 return Ok(());
1143 }
1144 let mut zip = zip::ZipArchive::new(reader)
1145 .map_err(|e| SanitizeError::ArchiveError(format!("open zip for discovery: {e}")))?;
1146 for i in 0..zip.len() {
1147 let mut entry = zip
1148 .by_index(i)
1149 .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {i}: {e}")))?;
1150 if entry.is_dir() {
1151 continue;
1152 }
1153 let name = sanitize_zip_entry_name(entry.name());
1154 let Some(profile) = self.find_profile(&name) else {
1155 continue;
1156 };
1157 let content = match read_bounded(&mut entry, STRUCTURED_ENTRY_SIZE, &name) {
1158 Ok(c) => c,
1159 Err(e) => {
1160 tracing::warn!(name = %name, error = %e, "discovery: skipping oversized entry");
1161 continue;
1162 }
1163 };
1164 if let Err(e) = self.registry.process(&content, profile, &self.store) {
1165 tracing::warn!(name = %name, error = %e, "discovery: structured processor failed; partial mappings may persist");
1166 }
1167 }
1168 Ok(())
1169 }
1170
1171 pub fn discover_profiles_gz<R: Read>(&self, name: &str, reader: R) -> Result<()> {
1184 if self.profiles.is_empty() {
1185 return Ok(());
1186 }
1187 let Some(inner_name) = strip_gz_suffix(name) else {
1188 return Ok(());
1189 };
1190 let Some(profile) = self.find_profile(inner_name) else {
1191 return Ok(());
1192 };
1193 let mut dec = flate2::read::GzDecoder::new(reader);
1194 let content = match read_bounded(&mut dec, STRUCTURED_ENTRY_SIZE, name) {
1195 Ok(c) => c,
1196 Err(e) => {
1197 tracing::warn!(name = %name, error = %e, "discovery: skipping undecodable or oversized gzip file");
1198 return Ok(());
1199 }
1200 };
1201 if let Err(e) = self.registry.process(&content, profile, &self.store) {
1202 tracing::warn!(name = %name, error = %e, "discovery: structured processor failed; partial mappings may persist");
1203 }
1204 Ok(())
1205 }
1206
1207 pub fn process_gz<R: Read, W: Write>(
1225 &self,
1226 name: &str,
1227 mut reader: R,
1228 mut writer: W,
1229 ) -> Result<ArchiveStats> {
1230 let mut stats = ArchiveStats::default();
1231 match strip_gz_suffix(name) {
1232 Some(inner_name) if !self.force_text => {
1233 self.sanitize_gz_entry(
1234 name,
1235 inner_name,
1236 &mut reader,
1237 &mut writer,
1238 &mut stats,
1239 None,
1240 0,
1241 )?;
1242 }
1243 _ => {
1244 self.scan_streaming(name, &mut reader, &mut writer, &mut stats)?;
1245 }
1246 }
1247 stats.files_processed += 1;
1248 Ok(stats)
1249 }
1250
1251 pub fn process_tar<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
1265 self.process_tar_at_depth(reader, writer, 0)
1266 }
1267
1268 #[allow(clippy::too_many_lines)]
1286 fn process_tar_at_depth<R: Read, W: Write>(
1287 &self,
1288 reader: R,
1289 writer: W,
1290 depth: u32,
1291 ) -> Result<ArchiveStats> {
1292 struct TarEntry {
1293 header: tar::Header,
1294 path: String,
1295 link_name: Option<PathBuf>,
1296 is_file: bool,
1297 passes_filter: bool,
1298 data: Vec<u8>,
1299 }
1300
1301 let mut archive = tar::Archive::new(reader);
1302 let mut builder = tar::Builder::new(writer);
1303 let mut stats = ArchiveStats::default();
1304
1305 let mut entries_iter = archive
1309 .entries()
1310 .map_err(|e| SanitizeError::ArchiveError(format!("read tar entries: {e}")))?;
1311
1312 let mut buffered: Vec<TarEntry> = Vec::new();
1313 let mut file_count: usize = 0;
1314 let mut total_data: u64 = 0;
1315 let mut overflowed = false;
1316
1317 for entry_result in entries_iter.by_ref() {
1318 let mut entry = entry_result
1319 .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
1320
1321 let header = entry.header().clone();
1322 let path = entry
1323 .path()
1324 .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1325 .to_string_lossy()
1326 .into_owned();
1327 let link_name = entry.link_name().ok().flatten().map(|c| c.into_owned());
1328 let is_file = header.entry_type().is_file();
1329 let passes_filter = !is_file || self.filter.passes(&path);
1330
1331 let mut data = Vec::new();
1332 entry
1333 .read_to_end(&mut data)
1334 .map_err(|e| SanitizeError::ArchiveError(format!("read entry '{path}': {e}")))?;
1335 drop(entry);
1336
1337 if is_file && passes_filter {
1338 file_count += 1;
1339 total_data = total_data.saturating_add(data.len() as u64);
1340 }
1341
1342 buffered.push(TarEntry {
1343 header,
1344 path,
1345 link_name,
1346 is_file,
1347 passes_filter,
1348 data,
1349 });
1350
1351 if total_data > PARALLEL_TAR_DATA_SIZE {
1352 overflowed = true;
1353 break;
1354 }
1355 }
1356
1357 let use_parallel = !overflowed
1359 && file_count >= self.parallel_threshold
1360 && rayon::current_thread_index().is_none();
1361
1362 if use_parallel {
1363 let file_indices: Vec<usize> = buffered
1366 .iter()
1367 .enumerate()
1368 .filter(|(_, e)| e.is_file && e.passes_filter)
1369 .map(|(i, _)| i)
1370 .collect();
1371
1372 let results: Vec<ParEntryResult> = file_indices
1373 .into_par_iter()
1374 .map(|i| {
1375 let e = &buffered[i];
1376 let size_hint = e.header.size().ok();
1377 (
1378 i,
1379 self.sanitize_entry_bytes(&e.path, &e.data, size_hint, depth),
1380 )
1381 })
1382 .collect();
1383
1384 let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; buffered.len()];
1385 for (i, r) in results {
1386 sanitized[i] = Some(r?);
1387 }
1388
1389 for (i, entry) in buffered.iter().enumerate() {
1390 if !entry.is_file {
1391 append_tar_entry(
1392 &mut builder,
1393 entry.header.clone(),
1394 &entry.path,
1395 entry.link_name.as_deref(),
1396 &entry.data,
1397 )?;
1398 stats.entries_skipped += 1;
1399 self.emit_progress(&stats, None, &entry.path);
1400 continue;
1401 }
1402 if !entry.passes_filter {
1403 stats.entries_filtered += 1;
1404 self.emit_progress(&stats, None, &entry.path);
1405 continue;
1406 }
1407
1408 let (sanitized_buf, entry_stats) =
1409 sanitized[i].take().expect("parallel result missing");
1410 stats.merge(&entry_stats);
1411 self.emit_entry_bytes(&entry.path, &sanitized_buf);
1412
1413 append_tar_entry(
1414 &mut builder,
1415 entry.header.clone(),
1416 &entry.path,
1417 None,
1418 &sanitized_buf,
1419 )?;
1420 stats.files_processed += 1;
1421 self.emit_progress(&stats, None, &entry.path);
1422 }
1423 } else {
1424 let write_buffered = |entry: &TarEntry,
1429 builder: &mut tar::Builder<W>,
1430 stats: &mut ArchiveStats,
1431 processor: &ArchiveProcessor|
1432 -> Result<()> {
1433 if !entry.is_file {
1434 append_tar_entry(
1435 builder,
1436 entry.header.clone(),
1437 &entry.path,
1438 entry.link_name.as_deref(),
1439 &entry.data,
1440 )?;
1441 stats.entries_skipped += 1;
1442 processor.emit_progress(stats, None, &entry.path);
1443 return Ok(());
1444 }
1445 if !entry.passes_filter {
1446 stats.entries_filtered += 1;
1447 processor.emit_progress(stats, None, &entry.path);
1448 return Ok(());
1449 }
1450 let size_hint = entry.header.size().ok();
1451 let (sanitized_buf, entry_stats) =
1452 processor.sanitize_entry_bytes(&entry.path, &entry.data, size_hint, depth)?;
1453 stats.merge(&entry_stats);
1454 processor.emit_entry_bytes(&entry.path, &sanitized_buf);
1455 append_tar_entry(
1456 builder,
1457 entry.header.clone(),
1458 &entry.path,
1459 None,
1460 &sanitized_buf,
1461 )?;
1462 stats.files_processed += 1;
1463 processor.emit_progress(stats, None, &entry.path);
1464 Ok(())
1465 };
1466
1467 for entry in &buffered {
1468 write_buffered(entry, &mut builder, &mut stats, self)?;
1469 }
1470 drop(buffered);
1471
1472 if overflowed {
1474 for entry_result in entries_iter {
1475 let mut entry = entry_result
1476 .map_err(|e| SanitizeError::ArchiveError(format!("read tar entry: {e}")))?;
1477
1478 let header = entry.header().clone();
1479 let path = entry
1480 .path()
1481 .map_err(|e| SanitizeError::ArchiveError(format!("entry path: {e}")))?
1482 .to_string_lossy()
1483 .into_owned();
1484 let is_file = header.entry_type().is_file();
1485
1486 if !is_file {
1487 let link_name = entry.link_name().ok().flatten().map(|c| c.into_owned());
1488 let mut data = Vec::new();
1489 entry.read_to_end(&mut data).map_err(|e| {
1490 SanitizeError::ArchiveError(format!("read '{path}': {e}"))
1491 })?;
1492 drop(entry);
1493 append_tar_entry(&mut builder, header, &path, link_name.as_deref(), &data)?;
1494 stats.entries_skipped += 1;
1495 self.emit_progress(&stats, None, &path);
1496 continue;
1497 }
1498
1499 if !self.filter.passes(&path) {
1500 stats.entries_filtered += 1;
1501 continue;
1502 }
1503
1504 let size_hint = header.size().ok();
1505 let mut sanitized_buf = Vec::new();
1506 let mut entry_stats = ArchiveStats::default();
1507 self.sanitize_entry(
1508 &path,
1509 &mut entry,
1510 &mut sanitized_buf,
1511 &mut entry_stats,
1512 size_hint,
1513 depth,
1514 )?;
1515 drop(entry);
1516 self.emit_entry_bytes(&path, &sanitized_buf);
1517
1518 append_tar_entry(&mut builder, header, &path, None, &sanitized_buf)?;
1519
1520 stats.merge(&entry_stats);
1521 stats.files_processed += 1;
1522 self.emit_progress(&stats, None, &path);
1523 }
1524 }
1525 }
1526
1527 builder
1528 .finish()
1529 .map_err(|e| SanitizeError::ArchiveError(format!("finalize tar: {e}")))?;
1530
1531 Ok(stats)
1532 }
1533
1534 pub fn process_tar_gz<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ArchiveStats> {
1544 self.process_tar_gz_at_depth(reader, writer, 0)
1545 }
1546
1547 fn process_tar_gz_at_depth<R: Read, W: Write>(
1549 &self,
1550 reader: R,
1551 writer: W,
1552 depth: u32,
1553 ) -> Result<ArchiveStats> {
1554 let gz_reader = flate2::read::GzDecoder::new(reader);
1555 let gz_writer = flate2::write::GzEncoder::new(writer, flate2::Compression::fast());
1556
1557 let stats = self.process_tar_at_depth(gz_reader, gz_writer, depth)?;
1558 Ok(stats)
1564 }
1565
1566 pub fn process_zip<R: Read + Seek, W: Write + Seek>(
1582 &self,
1583 reader: R,
1584 writer: W,
1585 ) -> Result<ArchiveStats> {
1586 self.process_zip_at_depth(reader, writer, 0)
1587 }
1588
1589 #[allow(clippy::too_many_lines)]
1600 fn process_zip_at_depth<R: Read + Seek, W: Write + Seek>(
1601 &self,
1602 reader: R,
1603 writer: W,
1604 depth: u32,
1605 ) -> Result<ArchiveStats> {
1606 struct ZipMeta {
1610 name: String,
1611 is_dir: bool,
1612 compression: zip::CompressionMethod,
1613 last_modified: Option<zip::DateTime>,
1614 unix_mode: Option<u32>,
1615 size: u64,
1616 }
1617
1618 let mut zip_in = zip::ZipArchive::new(reader)
1619 .map_err(|e| SanitizeError::ArchiveError(format!("open zip: {}", e)))?;
1620 let total_entries = zip_in.len();
1621 let total_entries_hint = Some(total_entries as u64);
1622
1623 let mut metas: Vec<ZipMeta> = Vec::with_capacity(total_entries);
1624 let mut file_count = 0usize;
1625 let mut total_uncompressed_size: u64 = 0;
1626
1627 for i in 0..total_entries {
1628 let entry = zip_in
1629 .by_index(i)
1630 .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1631 let is_dir = entry.is_dir();
1632 let size = entry.size();
1633 if !is_dir {
1634 file_count += 1;
1635 total_uncompressed_size = total_uncompressed_size.saturating_add(size);
1636 }
1637 metas.push(ZipMeta {
1638 name: sanitize_zip_entry_name(entry.name()),
1639 is_dir,
1640 compression: entry.compression(),
1641 last_modified: entry.last_modified(),
1642 unix_mode: entry.unix_mode(),
1643 size,
1644 });
1645 }
1647
1648 let use_parallel = file_count >= self.parallel_threshold
1653 && rayon::current_thread_index().is_none()
1654 && total_uncompressed_size <= PARALLEL_ZIP_DATA_SIZE;
1655
1656 let mut stats = ArchiveStats::default();
1657
1658 let make_options = |m: &ZipMeta| {
1660 let mut opts =
1661 zip::write::SimpleFileOptions::default().compression_method(m.compression);
1662 if let Some(dt) = m.last_modified {
1663 opts = opts.last_modified_time(dt);
1664 }
1665 if let Some(mode) = m.unix_mode {
1666 opts.unix_permissions(mode)
1667 } else {
1668 opts
1669 }
1670 };
1671
1672 if use_parallel {
1673 struct ZipEntry {
1675 meta_idx: usize,
1676 data: Vec<u8>,
1677 }
1678
1679 let mut file_entries: Vec<ZipEntry> = Vec::with_capacity(file_count);
1680
1681 for (i, meta) in metas.iter().enumerate() {
1682 if meta.is_dir {
1683 continue;
1684 }
1685 if !self.filter.passes(&meta.name) {
1687 continue;
1688 }
1689 let mut entry = zip_in
1690 .by_index(i)
1691 .map_err(|e| SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e)))?;
1692 let mut data = Vec::new();
1693 entry.read_to_end(&mut data).map_err(|e| {
1694 SanitizeError::ArchiveError(format!("read zip entry '{}': {}", meta.name, e))
1695 })?;
1696 file_entries.push(ZipEntry { meta_idx: i, data });
1697 }
1698
1699 let results: Vec<ParEntryResult> = file_entries
1700 .into_par_iter()
1701 .map(|e| {
1702 let meta = &metas[e.meta_idx];
1703 let result =
1704 self.sanitize_entry_bytes(&meta.name, &e.data, Some(meta.size), depth);
1705 (e.meta_idx, result)
1706 })
1707 .collect();
1708
1709 let mut sanitized: Vec<Option<(Vec<u8>, ArchiveStats)>> = vec![None; metas.len()];
1712 for (meta_idx, r) in results {
1713 sanitized[meta_idx] = Some(r?);
1714 }
1715
1716 let mut zip_out = zip::ZipWriter::new(writer);
1717 for (i, meta) in metas.iter().enumerate() {
1718 let options = make_options(meta);
1719 if meta.is_dir {
1720 zip_out.add_directory(&meta.name, options).map_err(|e| {
1721 SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1722 })?;
1723 stats.entries_skipped += 1;
1724 self.emit_progress(&stats, total_entries_hint, &meta.name);
1725 continue;
1726 }
1727 if !self.filter.passes(&meta.name) {
1729 stats.entries_filtered += 1;
1730 self.emit_progress(&stats, total_entries_hint, &meta.name);
1731 continue;
1732 }
1733 let (sanitized_buf, entry_stats) = sanitized[i]
1734 .take()
1735 .expect("file entry sanitization result missing");
1736 stats.merge(&entry_stats);
1737 self.emit_entry_bytes(&meta.name, &sanitized_buf);
1738 zip_out.start_file(&meta.name, options).map_err(|e| {
1739 SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1740 })?;
1741 zip_out.write_all(&sanitized_buf).map_err(|e| {
1742 SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1743 })?;
1744 stats.files_processed += 1;
1745 self.emit_progress(&stats, total_entries_hint, &meta.name);
1746 }
1747 zip_out
1748 .finish()
1749 .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1750 } else {
1751 let mut zip_out = zip::ZipWriter::new(writer);
1754 for (i, meta) in metas.iter().enumerate() {
1755 let options = make_options(meta);
1756 if meta.is_dir {
1757 zip_out.add_directory(&meta.name, options).map_err(|e| {
1758 SanitizeError::ArchiveError(format!("add dir '{}': {}", meta.name, e))
1759 })?;
1760 stats.entries_skipped += 1;
1761 self.emit_progress(&stats, total_entries_hint, &meta.name);
1762 continue;
1763 }
1764
1765 if !self.filter.passes(&meta.name) {
1767 stats.entries_filtered += 1;
1768 self.emit_progress(&stats, total_entries_hint, &meta.name);
1769 continue;
1770 }
1771
1772 let data = {
1773 let mut entry = zip_in.by_index(i).map_err(|e| {
1774 SanitizeError::ArchiveError(format!("zip entry {}: {}", i, e))
1775 })?;
1776 let mut buf = Vec::new();
1777 entry.read_to_end(&mut buf).map_err(|e| {
1778 SanitizeError::ArchiveError(format!(
1779 "read zip entry '{}': {}",
1780 meta.name, e
1781 ))
1782 })?;
1783 buf
1784 };
1786
1787 let (sanitized_buf, entry_stats) =
1788 self.sanitize_entry_bytes(&meta.name, &data, Some(meta.size), depth)?;
1789 drop(data);
1790 self.emit_entry_bytes(&meta.name, &sanitized_buf);
1791
1792 zip_out.start_file(&meta.name, options).map_err(|e| {
1793 SanitizeError::ArchiveError(format!("start file '{}': {}", meta.name, e))
1794 })?;
1795 zip_out.write_all(&sanitized_buf).map_err(|e| {
1796 SanitizeError::ArchiveError(format!("write file '{}': {}", meta.name, e))
1797 })?;
1798 drop(sanitized_buf);
1799
1800 stats.merge(&entry_stats);
1801 stats.files_processed += 1;
1802 self.emit_progress(&stats, total_entries_hint, &meta.name);
1803 }
1804 zip_out
1805 .finish()
1806 .map_err(|e| SanitizeError::ArchiveError(format!("finalize zip: {}", e)))?;
1807 }
1808
1809 Ok(stats)
1810 }
1811
1812 pub fn process<R: Read + Seek, W: Write + Seek>(
1828 &self,
1829 reader: R,
1830 writer: W,
1831 format: ArchiveFormat,
1832 ) -> Result<ArchiveStats> {
1833 match format {
1834 ArchiveFormat::Zip => self.process_zip(reader, writer),
1835 ArchiveFormat::Tar => self.process_tar(reader, writer),
1836 ArchiveFormat::TarGz => self.process_tar_gz(reader, writer),
1837 ArchiveFormat::Gz => self.process_gz("input.gz", reader, writer),
1841 }
1842 }
1843}
1844
1845struct CountingReader<'a> {
1851 inner: &'a mut dyn Read,
1852 count: u64,
1853}
1854
1855impl<'a> CountingReader<'a> {
1856 fn new(inner: &'a mut dyn Read) -> Self {
1857 Self { inner, count: 0 }
1858 }
1859
1860 fn bytes_read(&self) -> u64 {
1861 self.count
1862 }
1863}
1864
1865impl Read for CountingReader<'_> {
1866 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1867 let n = self.inner.read(buf)?;
1868 self.count += n as u64;
1869 Ok(n)
1870 }
1871}
1872
1873struct CountingWriter<'a> {
1875 inner: &'a mut dyn Write,
1876 count: u64,
1877}
1878
1879impl<'a> CountingWriter<'a> {
1880 fn new(inner: &'a mut dyn Write) -> Self {
1881 Self { inner, count: 0 }
1882 }
1883
1884 fn bytes_written(&self) -> u64 {
1885 self.count
1886 }
1887}
1888
1889impl Write for CountingWriter<'_> {
1890 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1891 let n = self.inner.write(buf)?;
1892 self.count += n as u64;
1893 Ok(n)
1894 }
1895
1896 fn flush(&mut self) -> io::Result<()> {
1897 self.inner.flush()
1898 }
1899}
1900
1901#[cfg(test)]
1906mod tests {
1907 use super::*;
1908 use crate::category::Category;
1909 use crate::generator::HmacGenerator;
1910 use crate::processor::profile::{FieldRule, FileTypeProfile};
1911 use crate::processor::registry::ProcessorRegistry;
1912 use crate::scanner::{ScanConfig, ScanPattern};
1913 use std::io::Cursor;
1914 use std::sync::Mutex;
1915
1916 fn make_archive_processor() -> ArchiveProcessor {
1918 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1919 let store = Arc::new(MappingStore::new(gen, None));
1920
1921 let patterns = vec![
1922 ScanPattern::from_regex(
1923 r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
1924 Category::Email,
1925 "email",
1926 )
1927 .unwrap(),
1928 ScanPattern::from_literal("SUPERSECRET", Category::Custom("api_key".into()), "api_key")
1929 .unwrap(),
1930 ];
1931
1932 let scanner = Arc::new(
1933 StreamScanner::new(patterns, Arc::clone(&store), ScanConfig::default()).unwrap(),
1934 );
1935
1936 let registry = Arc::new(ProcessorRegistry::with_builtins());
1937
1938 let profiles = vec![FileTypeProfile::new(
1939 "json",
1940 vec![FieldRule::new("*").with_category(Category::Custom("field".into()))],
1941 )
1942 .with_extension(".json")];
1943
1944 ArchiveProcessor::new(registry, scanner, store, profiles)
1945 }
1946
1947 #[test]
1950 fn command_output_discovery_scrubs_bare_occurrences_in_other_entries() {
1951 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
1957 let store = Arc::new(MappingStore::new(gen, None));
1958 let base_scanner = Arc::new(
1959 StreamScanner::new(vec![], Arc::clone(&store), ScanConfig::default()).unwrap(),
1960 );
1961 let registry = Arc::new(ProcessorRegistry::with_builtins());
1962 let profiles = vec![FileTypeProfile::new(
1963 "command_output",
1964 vec![FieldRule::new("hostname*")
1965 .with_category(Category::Hostname)
1966 .with_min_length(2)],
1967 )
1968 .with_extension(".txt")
1969 .with_include("diag.txt")];
1970
1971 let input = build_test_tar(&[
1972 (
1973 "diag.txt",
1974 b"> hostname --fqdn\ndss-prod-01.corp.example.com\n" as &[u8],
1975 ),
1976 (
1977 "backend.log",
1978 b"connecting to dss-prod-01.corp.example.com:10001 failed\n",
1979 ),
1980 ]);
1981
1982 let discovery = ArchiveProcessor::new(
1984 Arc::clone(®istry),
1985 Arc::clone(&base_scanner),
1986 Arc::clone(&store),
1987 profiles.clone(),
1988 );
1989 discovery.discover_profiles_tar(&input[..]).unwrap();
1990
1991 let extra: Vec<ScanPattern> = store
1993 .iter()
1994 .map(|(category, original, _)| {
1995 ScanPattern::from_literal(original.as_str(), category, "discovered").unwrap()
1996 })
1997 .collect();
1998 assert!(!extra.is_empty(), "discovery must have found the hostname");
1999 let augmented = Arc::new(base_scanner.with_extra_literals(extra).unwrap());
2000 let proc = ArchiveProcessor::new(registry, augmented, store, profiles);
2001
2002 let mut output = Vec::new();
2003 proc.process_tar(&input[..], &mut output).unwrap();
2004 let out = String::from_utf8_lossy(&output);
2005
2006 assert!(
2007 !out.contains("dss-prod-01.corp.example.com"),
2008 "hostname must be gone from every entry: {out}"
2009 );
2010 assert!(
2011 out.contains("connecting to ") && out.contains(":10001 failed"),
2012 "log context preserved: {out}"
2013 );
2014 }
2015
2016 #[test]
2019 fn entropy_configs_apply_to_archive_entries() {
2020 let shared_secret = "FLwnxzdPdfIwcrav7PpjWEoYEc55HAcl2Aad0w8rfSsUvYoJHOlqlngm5UzH1zKJ";
2025 let input = build_test_tar(&[
2026 ("run/shared-secret.txt", shared_secret.as_bytes()),
2027 ("notes.txt", b"plain low entropy words only"),
2028 ]);
2029
2030 let proc = make_archive_processor().with_entropy_configs(vec![crate::EntropyConfig {
2031 min_length: 40,
2032 charset: crate::EntropyCharset::Base64,
2033 ..Default::default()
2034 }]);
2035
2036 let mut output = Vec::new();
2037 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2038
2039 let out = String::from_utf8_lossy(&output);
2040 assert!(
2041 !out.contains(shared_secret),
2042 "high-entropy entry content must be replaced"
2043 );
2044 assert!(
2045 out.contains("plain low entropy words only"),
2046 "low-entropy entry must pass through"
2047 );
2048 let entry_stats = &stats.file_scan_stats["run/shared-secret.txt"];
2049 assert_eq!(entry_stats.pattern_counts["high_entropy_token"], 1);
2050 }
2051
2052 fn build_test_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
2055 let mut buf = Vec::new();
2056 {
2057 let mut builder = tar::Builder::new(&mut buf);
2058 for (name, data) in entries {
2059 let mut header = tar::Header::new_gnu();
2060 header.set_size(data.len() as u64);
2061 header.set_mode(0o644);
2062 header.set_mtime(1_700_000_000);
2063 header.set_cksum();
2064 builder.append_data(&mut header, *name, *data).unwrap();
2065 }
2066 builder.finish().unwrap();
2067 }
2068 buf
2069 }
2070
2071 #[test]
2072 fn tar_sanitizes_plaintext_with_scanner() {
2073 let proc = make_archive_processor();
2074 let input = build_test_tar(&[("readme.txt", b"Contact alice@corp.com for help.")]);
2075
2076 let mut output = Vec::new();
2077 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2078
2079 assert_eq!(stats.files_processed, 1);
2080 assert_eq!(stats.scanner_fallback, 1);
2081 assert_eq!(stats.structured_hits, 0);
2082
2083 let mut archive = tar::Archive::new(&output[..]);
2085 for entry in archive.entries().unwrap() {
2086 let mut e = entry.unwrap();
2087 let mut content = String::new();
2088 e.read_to_string(&mut content).unwrap();
2089 assert!(
2090 !content.contains("alice@corp.com"),
2091 "email should be sanitized: {content}"
2092 );
2093 }
2094 }
2095
2096 #[test]
2097 fn tar_sanitizes_json_with_structured_processor() {
2098 let proc = make_archive_processor();
2099 let json_content = br#"{"email": "bob@example.org", "name": "Bob"}"#;
2100 let input = build_test_tar(&[("config.json", json_content)]);
2101
2102 let mut output = Vec::new();
2103 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2104
2105 assert_eq!(stats.files_processed, 1);
2106 assert_eq!(stats.structured_hits, 1);
2107 assert_eq!(stats.scanner_fallback, 0);
2108 assert_eq!(
2109 stats.file_methods.get("config.json").unwrap(),
2110 "structured+scan:json"
2111 );
2112
2113 let mut archive = tar::Archive::new(&output[..]);
2115 for entry in archive.entries().unwrap() {
2116 let mut e = entry.unwrap();
2117 let mut content = String::new();
2118 e.read_to_string(&mut content).unwrap();
2119 assert!(
2120 !content.contains("bob@example.org"),
2121 "email should be sanitized"
2122 );
2123 assert!(!content.contains("Bob"), "name should be sanitized");
2124 }
2125 }
2126
2127 fn gzip_bytes(data: &[u8]) -> Vec<u8> {
2130 let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2131 enc.write_all(data).unwrap();
2132 enc.finish().unwrap()
2133 }
2134
2135 fn read_tar_entry(output: &[u8], name: &str) -> Vec<u8> {
2136 let mut archive = tar::Archive::new(output);
2137 for entry in archive.entries().unwrap() {
2138 let mut e = entry.unwrap();
2139 if e.path().unwrap().to_string_lossy() == name {
2140 let mut buf = Vec::new();
2141 e.read_to_end(&mut buf).unwrap();
2142 return buf;
2143 }
2144 }
2145 panic!("entry '{name}' not found in output tar");
2146 }
2147
2148 #[test]
2149 fn gz_entry_is_decompressed_scanned_and_recompressed() {
2150 let proc = make_archive_processor();
2155 let inner = b"job start\ntoken=SUPERSECRET\njob end\n";
2156 let input = build_test_tar(&[("logs/db-migrate.log.1.gz", &gzip_bytes(inner)[..])]);
2157
2158 let mut output = Vec::new();
2159 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2160
2161 let entry = read_tar_entry(&output, "logs/db-migrate.log.1.gz");
2162 let mut dec = flate2::read::GzDecoder::new(&entry[..]);
2163 let mut text = String::new();
2164 dec.read_to_string(&mut text)
2165 .expect("output entry must still be valid gzip");
2166 assert!(
2167 !text.contains("SUPERSECRET"),
2168 "secret must be redacted inside the gzip: {text}"
2169 );
2170 assert!(
2171 text.contains("job start") && text.contains("job end"),
2172 "surrounding context preserved: {text}"
2173 );
2174 assert_eq!(
2175 stats.file_methods.get("logs/db-migrate.log.1.gz").unwrap(),
2176 "gzip:scanner"
2177 );
2178 }
2179
2180 #[test]
2181 fn gz_entry_with_structured_inner_name_uses_processor() {
2182 let proc = make_archive_processor();
2185 let inner = br#"{"email": "bob@example.org"}"#;
2186 let input = build_test_tar(&[("config.json.gz", &gzip_bytes(inner)[..])]);
2187
2188 let mut output = Vec::new();
2189 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2190
2191 let entry = read_tar_entry(&output, "config.json.gz");
2192 let mut dec = flate2::read::GzDecoder::new(&entry[..]);
2193 let mut text = String::new();
2194 dec.read_to_string(&mut text).unwrap();
2195 assert!(!text.contains("bob@example.org"), "field redacted: {text}");
2196 assert_eq!(
2197 stats.file_methods.get("config.json.gz").unwrap(),
2198 "gzip:structured+scan:json"
2199 );
2200 }
2201
2202 #[test]
2203 fn gz_named_entry_that_is_not_gzip_falls_back_to_scanner() {
2204 let proc = make_archive_processor();
2207 let input = build_test_tar(&[("fake.log.gz", b"call alice@corp.com now" as &[u8])]);
2208
2209 let mut output = Vec::new();
2210 proc.process_tar(&input[..], &mut output).unwrap();
2211
2212 let entry = read_tar_entry(&output, "fake.log.gz");
2213 let text = String::from_utf8_lossy(&entry);
2214 assert!(
2215 !text.contains("alice@corp.com"),
2216 "fallback scan must redact the raw bytes: {text}"
2217 );
2218 }
2219
2220 #[test]
2221 fn standalone_gz_file_gets_structured_treatment() {
2222 let proc = make_archive_processor();
2226 let inner = br#"{"email": "bob@example.org"}"#;
2227 let input = gzip_bytes(inner);
2228
2229 let mut output = Vec::new();
2230 let stats = proc
2231 .process_gz("config.json.gz", &input[..], &mut output)
2232 .unwrap();
2233
2234 let mut dec = flate2::read::GzDecoder::new(&output[..]);
2235 let mut text = String::new();
2236 dec.read_to_string(&mut text)
2237 .expect("output must still be valid gzip");
2238 assert!(!text.contains("bob@example.org"), "field redacted: {text}");
2239 assert_eq!(
2240 stats.file_methods.get("config.json.gz").unwrap(),
2241 "gzip:structured+scan:json"
2242 );
2243 assert_eq!(stats.files_processed, 1);
2244 }
2245
2246 #[test]
2247 fn standalone_gz_that_is_not_gzip_falls_back_to_scanner() {
2248 let proc = make_archive_processor();
2251 let mut output = Vec::new();
2252 proc.process_gz(
2253 "fake.log.gz",
2254 b"call alice@corp.com now" as &[u8],
2255 &mut output,
2256 )
2257 .unwrap();
2258
2259 let text = String::from_utf8_lossy(&output);
2260 assert!(
2261 !text.contains("alice@corp.com"),
2262 "fallback scan must redact the raw bytes: {text}"
2263 );
2264 }
2265
2266 #[test]
2267 fn standalone_gz_force_text_scans_compressed_bytes() {
2268 let proc = make_archive_processor().with_force_text(true);
2272 let input = gzip_bytes(b"nothing sensitive here");
2273
2274 let mut output = Vec::new();
2275 proc.process_gz("data.txt.gz", &input[..], &mut output)
2276 .unwrap();
2277 assert_eq!(output, input, "force-text must not touch the gzip stream");
2278 }
2279
2280 #[test]
2281 fn archive_format_detects_standalone_gz() {
2282 assert_eq!(
2283 ArchiveFormat::from_path("x.log.gz"),
2284 Some(ArchiveFormat::Gz)
2285 );
2286 assert_eq!(
2287 ArchiveFormat::from_path("x.tar.gz"),
2288 Some(ArchiveFormat::TarGz)
2289 );
2290 assert_eq!(
2291 ArchiveFormat::from_path("x.tgz"),
2292 Some(ArchiveFormat::TarGz)
2293 );
2294 assert_eq!(ArchiveFormat::from_path(".gz"), None);
2295 assert_eq!(ArchiveFormat::from_path("plain.txt"), None);
2296 }
2297
2298 #[test]
2299 fn short_discovered_values_are_not_propagated_as_literals() {
2300 let proc = make_archive_processor();
2305 let json = br#"{"note": "ok", "tokens": "many-values-here"}"#;
2306 let input = build_test_tar(&[("config.json", json as &[u8])]);
2307
2308 let mut output = Vec::new();
2309 proc.process_tar(&input[..], &mut output).unwrap();
2310
2311 let text = String::from_utf8_lossy(&output).to_string();
2312 assert!(
2313 text.contains(r#""tokens""#),
2314 "the 'tokens' key must not be mangled by an 'ok' literal: {text}"
2315 );
2316 assert!(
2317 !text.contains("many-values-here"),
2318 "the long field value is still redacted: {text}"
2319 );
2320 }
2321
2322 #[test]
2328 fn archive_structured_entry_preserves_comments_and_formatting() {
2329 let gen = Arc::new(HmacGenerator::new([7u8; 32]));
2330 let store = Arc::new(MappingStore::new(gen, None));
2331 let scanner = Arc::new(
2334 StreamScanner::new(vec![], Arc::clone(&store), ScanConfig::default()).unwrap(),
2335 );
2336 let registry = Arc::new(ProcessorRegistry::with_builtins());
2337 let profiles = vec![FileTypeProfile::new(
2338 "yaml",
2339 vec![FieldRule::new("owner_email").with_category(Category::Email)],
2340 )
2341 .with_extension(".yaml")];
2342 let proc = ArchiveProcessor::new(registry, scanner, store, profiles);
2343
2344 let yaml = b"# owner was secret.person@corp.test (keep this comment)\nowner_email: secret.person@corp.test\nport: 8080\n";
2345 let input = build_test_tar(&[("settings.yaml", yaml)]);
2346
2347 let mut output = Vec::new();
2348 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2349 assert_eq!(stats.structured_hits, 1);
2350
2351 let mut archive = tar::Archive::new(&output[..]);
2352 let mut content = String::new();
2353 for entry in archive.entries().unwrap() {
2354 entry.unwrap().read_to_string(&mut content).unwrap();
2355 }
2356
2357 assert!(
2359 !content.contains("secret.person@corp.test"),
2360 "email must be redacted everywhere: {content}"
2361 );
2362 assert!(
2366 content.starts_with("# owner was "),
2367 "leading comment must be preserved: {content}"
2368 );
2369 assert!(
2370 content.contains("(keep this comment)"),
2371 "comment trailing text must be preserved: {content}"
2372 );
2373 assert!(
2374 content.contains("\nport: 8080\n"),
2375 "unrelated line and surrounding whitespace must be untouched: {content}"
2376 );
2377 assert!(
2378 content.contains("owner_email: "),
2379 "key and `: ` separator must be preserved: {content}"
2380 );
2381 }
2382
2383 #[test]
2384 fn tar_preserves_metadata() {
2385 let proc = make_archive_processor();
2386 let input = build_test_tar(&[("data.txt", b"SUPERSECRET token here")]);
2387
2388 let mut output = Vec::new();
2389 proc.process_tar(&input[..], &mut output).unwrap();
2390
2391 let mut archive = tar::Archive::new(&output[..]);
2392 for entry in archive.entries().unwrap() {
2393 let e = entry.unwrap();
2394 let hdr = e.header();
2395 assert_eq!(hdr.mode().unwrap(), 0o644);
2396 assert_eq!(hdr.mtime().unwrap(), 1_700_000_000);
2397 }
2398 }
2399
2400 #[test]
2401 fn tar_handles_multiple_files() {
2402 let proc = make_archive_processor();
2403 let input = build_test_tar(&[
2404 ("a.txt", b"alice@corp.com"),
2405 ("b.json", br#"{"key":"value"}"#),
2406 ("c.log", b"no secrets here"),
2407 ]);
2408
2409 let mut output = Vec::new();
2410 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2411
2412 assert_eq!(stats.files_processed, 3);
2413 assert_eq!(stats.structured_hits, 1);
2415 assert_eq!(stats.scanner_fallback, 2);
2417 }
2418
2419 #[test]
2420 fn tar_passes_through_directories() {
2421 let mut buf = Vec::new();
2422 {
2423 let mut builder = tar::Builder::new(&mut buf);
2424
2425 let mut dir_header = tar::Header::new_gnu();
2427 dir_header.set_entry_type(tar::EntryType::Directory);
2428 dir_header.set_size(0);
2429 dir_header.set_mode(0o755);
2430 dir_header.set_cksum();
2431 builder
2432 .append_data(&mut dir_header, "mydir/", &b""[..])
2433 .unwrap();
2434
2435 let mut file_header = tar::Header::new_gnu();
2437 file_header.set_size(5);
2438 file_header.set_mode(0o644);
2439 file_header.set_cksum();
2440 builder
2441 .append_data(&mut file_header, "mydir/hello.txt", &b"hello"[..])
2442 .unwrap();
2443
2444 builder.finish().unwrap();
2445 }
2446
2447 let proc = make_archive_processor();
2448 let mut output = Vec::new();
2449 let stats = proc.process_tar(&buf[..], &mut output).unwrap();
2450
2451 assert_eq!(stats.entries_skipped, 1);
2452 assert_eq!(stats.files_processed, 1);
2453 }
2454
2455 #[test]
2456 fn tar_preserves_file_paths_longer_than_ustar_limit() {
2457 let long_dir = format!(
2460 "gitlabsos.sr-env-0a1b2c3d-omnibus_20260101-000000_{}",
2461 "gitaly-nginx-prometheus-psql-puma-redis-sidekiq"
2462 );
2463 let long_path = format!("{long_dir}/mpstat");
2464 assert!(long_path.len() > 100);
2465
2466 let proc = make_archive_processor();
2467 let input = build_test_tar(&[(long_path.as_str(), b"cpu stats here".as_slice())]);
2468
2469 let mut output = Vec::new();
2470 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2471 assert_eq!(stats.files_processed, 1);
2472
2473 let mut archive = tar::Archive::new(&output[..]);
2474 let paths: Vec<String> = archive
2475 .entries()
2476 .unwrap()
2477 .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
2478 .collect();
2479 assert_eq!(paths, vec![long_path]);
2480 }
2481
2482 #[test]
2483 fn tar_preserves_long_directory_and_symlink_entries() {
2484 let long_dir = format!("{}/", "d".repeat(120));
2485 let link_path = format!("{}/current-log", "l".repeat(120));
2486 let link_target = format!("{}/log.2026-06-26", "t".repeat(120));
2487
2488 let mut buf = Vec::new();
2489 {
2490 let mut builder = tar::Builder::new(&mut buf);
2491
2492 let mut dir_header = tar::Header::new_gnu();
2493 dir_header.set_entry_type(tar::EntryType::Directory);
2494 dir_header.set_size(0);
2495 dir_header.set_mode(0o755);
2496 dir_header.set_cksum();
2497 builder
2498 .append_data(&mut dir_header, long_dir.as_str(), &b""[..])
2499 .unwrap();
2500
2501 let mut link_header = tar::Header::new_gnu();
2502 link_header.set_entry_type(tar::EntryType::Symlink);
2503 link_header.set_size(0);
2504 link_header.set_cksum();
2505 builder
2506 .append_link(&mut link_header, link_path.as_str(), link_target.as_str())
2507 .unwrap();
2508
2509 builder.finish().unwrap();
2510 }
2511
2512 let proc = make_archive_processor();
2513 let mut output = Vec::new();
2514 let stats = proc.process_tar(&buf[..], &mut output).unwrap();
2515 assert_eq!(stats.entries_skipped, 2);
2516
2517 let mut archive = tar::Archive::new(&output[..]);
2518 let mut entries = archive.entries().unwrap();
2519
2520 let dir_entry = entries.next().unwrap().unwrap();
2521 assert_eq!(
2522 dir_entry
2523 .path()
2524 .unwrap()
2525 .to_string_lossy()
2526 .trim_end_matches('/'),
2527 long_dir.trim_end_matches('/')
2528 );
2529
2530 let link_entry = entries.next().unwrap().unwrap();
2531 assert_eq!(
2532 link_entry.path().unwrap().to_string_lossy(),
2533 link_path.as_str()
2534 );
2535 assert_eq!(
2536 link_entry.link_name().unwrap().unwrap().to_string_lossy(),
2537 link_target.as_str()
2538 );
2539 }
2540
2541 #[test]
2544 fn tar_gz_round_trip() {
2545 let proc = make_archive_processor();
2546
2547 let tar_data = build_test_tar(&[("secret.txt", b"Key is SUPERSECRET okay")]);
2549 let mut gz_input = Vec::new();
2550 {
2551 let mut encoder =
2552 flate2::write::GzEncoder::new(&mut gz_input, flate2::Compression::fast());
2553 encoder.write_all(&tar_data).unwrap();
2554 encoder.finish().unwrap();
2555 }
2556
2557 let mut gz_output = Vec::new();
2558 let stats = proc.process_tar_gz(&gz_input[..], &mut gz_output).unwrap();
2559
2560 assert_eq!(stats.files_processed, 1);
2561 assert_eq!(stats.scanner_fallback, 1);
2562
2563 let decoder = flate2::read::GzDecoder::new(&gz_output[..]);
2565 let mut archive = tar::Archive::new(decoder);
2566 for entry in archive.entries().unwrap() {
2567 let mut e = entry.unwrap();
2568 let mut content = String::new();
2569 e.read_to_string(&mut content).unwrap();
2570 assert!(
2571 !content.contains("SUPERSECRET"),
2572 "secret should be sanitized: {content}"
2573 );
2574 }
2575 }
2576
2577 fn build_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
2580 let mut buf = Cursor::new(Vec::new());
2581 {
2582 let mut zip = zip::ZipWriter::new(&mut buf);
2583 for (name, data) in entries {
2584 let options = zip::write::SimpleFileOptions::default()
2585 .compression_method(zip::CompressionMethod::Deflated);
2586 zip.start_file(*name, options).unwrap();
2587 zip.write_all(data).unwrap();
2588 }
2589 zip.finish().unwrap();
2590 }
2591 buf.into_inner()
2592 }
2593
2594 #[test]
2595 fn zip_sanitizes_plaintext_with_scanner() {
2596 let proc = make_archive_processor();
2597 let zip_data = build_test_zip(&[("notes.txt", b"Reach alice@corp.com for info.")]);
2598
2599 let reader = Cursor::new(&zip_data);
2600 let mut writer = Cursor::new(Vec::new());
2601 let stats = proc.process_zip(reader, &mut writer).unwrap();
2602
2603 assert_eq!(stats.files_processed, 1);
2604 assert_eq!(stats.scanner_fallback, 1);
2605
2606 let out_data = writer.into_inner();
2608 let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
2609 let mut entry = zip_out.by_index(0).unwrap();
2610 let mut content = String::new();
2611 entry.read_to_string(&mut content).unwrap();
2612 assert!(
2613 !content.contains("alice@corp.com"),
2614 "email should be sanitized: {content}"
2615 );
2616 }
2617
2618 #[test]
2619 fn zip_sanitizes_json_with_structured_processor() {
2620 let proc = make_archive_processor();
2621 let json_content = br#"{"password": "hunter2", "host": "db.internal"}"#;
2622 let zip_data = build_test_zip(&[("settings.json", json_content)]);
2623
2624 let reader = Cursor::new(&zip_data);
2625 let mut writer = Cursor::new(Vec::new());
2626 let stats = proc.process_zip(reader, &mut writer).unwrap();
2627
2628 assert_eq!(stats.files_processed, 1);
2629 assert_eq!(stats.structured_hits, 1);
2630
2631 let out_data = writer.into_inner();
2632 let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
2633 let mut entry = zip_out.by_index(0).unwrap();
2634 let mut content = String::new();
2635 entry.read_to_string(&mut content).unwrap();
2636 assert!(!content.contains("hunter2"), "password should be sanitized");
2637 assert!(!content.contains("db.internal"), "host should be sanitized");
2638 }
2639
2640 #[test]
2641 fn zip_preserves_directory_entries() {
2642 let mut buf = Cursor::new(Vec::new());
2643 {
2644 let mut zip = zip::ZipWriter::new(&mut buf);
2645
2646 let dir_options = zip::write::SimpleFileOptions::default();
2647 zip.add_directory("subdir/", dir_options).unwrap();
2648
2649 let file_options = zip::write::SimpleFileOptions::default()
2650 .compression_method(zip::CompressionMethod::Stored);
2651 zip.start_file("subdir/data.txt", file_options).unwrap();
2652 zip.write_all(b"SUPERSECRET value").unwrap();
2653
2654 zip.finish().unwrap();
2655 }
2656
2657 let zip_data = buf.into_inner();
2658 let proc = make_archive_processor();
2659 let reader = Cursor::new(&zip_data);
2660 let mut writer = Cursor::new(Vec::new());
2661 let stats = proc.process_zip(reader, &mut writer).unwrap();
2662
2663 assert_eq!(stats.entries_skipped, 1); assert_eq!(stats.files_processed, 1);
2665 }
2666
2667 #[test]
2668 fn zip_handles_multiple_files() {
2669 let proc = make_archive_processor();
2670 let zip_data = build_test_zip(&[
2671 ("file1.txt", b"alice@corp.com"),
2672 ("file2.json", br#"{"secret":"SUPERSECRET"}"#),
2673 ("file3.log", b"nothing to see"),
2674 ]);
2675
2676 let reader = Cursor::new(&zip_data);
2677 let mut writer = Cursor::new(Vec::new());
2678 let stats = proc.process_zip(reader, &mut writer).unwrap();
2679
2680 assert_eq!(stats.files_processed, 3);
2681 assert_eq!(stats.structured_hits, 1); assert_eq!(stats.scanner_fallback, 2); }
2684
2685 #[test]
2686 fn tar_progress_callback_receives_updates() {
2687 let updates = Arc::new(Mutex::new(Vec::new()));
2688 let proc = make_archive_processor().with_progress_callback({
2689 let updates = Arc::clone(&updates);
2690 Arc::new(move |progress| {
2691 updates
2692 .lock()
2693 .expect("archive progress lock")
2694 .push(progress.clone());
2695 })
2696 });
2697 let input = build_test_tar(&[("a.txt", b"alice@corp.com"), ("b.txt", b"SUPERSECRET")]);
2698
2699 let mut output = Vec::new();
2700 let stats = proc.process_tar(&input[..], &mut output).unwrap();
2701 let updates = updates.lock().unwrap();
2702
2703 assert_eq!(updates.len(), 2);
2704 assert_eq!(updates.last().unwrap().entries_seen, 2);
2705 assert_eq!(
2706 updates.last().unwrap().files_processed,
2707 stats.files_processed
2708 );
2709 assert_eq!(updates.last().unwrap().total_entries, None);
2710 }
2711
2712 #[test]
2713 fn zip_progress_callback_reports_total_entries() {
2714 let updates = Arc::new(Mutex::new(Vec::new()));
2715 let proc = make_archive_processor().with_progress_callback({
2716 let updates = Arc::clone(&updates);
2717 Arc::new(move |progress| {
2718 updates
2719 .lock()
2720 .expect("archive progress lock")
2721 .push(progress.clone());
2722 })
2723 });
2724 let zip_data = build_test_zip(&[
2725 ("file1.txt", b"alice@corp.com"),
2726 ("file2.log", b"nothing to see"),
2727 ]);
2728
2729 let reader = Cursor::new(&zip_data);
2730 let mut writer = Cursor::new(Vec::new());
2731 let stats = proc.process_zip(reader, &mut writer).unwrap();
2732 let updates = updates.lock().unwrap();
2733
2734 assert_eq!(updates.len(), 2);
2735 assert_eq!(
2736 updates.last().unwrap().files_processed,
2737 stats.files_processed
2738 );
2739 assert_eq!(updates.last().unwrap().total_entries, Some(2));
2740 assert_eq!(updates.last().unwrap().current_entry, "file2.log");
2741 }
2742
2743 #[test]
2746 fn format_detection_from_path() {
2747 assert_eq!(
2748 ArchiveFormat::from_path("data.tar"),
2749 Some(ArchiveFormat::Tar)
2750 );
2751 assert_eq!(
2752 ArchiveFormat::from_path("data.tar.gz"),
2753 Some(ArchiveFormat::TarGz)
2754 );
2755 assert_eq!(
2756 ArchiveFormat::from_path("data.tgz"),
2757 Some(ArchiveFormat::TarGz)
2758 );
2759 assert_eq!(
2760 ArchiveFormat::from_path("data.zip"),
2761 Some(ArchiveFormat::Zip)
2762 );
2763 assert_eq!(
2764 ArchiveFormat::from_path("DATA.ZIP"),
2765 Some(ArchiveFormat::Zip)
2766 );
2767 assert_eq!(ArchiveFormat::from_path("photo.png"), None);
2768 }
2769
2770 #[test]
2773 fn same_secret_gets_same_replacement_across_entries() {
2774 let proc = make_archive_processor();
2775 let input = build_test_tar(&[
2776 ("a.txt", b"contact alice@corp.com"),
2777 ("b.txt", b"reach alice@corp.com"),
2778 ]);
2779
2780 let mut output = Vec::new();
2781 proc.process_tar(&input[..], &mut output).unwrap();
2782
2783 let mut archive = tar::Archive::new(&output[..]);
2784 let mut contents: Vec<String> = Vec::new();
2785 for entry in archive.entries().unwrap() {
2786 let mut e = entry.unwrap();
2787 let mut s = String::new();
2788 e.read_to_string(&mut s).unwrap();
2789 contents.push(s);
2790 }
2791
2792 let replacement_a = contents[0].strip_prefix("contact ").unwrap();
2795 let replacement_b = contents[1].strip_prefix("reach ").unwrap();
2796 assert_eq!(
2797 replacement_a, replacement_b,
2798 "dedup should produce identical replacements"
2799 );
2800 assert!(!replacement_a.contains("alice@corp.com"));
2801 }
2802
2803 #[test]
2806 fn process_auto_dispatch_tar() {
2807 let proc = make_archive_processor();
2808 let tar_data = build_test_tar(&[("f.txt", b"SUPERSECRET")]);
2809
2810 let reader = Cursor::new(tar_data);
2811 let writer = Cursor::new(Vec::new());
2812 let stats = proc.process(reader, writer, ArchiveFormat::Tar).unwrap();
2813
2814 assert_eq!(stats.files_processed, 1);
2815 }
2816
2817 #[test]
2818 fn process_auto_dispatch_zip() {
2819 let proc = make_archive_processor();
2820 let zip_data = build_test_zip(&[("f.txt", b"SUPERSECRET")]);
2821
2822 let reader = Cursor::new(zip_data);
2823 let mut writer = Cursor::new(Vec::new());
2824 let stats = proc
2825 .process(reader, &mut writer, ArchiveFormat::Zip)
2826 .unwrap();
2827
2828 assert_eq!(stats.files_processed, 1);
2829 }
2830
2831 #[test]
2834 fn tar_empty_archive() {
2835 let proc = make_archive_processor();
2836 let tar_data = build_test_tar(&[]);
2837
2838 let mut output = Vec::new();
2839 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
2840
2841 assert_eq!(stats.files_processed, 0);
2842 assert_eq!(stats.entries_skipped, 0);
2843 }
2844
2845 #[test]
2846 fn zip_empty_archive() {
2847 let proc = make_archive_processor();
2848 let zip_data = build_test_zip(&[]);
2849
2850 let reader = Cursor::new(zip_data);
2851 let mut writer = Cursor::new(Vec::new());
2852 let stats = proc.process_zip(reader, &mut writer).unwrap();
2853
2854 assert_eq!(stats.files_processed, 0);
2855 }
2856
2857 #[test]
2860 fn zip_entry_name_clean_passthrough() {
2861 assert_eq!(sanitize_zip_entry_name("logs/app.log"), "logs/app.log");
2862 assert_eq!(sanitize_zip_entry_name("config.yaml"), "config.yaml");
2863 assert_eq!(sanitize_zip_entry_name("a/b/c.txt"), "a/b/c.txt");
2864 }
2865
2866 #[test]
2867 fn zip_entry_name_strips_leading_slash() {
2868 assert_eq!(sanitize_zip_entry_name("/etc/passwd"), "etc/passwd");
2869 assert_eq!(sanitize_zip_entry_name("///etc/passwd"), "etc/passwd");
2870 }
2871
2872 #[test]
2873 fn zip_entry_name_strips_dotdot() {
2874 assert_eq!(sanitize_zip_entry_name("../etc/passwd"), "etc/passwd");
2875 assert_eq!(
2876 sanitize_zip_entry_name("a/../../etc/passwd"),
2877 "a/etc/passwd"
2878 );
2879 assert_eq!(
2880 sanitize_zip_entry_name("../../root/.ssh/id_rsa"),
2881 "root/.ssh/id_rsa"
2882 );
2883 }
2884
2885 #[test]
2886 fn zip_entry_name_strips_leading_dot_slash() {
2887 assert_eq!(sanitize_zip_entry_name("./config.yaml"), "config.yaml");
2888 assert_eq!(sanitize_zip_entry_name("././config.yaml"), "config.yaml");
2889 }
2890
2891 #[test]
2892 fn zip_entry_name_backslash_normalised() {
2893 assert_eq!(sanitize_zip_entry_name("a\\b\\c.txt"), "a/b/c.txt");
2894 assert_eq!(sanitize_zip_entry_name("..\\etc\\passwd"), "etc/passwd");
2895 }
2896
2897 #[test]
2898 fn zip_entry_name_empty_result_replaced() {
2899 assert_eq!(sanitize_zip_entry_name("../.."), "_");
2900 assert_eq!(sanitize_zip_entry_name(""), "_");
2901 assert_eq!(sanitize_zip_entry_name("/"), "_");
2902 }
2903
2904 #[test]
2905 fn zip_entry_name_absolute_dotdot_combo() {
2906 assert_eq!(sanitize_zip_entry_name("/../etc/passwd"), "etc/passwd");
2907 }
2908
2909 #[test]
2912 fn filter_empty_passes_everything() {
2913 let f = ArchiveFilter::new(vec![], vec![]).unwrap();
2914 assert!(f.is_empty());
2915 assert!(f.passes("config/app.yaml"));
2916 assert!(f.passes("logs/server.log"));
2917 }
2918
2919 #[test]
2920 fn filter_only_glob_includes_match() {
2921 let f = ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap();
2922 assert!(!f.is_empty());
2923 assert!(f.passes("config/settings.json"));
2924 assert!(f.passes("deep/nested/file.json"));
2925 assert!(!f.passes("config/settings.yaml"));
2926 }
2927
2928 #[test]
2929 fn filter_only_dir_prefix_includes_subtree() {
2930 let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2931 assert!(f.passes("config/app.yaml"));
2932 assert!(f.passes("config/nested/db.yaml"));
2933 assert!(!f.passes("logs/server.log"));
2934 }
2935
2936 #[test]
2937 fn filter_dir_prefix_exact_match() {
2938 let f = ArchiveFilter::new(vec!["config/".into()], vec![]).unwrap();
2939 assert!(f.passes("config"));
2941 }
2942
2943 #[test]
2944 fn filter_exclude_removes_match() {
2945 let f = ArchiveFilter::new(vec![], vec!["**/*.log".into()]).unwrap();
2946 assert!(!f.passes("logs/server.log"));
2947 assert!(f.passes("config/app.yaml"));
2948 }
2949
2950 #[test]
2951 fn filter_only_and_exclude_combined() {
2952 let f =
2953 ArchiveFilter::new(vec!["config/".into()], vec!["config/secrets.yaml".into()]).unwrap();
2954 assert!(f.passes("config/app.yaml"));
2955 assert!(!f.passes("config/secrets.yaml"));
2956 assert!(!f.passes("logs/server.log"));
2957 }
2958
2959 #[test]
2960 fn filter_invalid_glob_returns_error() {
2961 assert!(ArchiveFilter::new(vec!["[invalid".into()], vec![]).is_err());
2962 assert!(ArchiveFilter::new(vec![], vec!["[bad".into()]).is_err());
2963 }
2964
2965 #[test]
2968 fn builder_with_max_depth_clamps_at_max() {
2969 let proc = make_archive_processor().with_max_depth(999);
2970 assert_eq!(proc.max_depth, MAX_ARCHIVE_DEPTH);
2971 }
2972
2973 #[test]
2974 fn builder_with_max_depth_sets_value() {
2975 let proc = make_archive_processor().with_max_depth(2);
2976 assert_eq!(proc.max_depth, 2);
2977 }
2978
2979 #[test]
2980 fn builder_with_parallel_threshold_sets_value() {
2981 let proc = make_archive_processor().with_parallel_threshold(usize::MAX);
2982 assert_eq!(proc.parallel_threshold, usize::MAX);
2983 }
2984
2985 #[test]
2986 fn builder_with_force_text_enables_flag() {
2987 let proc = make_archive_processor().with_force_text(true);
2988 assert!(proc.force_text);
2989 }
2990
2991 #[test]
2992 fn builder_with_filter_applied_to_zip() {
2993 let proc = make_archive_processor()
2994 .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
2995
2996 let zip_data = build_test_zip(&[
2997 ("config.json", br#"{"email":"alice@corp.com"}"#),
2998 ("notes.txt", b"alice@corp.com"),
2999 ]);
3000
3001 let reader = Cursor::new(zip_data);
3002 let mut writer = Cursor::new(Vec::new());
3003 let stats = proc.process_zip(reader, &mut writer).unwrap();
3004
3005 assert_eq!(stats.files_processed, 1);
3007 assert_eq!(stats.entries_filtered, 1);
3008 }
3009
3010 #[test]
3011 fn builder_with_filter_applied_to_tar() {
3012 let proc = make_archive_processor()
3013 .with_filter(ArchiveFilter::new(vec!["**/*.json".into()], vec![]).unwrap());
3014
3015 let tar_data = build_test_tar(&[
3016 ("config.json", br#"{"email":"alice@corp.com"}"#),
3017 ("notes.txt", b"alice@corp.com"),
3018 ]);
3019
3020 let mut output = Vec::new();
3021 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3022
3023 assert_eq!(stats.files_processed, 1);
3024 assert_eq!(stats.entries_filtered, 1);
3025 }
3026
3027 #[test]
3030 fn parallel_tar_sanitizes_all_entries() {
3031 let proc = make_archive_processor().with_parallel_threshold(0);
3033 let tar_data = build_test_tar(&[
3034 ("a.txt", b"alice@corp.com"),
3035 ("b.txt", b"bob@corp.com"),
3036 ("c.txt", b"carol@corp.com"),
3037 ("d.txt", b"dave@corp.com"),
3038 ]);
3039
3040 let mut output = Vec::new();
3041 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3042
3043 assert_eq!(stats.files_processed, 4);
3044
3045 let originals = [
3047 "alice@corp.com",
3048 "bob@corp.com",
3049 "carol@corp.com",
3050 "dave@corp.com",
3051 ];
3052 let mut archive = tar::Archive::new(&output[..]);
3053 for entry in archive.entries().unwrap() {
3054 let mut e = entry.unwrap();
3055 let mut content = String::new();
3056 e.read_to_string(&mut content).unwrap();
3057 for orig in &originals {
3058 assert!(
3059 !content.contains(orig),
3060 "original secret leaked in {:?}",
3061 e.path()
3062 );
3063 }
3064 }
3065 }
3066
3067 #[test]
3068 fn parallel_tar_preserves_entry_order() {
3069 let proc = make_archive_processor().with_parallel_threshold(0);
3070 let tar_data = build_test_tar(&[
3071 ("first.txt", b"alice@corp.com"),
3072 ("second.txt", b"hello"),
3073 ("third.txt", b"bob@corp.com"),
3074 ]);
3075
3076 let mut output = Vec::new();
3077 proc.process_tar(&tar_data[..], &mut output).unwrap();
3078
3079 let mut archive = tar::Archive::new(&output[..]);
3080 let names: Vec<String> = archive
3081 .entries()
3082 .unwrap()
3083 .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
3084 .collect();
3085
3086 assert_eq!(names, vec!["first.txt", "second.txt", "third.txt"]);
3087 }
3088
3089 #[test]
3090 fn parallel_zip_sanitizes_all_entries() {
3091 let proc = make_archive_processor().with_parallel_threshold(0);
3092 let zip_data = build_test_zip(&[
3093 ("a.txt", b"alice@corp.com"),
3094 ("b.txt", b"bob@corp.com"),
3095 ("c.txt", b"carol@corp.com"),
3096 ("d.txt", b"dave@corp.com"),
3097 ]);
3098
3099 let reader = Cursor::new(zip_data);
3100 let mut writer = Cursor::new(Vec::new());
3101 let stats = proc.process_zip(reader, &mut writer).unwrap();
3102
3103 assert_eq!(stats.files_processed, 4);
3104
3105 let originals = [
3106 "alice@corp.com",
3107 "bob@corp.com",
3108 "carol@corp.com",
3109 "dave@corp.com",
3110 ];
3111 let out_data = writer.into_inner();
3112 let mut zip_out = zip::ZipArchive::new(Cursor::new(out_data)).unwrap();
3113 for i in 0..zip_out.len() {
3114 let mut entry = zip_out.by_index(i).unwrap();
3115 let mut content = String::new();
3116 entry.read_to_string(&mut content).unwrap();
3117 for orig in &originals {
3118 assert!(
3119 !content.contains(orig),
3120 "original secret leaked in entry {i}"
3121 );
3122 }
3123 }
3124 }
3125
3126 #[test]
3127 fn parallel_tar_mixed_structured_and_scanner() {
3128 let proc = make_archive_processor().with_parallel_threshold(0);
3129 let tar_data = build_test_tar(&[
3130 ("config.json", br#"{"email":"alice@corp.com","port":5432}"#),
3131 ("notes.txt", b"contact bob@corp.com for help"),
3132 ("data.json", br#"{"email":"carol@corp.com"}"#),
3133 ("readme.txt", b"dave@corp.com is the owner"),
3134 ]);
3135
3136 let mut output = Vec::new();
3137 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3138
3139 assert_eq!(stats.files_processed, 4);
3140 assert_eq!(stats.structured_hits, 2); assert_eq!(stats.scanner_fallback, 2); let originals = [
3144 "alice@corp.com",
3145 "bob@corp.com",
3146 "carol@corp.com",
3147 "dave@corp.com",
3148 ];
3149 let mut archive = tar::Archive::new(&output[..]);
3150 for entry in archive.entries().unwrap() {
3151 let mut e = entry.unwrap();
3152 let mut content = String::new();
3153 e.read_to_string(&mut content).unwrap();
3154 for orig in &originals {
3155 assert!(!content.contains(orig), "original secret leaked");
3156 }
3157 }
3158 }
3159
3160 #[test]
3163 fn tar_in_tar_secrets_sanitized() {
3164 let inner_tar = build_test_tar(&[("inner.txt", b"alice@corp.com")]);
3166
3167 let outer_tar = build_test_tar(&[("nested.tar", &inner_tar)]);
3169
3170 let proc = make_archive_processor();
3171 let mut output = Vec::new();
3172 let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
3173
3174 assert_eq!(stats.nested_archives, 1);
3175
3176 let mut outer = tar::Archive::new(&output[..]);
3178 for entry in outer.entries().unwrap() {
3179 let mut e = entry.unwrap();
3180 let mut inner_bytes = Vec::new();
3181 e.read_to_end(&mut inner_bytes).unwrap();
3182 let mut inner = tar::Archive::new(&inner_bytes[..]);
3183 for inner_entry in inner.entries().unwrap() {
3184 let mut ie = inner_entry.unwrap();
3185 let mut content = String::new();
3186 ie.read_to_string(&mut content).unwrap();
3187 assert!(
3188 !content.contains("alice@corp.com"),
3189 "secret survived nested tar"
3190 );
3191 }
3192 }
3193 }
3194
3195 #[test]
3196 fn zip_in_tar_secrets_sanitized() {
3197 let inner_zip = build_test_zip(&[("inner.txt", b"SUPERSECRET")]);
3198 let outer_tar = build_test_tar(&[("nested.zip", &inner_zip)]);
3199
3200 let proc = make_archive_processor();
3201 let mut output = Vec::new();
3202 let stats = proc.process_tar(&outer_tar[..], &mut output).unwrap();
3203
3204 assert_eq!(stats.nested_archives, 1);
3205
3206 let mut outer = tar::Archive::new(&output[..]);
3207 for entry in outer.entries().unwrap() {
3208 let mut e = entry.unwrap();
3209 let mut zip_bytes = Vec::new();
3210 e.read_to_end(&mut zip_bytes).unwrap();
3211 let mut zip_out = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
3212 for i in 0..zip_out.len() {
3213 let mut ze = zip_out.by_index(i).unwrap();
3214 let mut content = String::new();
3215 ze.read_to_string(&mut content).unwrap();
3216 assert!(
3217 !content.contains("SUPERSECRET"),
3218 "secret survived zip-in-tar"
3219 );
3220 }
3221 }
3222 }
3223
3224 #[test]
3225 fn zip_in_zip_secrets_sanitized() {
3226 let inner_zip = build_test_zip(&[("secret.txt", b"alice@corp.com")]);
3227 let outer_zip = build_test_zip(&[("nested.zip", &inner_zip)]);
3228
3229 let proc = make_archive_processor();
3230 let reader = Cursor::new(outer_zip);
3231 let mut writer = Cursor::new(Vec::new());
3232 let stats = proc.process_zip(reader, &mut writer).unwrap();
3233
3234 assert_eq!(stats.nested_archives, 1);
3235
3236 let out_bytes = writer.into_inner();
3237 let mut outer = zip::ZipArchive::new(Cursor::new(out_bytes)).unwrap();
3238 let mut inner_bytes = Vec::new();
3239 outer
3240 .by_index(0)
3241 .unwrap()
3242 .read_to_end(&mut inner_bytes)
3243 .unwrap();
3244 let mut inner = zip::ZipArchive::new(Cursor::new(inner_bytes)).unwrap();
3245 let mut content = String::new();
3246 inner
3247 .by_index(0)
3248 .unwrap()
3249 .read_to_string(&mut content)
3250 .unwrap();
3251 assert!(
3252 !content.contains("alice@corp.com"),
3253 "secret survived zip-in-zip"
3254 );
3255 }
3256
3257 #[test]
3258 fn nested_archive_depth_limit_returns_error() {
3259 let proc = make_archive_processor().with_max_depth(1);
3262
3263 let innermost = build_test_tar(&[("file.txt", b"secret")]);
3264 let middle = build_test_tar(&[("inner.tar", &innermost)]);
3265 let outer = build_test_tar(&[("middle.tar", &middle)]);
3266
3267 let mut output = Vec::new();
3268 let err = proc.process_tar(&outer[..], &mut output).unwrap_err();
3269 assert!(matches!(err, SanitizeError::RecursionDepthExceeded(_)));
3270 }
3271
3272 #[test]
3273 fn force_text_skips_structured_processor() {
3274 let proc = make_archive_processor().with_force_text(true);
3275 let tar_data = build_test_tar(&[("config.json", br#"{"email":"alice@corp.com"}"#)]);
3276
3277 let mut output = Vec::new();
3278 let stats = proc.process_tar(&tar_data[..], &mut output).unwrap();
3279
3280 assert_eq!(stats.scanner_fallback, 1);
3282 assert_eq!(stats.structured_hits, 0);
3283 }
3284}