1use std::path::Path;
4
5use crate::ArchiveError;
6use crate::ExtractionReport;
7use crate::NoopProgress;
8use crate::ProgressCallback;
9use crate::Result;
10use crate::SecurityConfig;
11use crate::config::ExtractionOptions;
12use crate::config::Validated;
13use crate::creation::CreationConfig;
14use crate::creation::CreationReport;
15use crate::formats::compression::CompressionCodec;
16use crate::formats::detect::ArchiveType;
17use crate::formats::detect::FormatFamily;
18use crate::formats::detect::detect_format;
19use crate::formats::detect::detect_format_from_extension;
20use crate::formats::detect::is_zip_family_alias;
21use crate::inspection::ArchiveManifest;
22use crate::inspection::VerificationReport;
23
24pub fn extract_archive<P: AsRef<Path>, Q: AsRef<Path>>(
57 archive_path: P,
58 output_dir: Q,
59 config: &SecurityConfig,
60) -> Result<ExtractionReport> {
61 let mut noop = NoopProgress;
62 extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
63}
64
65pub fn extract_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
102 archive_path: P,
103 output_dir: Q,
104 config: &SecurityConfig,
105 progress: &mut dyn ProgressCallback,
106) -> Result<ExtractionReport> {
107 let options = ExtractionOptions::default();
108 extract_archive_with_options_and_progress(archive_path, output_dir, config, &options, progress)
109}
110
111fn extract_impl<P: AsRef<Path>, Q: AsRef<Path>>(
112 archive_path: P,
113 output_dir: Q,
114 config: &SecurityConfig,
115 options: &ExtractionOptions,
116 progress: &mut dyn ProgressCallback,
117) -> Result<ExtractionReport> {
118 let config = config.clone().validate()?;
119 let config = &config;
120
121 let archive_path = archive_path.as_ref();
122 let output_dir = output_dir.as_ref();
123
124 let format = detect_format(archive_path)?;
126
127 match format.family() {
129 FormatFamily::Tar(codec) => {
130 extract_tar(archive_path, output_dir, config, options, progress, codec)
131 }
132 FormatFamily::Zip => extract_zip(archive_path, output_dir, config, options, progress),
133 FormatFamily::SevenZ => extract_7z(archive_path, output_dir, config, options, progress),
134 }
135}
136
137pub fn extract_archive_with_options_and_progress<P: AsRef<Path>, Q: AsRef<Path>>(
185 archive_path: P,
186 output_dir: Q,
187 config: &SecurityConfig,
188 options: &ExtractionOptions,
189 progress: &mut dyn ProgressCallback,
190) -> Result<ExtractionReport> {
191 if options.atomic {
192 extract_atomic(archive_path, output_dir, config, options, progress)
193 } else {
194 extract_impl(archive_path, output_dir, config, options, progress)
195 }
196}
197
198pub fn extract_archive_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
229 archive_path: P,
230 output_dir: Q,
231 config: &SecurityConfig,
232 options: &ExtractionOptions,
233) -> Result<ExtractionReport> {
234 let mut noop = NoopProgress;
235 extract_archive_with_options_and_progress(archive_path, output_dir, config, options, &mut noop)
236}
237
238fn extract_atomic<P: AsRef<Path>, Q: AsRef<Path>>(
239 archive_path: P,
240 output_dir: Q,
241 config: &SecurityConfig,
242 options: &ExtractionOptions,
243 progress: &mut dyn ProgressCallback,
244) -> Result<ExtractionReport> {
245 let output_dir = output_dir.as_ref();
246
247 let canonical_output = if output_dir.exists() {
251 output_dir.canonicalize().map_err(ArchiveError::Io)?
252 } else {
253 output_dir.to_path_buf()
254 };
255
256 let parent = canonical_output
257 .parent()
258 .ok_or_else(|| ArchiveError::InvalidConfiguration {
259 reason: "output directory has no parent".into(),
260 })?;
261
262 std::fs::create_dir_all(parent).map_err(ArchiveError::Io)?;
263
264 let temp_dir = tempfile::tempdir_in(parent).map_err(|e| {
265 ArchiveError::Io(std::io::Error::new(
266 e.kind(),
267 format!(
268 "failed to create temp directory in {}: {e}",
269 parent.display()
270 ),
271 ))
272 })?;
273
274 let result = extract_impl(archive_path, temp_dir.path(), config, options, progress);
275
276 match result {
277 Ok(report) => {
278 let temp_path = temp_dir.keep();
280 std::fs::rename(&temp_path, output_dir).map_err(|e| {
281 let _ = std::fs::remove_dir_all(&temp_path);
283 if e.kind() == std::io::ErrorKind::AlreadyExists {
285 ArchiveError::OutputExists {
286 path: output_dir.to_path_buf(),
287 }
288 } else {
289 ArchiveError::Io(std::io::Error::new(
290 e.kind(),
291 format!("failed to rename temp dir to {}: {e}", output_dir.display()),
292 ))
293 }
294 })?;
295
296 Ok(report)
297 }
298 Err(e) => {
299 Err(e.rolled_back())
301 }
302 }
303}
304
305fn extract_tar(
308 archive_path: &Path,
309 output_dir: &Path,
310 config: &SecurityConfig<Validated>,
311 options: &ExtractionOptions,
312 progress: &mut dyn ProgressCallback,
313 codec: Option<CompressionCodec>,
314) -> Result<ExtractionReport> {
315 use crate::formats::TarArchive;
316 use crate::formats::tar::open_tar_stream;
317 use crate::formats::traits::ArchiveFormat;
318
319 let stream = open_tar_stream(archive_path, codec, config.max_decoder_memory)?;
320 let mut archive = TarArchive::new(stream);
321 archive.extract(output_dir, config, options, progress)
322}
323
324fn extract_zip(
325 archive_path: &Path,
326 output_dir: &Path,
327 config: &SecurityConfig<Validated>,
328 options: &ExtractionOptions,
329 progress: &mut dyn ProgressCallback,
330) -> Result<ExtractionReport> {
331 use crate::formats::ZipArchive;
332 use crate::formats::traits::ArchiveFormat;
333 use std::fs::File;
334
335 let file = File::open(archive_path)?;
336 let mut archive = ZipArchive::new(file)?;
337 archive.extract(output_dir, config, options, progress)
338}
339
340fn extract_7z(
341 archive_path: &Path,
342 output_dir: &Path,
343 config: &SecurityConfig<Validated>,
344 options: &ExtractionOptions,
345 progress: &mut dyn ProgressCallback,
346) -> Result<ExtractionReport> {
347 use crate::formats::SevenZArchive;
348 use crate::formats::traits::ArchiveFormat;
349 use std::fs::File;
350
351 let file = File::open(archive_path)?;
352 let mut archive = SevenZArchive::new(file)?;
353 archive.extract(output_dir, config, options, progress)
354}
355
356pub fn create_archive<P: AsRef<Path>, Q: AsRef<Path>>(
389 output_path: P,
390 sources: &[Q],
391 config: &CreationConfig,
392) -> Result<CreationReport> {
393 let mut noop = NoopProgress;
394 create_archive_with_progress(output_path, sources, config, &mut noop)
395}
396
397pub fn create_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
438 output_path: P,
439 sources: &[Q],
440 config: &CreationConfig,
441 progress: &mut dyn ProgressCallback,
442) -> Result<CreationReport> {
443 let config = config.clone().validate()?;
444 let config = &config;
445
446 let output = output_path.as_ref();
447
448 if config.format.is_none() {
457 reject_zip_family_creation(output)?;
458 }
459
460 let format = determine_creation_format(output, config)?;
462
463 let source_refs: Vec<&Path> = sources.iter().map(AsRef::as_ref).collect();
464 let creator = creator_for_format(format)?;
465 creator.create(output, &source_refs, config, progress)
466}
467
468fn creator_for_format(
469 format: ArchiveType,
470) -> Result<Box<dyn crate::formats::traits::FormatCreator>> {
471 match format.family() {
472 FormatFamily::Tar(codec) => Ok(Box::new(crate::creation::TarCreator(codec))),
473 FormatFamily::Zip => Ok(Box::new(crate::creation::ZipCreator)),
474 FormatFamily::SevenZ => Err(ArchiveError::InvalidConfiguration {
475 reason: "7z archive creation is not supported".into(),
476 }),
477 }
478}
479
480pub fn list_archive<P: AsRef<Path>>(
516 archive_path: P,
517 config: &SecurityConfig,
518) -> Result<ArchiveManifest> {
519 crate::inspection::list_archive(archive_path, config)
520}
521
522pub fn list_archive_with_options<P: AsRef<Path>>(
552 archive_path: P,
553 config: &SecurityConfig,
554 options: &crate::ListOptions,
555) -> Result<ArchiveManifest> {
556 crate::inspection::list_archive_with_options(archive_path, config, options)
557}
558
559pub fn verify_archive<P: AsRef<Path>>(
613 archive_path: P,
614 config: &SecurityConfig,
615) -> Result<VerificationReport> {
616 crate::inspection::verify_archive(archive_path, config)
617}
618
619pub fn verify_archive_with_options<P: AsRef<Path>>(
647 archive_path: P,
648 config: &SecurityConfig,
649 options: &crate::VerificationOptions,
650) -> Result<VerificationReport> {
651 crate::inspection::verify_archive_with_options(archive_path, config, options)
652}
653
654fn reject_zip_family_creation(output: &Path) -> Result<()> {
660 let Some(ext) = output.extension().and_then(|e| e.to_str()) else {
661 return Ok(());
662 };
663 if is_zip_family_alias(ext) {
664 let ext_lower = ext.to_ascii_lowercase();
665 return Err(ArchiveError::InvalidArchive(format!(
666 "creation for .{ext_lower} isn't supported: the format is ZIP-based but \
667 requires extra structure (signing, manifests, ordering) that exarch \
668 doesn't produce. Use .zip, or set CreationConfig::format = Some(\
669 exarch_core::formats::detect::ArchiveType::Zip) to override."
670 )));
671 }
672 Ok(())
673}
674
675fn determine_creation_format<State>(
681 output: &Path,
682 config: &CreationConfig<State>,
683) -> Result<ArchiveType> {
684 if let Some(format) = config.format {
686 return Ok(format);
687 }
688
689 detect_format_from_extension(output)
691}
692
693#[cfg(test)]
694#[allow(clippy::unwrap_used)]
695mod tests {
696 use super::*;
697 use crate::raw_zip::RawEntry;
698 use crate::raw_zip::RawZip;
699 use std::assert_matches;
700 use std::path::PathBuf;
701
702 #[test]
703 fn test_extract_archive_nonexistent_file() {
704 let config = SecurityConfig::default();
705 let result = extract_archive(
706 PathBuf::from("nonexistent_test.tar"),
707 PathBuf::from("/tmp/test"),
708 &config,
709 );
710 assert!(result.is_err());
712 }
713
714 #[test]
715 fn test_determine_creation_format_tar() {
716 let config = CreationConfig::default();
717 let path = PathBuf::from("archive.tar");
718 let format = determine_creation_format(&path, &config).unwrap();
719 assert_eq!(format, ArchiveType::Tar);
720 }
721
722 #[test]
723 fn test_determine_creation_format_tar_gz() {
724 let config = CreationConfig::default();
725 let path = PathBuf::from("archive.tar.gz");
726 let format = determine_creation_format(&path, &config).unwrap();
727 assert_eq!(format, ArchiveType::TarGz);
728
729 let path2 = PathBuf::from("archive.tgz");
730 let format2 = determine_creation_format(&path2, &config).unwrap();
731 assert_eq!(format2, ArchiveType::TarGz);
732 }
733
734 #[test]
735 fn test_determine_creation_format_tar_bz2() {
736 let config = CreationConfig::default();
737 let path = PathBuf::from("archive.tar.bz2");
738 let format = determine_creation_format(&path, &config).unwrap();
739 assert_eq!(format, ArchiveType::TarBz2);
740 }
741
742 #[test]
743 fn test_determine_creation_format_tar_xz() {
744 let config = CreationConfig::default();
745 let path = PathBuf::from("archive.tar.xz");
746 let format = determine_creation_format(&path, &config).unwrap();
747 assert_eq!(format, ArchiveType::TarXz);
748 }
749
750 #[test]
751 fn test_determine_creation_format_tar_zst() {
752 let config = CreationConfig::default();
753 let path = PathBuf::from("archive.tar.zst");
754 let format = determine_creation_format(&path, &config).unwrap();
755 assert_eq!(format, ArchiveType::TarZst);
756 }
757
758 #[test]
759 fn test_determine_creation_format_zip() {
760 let config = CreationConfig::default();
761 let path = PathBuf::from("archive.zip");
762 let format = determine_creation_format(&path, &config).unwrap();
763 assert_eq!(format, ArchiveType::Zip);
764 }
765
766 #[test]
767 fn test_determine_creation_format_explicit() {
768 let config = CreationConfig::default().with_format(Some(ArchiveType::TarGz));
769 let path = PathBuf::from("archive.xyz");
770 let format = determine_creation_format(&path, &config).unwrap();
771 assert_eq!(format, ArchiveType::TarGz);
772 }
773
774 #[test]
775 fn test_determine_creation_format_unknown() {
776 let config = CreationConfig::default();
777 let path = PathBuf::from("archive.rar");
778 let result = determine_creation_format(&path, &config);
779 assert!(result.is_err());
780 }
781
782 #[test]
783 fn test_determine_creation_format_ignores_stale_magic_bytes() {
784 let dir = tempfile::tempdir().unwrap();
787 let path = dir.path().join("backup.zip");
788 std::fs::write(&path, b"\x1f\x8b\x08\x00\x00\x00\x00\x00").unwrap();
790
791 let config = CreationConfig::default();
792 let format = determine_creation_format(&path, &config).unwrap();
793 assert_eq!(
794 format,
795 ArchiveType::Zip,
796 "creation format must follow extension, not stale on-disk magic bytes"
797 );
798 }
799
800 #[test]
801 fn test_extract_archive_7z_not_implemented() {
802 let dest = tempfile::TempDir::new().unwrap();
803 let path = PathBuf::from("test.7z");
804
805 let result = extract_archive(&path, dest.path(), &SecurityConfig::default());
806
807 assert!(result.is_err());
808 }
809
810 #[test]
811 fn test_create_archive_invalid_compression_level_rejected_before_io() {
812 for ext in ["tar.gz", "tar.bz2", "tar.xz", "tar.zst"] {
813 let dest = tempfile::TempDir::new().unwrap();
814 let archive_path = dest.path().join(format!("output.{ext}"));
815 let mut config = CreationConfig::default();
816 config.compression_level = Some(200);
817 let result = create_archive(&archive_path, &[] as &[&str], &config);
818 assert_matches!(
819 result,
820 Err(ArchiveError::InvalidCompressionLevel { level: 200 }),
821 "{ext}: expected InvalidCompressionLevel, got {result:?}",
822 );
823 assert!(
825 !archive_path.exists(),
826 "{ext}: output file must not be created"
827 );
828 }
829 }
830
831 #[test]
832 fn test_create_archive_zip_family_not_supported() {
833 let dest = tempfile::TempDir::new().unwrap();
837 for ext in ["apk", "whl", "EPUB"] {
838 let archive_path = dest.path().join(format!("output.{ext}"));
839 let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
840 assert_matches!(
841 result,
842 Err(ArchiveError::InvalidArchive(_)),
843 ".{ext} should be rejected, got {result:?}",
844 );
845 }
846 }
847
848 #[test]
849 fn test_create_archive_zip_family_override_bypasses_guard() {
850 let dest = tempfile::TempDir::new().unwrap();
854 let src = dest.path().join("source.txt");
855 std::fs::write(&src, b"hello").unwrap();
856 let archive_path = dest.path().join("output.apk");
857 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
858 let result = create_archive(&archive_path, &[&src], &config);
859 assert!(
860 result.is_ok(),
861 "explicit format override should bypass the guard, got {result:?}",
862 );
863 }
864
865 #[test]
866 fn test_create_archive_7z_not_supported() {
867 let dest = tempfile::TempDir::new().unwrap();
868 let archive_path = dest.path().join("output.7z");
869
870 let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
871
872 assert!(result.is_err());
873 assert_matches!(
874 result.unwrap_err(),
875 ArchiveError::InvalidConfiguration { .. }
876 );
877 }
878
879 #[test]
880 fn test_extract_archive_with_options_and_progress_non_atomic_delegates_to_normal() {
881 let dest = tempfile::TempDir::new().unwrap();
882 let options = ExtractionOptions {
883 atomic: false,
884 skip_duplicates: true,
885 };
886 let result = extract_archive_with_options_and_progress(
887 PathBuf::from("nonexistent.tar.gz"),
888 dest.path(),
889 &SecurityConfig::default(),
890 &options,
891 &mut NoopProgress,
892 );
893 assert!(result.is_err());
894 }
895
896 #[test]
897 fn test_extract_archive_with_options_delegates() {
898 let dest = tempfile::TempDir::new().unwrap();
899 let options = ExtractionOptions {
900 atomic: false,
901 skip_duplicates: true,
902 };
903 let result = extract_archive_with_options(
904 PathBuf::from("nonexistent.tar.gz"),
905 dest.path(),
906 &SecurityConfig::default(),
907 &options,
908 );
909 assert!(result.is_err());
910 }
911
912 #[test]
913 fn test_extract_atomic_success() {
914 use crate::create_archive;
915 use crate::creation::CreationConfig;
916
917 let archive_dir = tempfile::TempDir::new().unwrap();
919 let archive_path = archive_dir.path().join("test.tar.gz");
920
921 let src_dir = tempfile::TempDir::new().unwrap();
923 std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
924 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
925
926 let parent = tempfile::TempDir::new().unwrap();
927 let output_dir = parent.path().join("extracted");
928
929 let options = ExtractionOptions {
930 atomic: true,
931 skip_duplicates: true,
932 };
933 let result = extract_archive_with_options(
934 &archive_path,
935 &output_dir,
936 &SecurityConfig::default(),
937 &options,
938 );
939
940 assert!(result.is_ok());
941 assert!(output_dir.exists());
942 let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
944 assert_eq!(
945 temp_entries.len(),
946 1,
947 "Expected only the output dir, found temp remnants"
948 );
949 }
950
951 #[test]
952 fn test_extract_atomic_failure_cleans_up() {
953 let parent = tempfile::TempDir::new().unwrap();
954 let output_dir = parent.path().join("extracted");
955
956 let options = ExtractionOptions {
957 atomic: true,
958 skip_duplicates: true,
959 };
960 let result = extract_archive_with_options(
961 PathBuf::from("nonexistent_archive.tar.gz"),
962 &output_dir,
963 &SecurityConfig::default(),
964 &options,
965 );
966
967 assert!(result.is_err());
968 assert!(!output_dir.exists());
970 let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
972 assert!(
973 temp_entries.is_empty(),
974 "Temp dir not cleaned up after failure"
975 );
976 }
977
978 #[test]
979 fn test_extract_atomic_output_already_exists_fails() {
980 use crate::create_archive;
981 use crate::creation::CreationConfig;
982
983 let parent = tempfile::TempDir::new().unwrap();
984 let output_dir = parent.path().join("extracted");
985 std::fs::create_dir_all(&output_dir).unwrap();
986 std::fs::write(output_dir.join("existing.txt"), b"old content").unwrap();
989
990 let archive_dir = tempfile::TempDir::new().unwrap();
991 let archive_path = archive_dir.path().join("test.tar.gz");
992 let src_dir = tempfile::TempDir::new().unwrap();
993 std::fs::write(src_dir.path().join("new.txt"), b"new content").unwrap();
994 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
995
996 let options = ExtractionOptions {
997 atomic: true,
998 skip_duplicates: true,
999 };
1000 let result = extract_archive_with_options(
1001 &archive_path,
1002 &output_dir,
1003 &SecurityConfig::default(),
1004 &options,
1005 );
1006
1007 assert!(result.is_err());
1010 assert!(output_dir.join("existing.txt").exists());
1012 }
1013
1014 #[test]
1016 fn test_progress_callback_invoked_during_extraction() {
1017 use crate::ProgressCallback;
1018 use std::path::Path;
1019
1020 struct TrackingProgress {
1021 started: usize,
1022 completed: usize,
1023 finished: bool,
1024 }
1025
1026 impl ProgressCallback for TrackingProgress {
1027 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1028 self.started += 1;
1029 }
1030
1031 fn on_bytes_written(&mut self, _bytes: u64) {}
1032
1033 fn on_entry_complete(&mut self, _path: &Path) {
1034 self.completed += 1;
1035 }
1036
1037 fn on_complete(&mut self) {
1038 self.finished = true;
1039 }
1040 }
1041
1042 let archive_dir = tempfile::TempDir::new().unwrap();
1043 let archive_path = archive_dir.path().join("test.tar.gz");
1044 let src_dir = tempfile::TempDir::new().unwrap();
1045 std::fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
1046 std::fs::write(src_dir.path().join("b.txt"), b"world").unwrap();
1047 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
1048
1049 let dest = tempfile::TempDir::new().unwrap();
1050 let mut progress = TrackingProgress {
1051 started: 0,
1052 completed: 0,
1053 finished: false,
1054 };
1055
1056 let report = extract_archive_with_progress(
1057 &archive_path,
1058 dest.path(),
1059 &SecurityConfig::default(),
1060 &mut progress,
1061 )
1062 .unwrap();
1063
1064 assert!(report.files_extracted >= 2, "expected at least 2 files");
1065 assert!(progress.started >= 2, "on_entry_start not called");
1066 assert!(progress.completed >= 2, "on_entry_complete not called");
1067 assert!(progress.finished, "on_complete not called");
1068 }
1069
1070 #[test]
1072 fn test_progress_callback_invoked_during_zip_extraction() {
1073 use crate::ProgressCallback;
1074 use std::path::Path;
1075
1076 struct TrackingProgress {
1077 started: usize,
1078 completed: usize,
1079 finished: bool,
1080 }
1081
1082 impl ProgressCallback for TrackingProgress {
1083 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1084 self.started += 1;
1085 }
1086
1087 fn on_bytes_written(&mut self, _bytes: u64) {}
1088
1089 fn on_entry_complete(&mut self, _path: &Path) {
1090 self.completed += 1;
1091 }
1092
1093 fn on_complete(&mut self) {
1094 self.finished = true;
1095 }
1096 }
1097
1098 let tmp = tempfile::TempDir::new().unwrap();
1099 let archive_path = tmp.path().join("test.zip");
1100 let src_dir = tempfile::TempDir::new().unwrap();
1101 std::fs::write(src_dir.path().join("x.txt"), b"foo").unwrap();
1102 std::fs::write(src_dir.path().join("y.txt"), b"bar").unwrap();
1103 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1104 create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1105
1106 let dest = tempfile::TempDir::new().unwrap();
1107 let mut progress = TrackingProgress {
1108 started: 0,
1109 completed: 0,
1110 finished: false,
1111 };
1112 let report = extract_archive_with_progress(
1113 &archive_path,
1114 dest.path(),
1115 &SecurityConfig::default(),
1116 &mut progress,
1117 )
1118 .unwrap();
1119
1120 assert!(report.files_extracted >= 2, "expected at least 2 files");
1121 assert!(progress.started >= 2, "on_entry_start not called for ZIP");
1122 assert!(
1123 progress.completed >= 2,
1124 "on_entry_complete not called for ZIP"
1125 );
1126 assert!(progress.finished, "on_complete not called for ZIP");
1127 }
1128
1129 #[test]
1131 fn test_progress_callback_invoked_during_sevenz_extraction() {
1132 use crate::ProgressCallback;
1133 use std::path::Path;
1134
1135 struct TrackingProgress {
1136 started: usize,
1137 completed: usize,
1138 finished: bool,
1139 }
1140
1141 impl ProgressCallback for TrackingProgress {
1142 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1143 self.started += 1;
1144 }
1145
1146 fn on_bytes_written(&mut self, _bytes: u64) {}
1147
1148 fn on_entry_complete(&mut self, _path: &Path) {
1149 self.completed += 1;
1150 }
1151
1152 fn on_complete(&mut self) {
1153 self.finished = true;
1154 }
1155 }
1156
1157 let fixture =
1158 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1159
1160 let dest = tempfile::TempDir::new().unwrap();
1161 let mut progress = TrackingProgress {
1162 started: 0,
1163 completed: 0,
1164 finished: false,
1165 };
1166 let report = extract_archive_with_progress(
1167 &fixture,
1168 dest.path(),
1169 &SecurityConfig::default(),
1170 &mut progress,
1171 )
1172 .unwrap();
1173
1174 assert!(
1175 report.files_extracted >= 1,
1176 "expected at least 1 file from simple.7z"
1177 );
1178 assert!(progress.started >= 1, "on_entry_start not called for 7z");
1179 assert!(
1180 progress.completed >= 1,
1181 "on_entry_complete not called for 7z"
1182 );
1183 assert!(progress.finished, "on_complete not called for 7z");
1184 }
1185
1186 #[test]
1189 fn test_on_bytes_written_called_for_tar() {
1190 use crate::ProgressCallback;
1191 use std::path::Path;
1192
1193 struct ByteTracker {
1194 total: u64,
1195 }
1196
1197 impl ProgressCallback for ByteTracker {
1198 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1199
1200 fn on_bytes_written(&mut self, bytes: u64) {
1201 self.total += bytes;
1202 }
1203
1204 fn on_entry_complete(&mut self, _path: &Path) {}
1205
1206 fn on_complete(&mut self) {}
1207 }
1208
1209 let archive_dir = tempfile::TempDir::new().unwrap();
1210 let archive_path = archive_dir.path().join("test.tar.gz");
1211 let src_dir = tempfile::TempDir::new().unwrap();
1212 std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
1213 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
1214
1215 let dest = tempfile::TempDir::new().unwrap();
1216 let mut progress = ByteTracker { total: 0 };
1217 let report = extract_archive_with_progress(
1218 &archive_path,
1219 dest.path(),
1220 &SecurityConfig::default(),
1221 &mut progress,
1222 )
1223 .unwrap();
1224
1225 assert!(
1226 report.bytes_written > 0,
1227 "report.bytes_written must be > 0, got {}",
1228 report.bytes_written
1229 );
1230 assert!(
1231 progress.total > 0,
1232 "on_bytes_written must be called with > 0 bytes for TAR, got {}",
1233 progress.total
1234 );
1235 }
1236
1237 #[test]
1240 fn test_on_bytes_written_called_for_zip() {
1241 use crate::ProgressCallback;
1242 use std::path::Path;
1243
1244 struct ByteTracker {
1245 total: u64,
1246 }
1247
1248 impl ProgressCallback for ByteTracker {
1249 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1250
1251 fn on_bytes_written(&mut self, bytes: u64) {
1252 self.total += bytes;
1253 }
1254
1255 fn on_entry_complete(&mut self, _path: &Path) {}
1256
1257 fn on_complete(&mut self) {}
1258 }
1259
1260 let tmp = tempfile::TempDir::new().unwrap();
1261 let archive_path = tmp.path().join("test.zip");
1262 let src_dir = tempfile::TempDir::new().unwrap();
1263 std::fs::write(src_dir.path().join("data.txt"), b"hello world").unwrap();
1264 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1265 create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1266
1267 let dest = tempfile::TempDir::new().unwrap();
1268 let mut progress = ByteTracker { total: 0 };
1269 let report = extract_archive_with_progress(
1270 &archive_path,
1271 dest.path(),
1272 &SecurityConfig::default(),
1273 &mut progress,
1274 )
1275 .unwrap();
1276
1277 assert!(
1278 report.bytes_written > 0,
1279 "report.bytes_written must be > 0, got {}",
1280 report.bytes_written
1281 );
1282 assert!(
1283 progress.total > 0,
1284 "on_bytes_written must be called with > 0 bytes for ZIP, got {}",
1285 progress.total
1286 );
1287 }
1288
1289 #[test]
1292 fn test_on_bytes_written_called_for_sevenz() {
1293 use crate::ProgressCallback;
1294 use std::path::Path;
1295
1296 struct ByteTracker {
1297 total: u64,
1298 }
1299
1300 impl ProgressCallback for ByteTracker {
1301 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1302
1303 fn on_bytes_written(&mut self, bytes: u64) {
1304 self.total += bytes;
1305 }
1306
1307 fn on_entry_complete(&mut self, _path: &Path) {}
1308
1309 fn on_complete(&mut self) {}
1310 }
1311
1312 let fixture =
1313 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1314
1315 let dest = tempfile::TempDir::new().unwrap();
1316 let mut progress = ByteTracker { total: 0 };
1317 let report = extract_archive_with_progress(
1318 &fixture,
1319 dest.path(),
1320 &SecurityConfig::default(),
1321 &mut progress,
1322 )
1323 .unwrap();
1324
1325 assert!(
1326 report.bytes_written > 0,
1327 "report.bytes_written must be > 0, got {}",
1328 report.bytes_written
1329 );
1330 assert!(
1331 progress.total > 0,
1332 "on_bytes_written must be called with > 0 bytes for 7z, got {}",
1333 progress.total
1334 );
1335 }
1336
1337 #[test]
1340 fn test_tar_hardlink_calls_on_bytes_written() {
1341 use crate::ProgressCallback;
1342 use crate::formats::TarArchive;
1343 use crate::formats::traits::ArchiveFormat;
1344 use std::io::Cursor;
1345 use std::path::Path;
1346
1347 struct ByteTracker {
1348 total: u64,
1349 }
1350
1351 impl ProgressCallback for ByteTracker {
1352 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1353
1354 fn on_bytes_written(&mut self, bytes: u64) {
1355 self.total += bytes;
1356 }
1357
1358 fn on_entry_complete(&mut self, _path: &Path) {}
1359
1360 fn on_complete(&mut self) {}
1361 }
1362
1363 let content = b"hello hardlink";
1365 let tar_data = {
1366 let mut builder = tar::Builder::new(Vec::new());
1367
1368 let mut header = tar::Header::new_gnu();
1369 header.set_size(content.len() as u64);
1370 header.set_mode(0o644);
1371 header.set_entry_type(tar::EntryType::Regular);
1372 header.set_cksum();
1373 builder
1374 .append_data(&mut header, "original.txt", content.as_ref())
1375 .unwrap();
1376
1377 let mut hdr = tar::Header::new_gnu();
1378 hdr.set_size(0);
1379 hdr.set_mode(0o644);
1380 hdr.set_entry_type(tar::EntryType::Link);
1381 hdr.set_link_name("original.txt").unwrap();
1382 hdr.set_cksum();
1383 builder
1384 .append_data(&mut hdr, "link.txt", std::io::empty())
1385 .unwrap();
1386
1387 builder.into_inner().unwrap()
1388 };
1389
1390 let temp = tempfile::TempDir::new().unwrap();
1391 let mut config = SecurityConfig::default();
1392 config.allowed.hardlinks = true;
1393 let config = config.validate().unwrap();
1394
1395 let mut archive = TarArchive::new(Cursor::new(tar_data));
1396 let mut progress = ByteTracker { total: 0 };
1397 let report = archive
1398 .extract(
1399 temp.path(),
1400 &config,
1401 &ExtractionOptions::default(),
1402 &mut progress,
1403 )
1404 .unwrap();
1405
1406 let expected = (content.len() as u64) * 2;
1409 assert_eq!(
1410 progress.total, expected,
1411 "on_bytes_written must report bytes for both original and hardlink copy, \
1412 got {} (report.bytes_written={})",
1413 progress.total, report.bytes_written
1414 );
1415 }
1416
1417 #[test]
1420 fn test_tar_on_entry_complete_called_on_path_traversal_error() {
1421 use crate::ProgressCallback;
1422 use crate::formats::TarArchive;
1423 use crate::formats::traits::ArchiveFormat;
1424 use std::io::Cursor;
1425 use std::path::Path;
1426
1427 struct SymmetryTracker {
1428 started: usize,
1429 completed: usize,
1430 }
1431
1432 impl ProgressCallback for SymmetryTracker {
1433 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1434 self.started += 1;
1435 }
1436
1437 fn on_bytes_written(&mut self, _bytes: u64) {}
1438
1439 fn on_entry_complete(&mut self, _path: &Path) {
1440 self.completed += 1;
1441 }
1442
1443 fn on_complete(&mut self) {}
1444 }
1445
1446 let tar_data = make_raw_tar_single(b"../../etc/passwd", b"evil");
1449
1450 let temp = tempfile::TempDir::new().unwrap();
1451 let mut archive = TarArchive::new(Cursor::new(tar_data));
1452 let mut progress = SymmetryTracker {
1453 started: 0,
1454 completed: 0,
1455 };
1456 let result = archive.extract(
1457 temp.path(),
1458 &SecurityConfig::default().validate().unwrap(),
1459 &ExtractionOptions::default(),
1460 &mut progress,
1461 );
1462
1463 assert!(result.is_err(), "traversal entry must be rejected");
1464 assert_eq!(
1465 progress.started, progress.completed,
1466 "on_entry_complete must be called for every on_entry_start, \
1467 even when extraction fails: started={}, completed={}",
1468 progress.started, progress.completed
1469 );
1470 }
1471
1472 #[test]
1475 fn test_zip_on_entry_complete_called_on_path_traversal_error() {
1476 use crate::ProgressCallback;
1477 use crate::formats::ZipArchive;
1478 use crate::formats::traits::ArchiveFormat;
1479 use std::io::Cursor;
1480 use std::path::Path;
1481
1482 struct SymmetryTracker {
1483 started: usize,
1484 completed: usize,
1485 }
1486
1487 impl ProgressCallback for SymmetryTracker {
1488 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1489 self.started += 1;
1490 }
1491
1492 fn on_bytes_written(&mut self, _bytes: u64) {}
1493
1494 fn on_entry_complete(&mut self, _path: &Path) {
1495 self.completed += 1;
1496 }
1497
1498 fn on_complete(&mut self) {}
1499 }
1500
1501 let zip_data = make_zip_with_traversal(b"../../etc/passwd", b"evil");
1503
1504 let temp = tempfile::TempDir::new().unwrap();
1505 let mut archive = ZipArchive::new(Cursor::new(zip_data)).unwrap();
1506 let mut progress = SymmetryTracker {
1507 started: 0,
1508 completed: 0,
1509 };
1510 let result = archive.extract(
1511 temp.path(),
1512 &SecurityConfig::default().validate().unwrap(),
1513 &ExtractionOptions::default(),
1514 &mut progress,
1515 );
1516
1517 assert!(result.is_err(), "traversal entry must be rejected");
1518 assert_eq!(
1519 progress.started, progress.completed,
1520 "on_entry_complete must be called for every on_entry_start in ZIP, \
1521 even when extraction fails: started={}, completed={}",
1522 progress.started, progress.completed
1523 );
1524 }
1525
1526 fn make_raw_tar_single(path: &[u8], data: &[u8]) -> Vec<u8> {
1529 let mut out = Vec::new();
1530 let mut header = [0u8; 512];
1531
1532 let path_len = path.len().min(100);
1533 header[..path_len].copy_from_slice(&path[..path_len]);
1534 header[100..108].copy_from_slice(b"0000644\0");
1535 header[108..116].copy_from_slice(b"0000000\0");
1536 header[116..124].copy_from_slice(b"0000000\0");
1537 let size_str = format!("{:011o}\0", data.len());
1538 header[124..136].copy_from_slice(size_str.as_bytes());
1539 header[136..148].copy_from_slice(b"00000000000\0");
1540 header[156] = b'0';
1541 header[257..263].copy_from_slice(b"ustar ");
1542 header[263..265].copy_from_slice(b" \0");
1543 header[148..156].copy_from_slice(b" ");
1544 let checksum: u32 = header.iter().map(|&b| u32::from(b)).sum();
1545 let ck_str = format!("{checksum:06o}\0 ");
1546 header[148..156].copy_from_slice(ck_str.as_bytes());
1547
1548 out.extend_from_slice(&header);
1549 out.extend_from_slice(data);
1550 let rem = data.len() % 512;
1551 if rem != 0 {
1552 out.extend(std::iter::repeat_n(0u8, 512 - rem));
1553 }
1554 out.extend(std::iter::repeat_n(0u8, 1024));
1555 out
1556 }
1557
1558 fn make_zip_with_traversal(path: &[u8], data: &[u8]) -> Vec<u8> {
1561 let entry = RawEntry {
1562 name: path.to_vec(),
1563 date: 0,
1564 ..RawEntry::stored("", data)
1565 };
1566 RawZip::new(vec![entry]).build().bytes
1567 }
1568}