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::detect::ArchiveType;
16use crate::formats::detect::detect_format;
17use crate::formats::detect::detect_format_from_extension;
18use crate::formats::detect::is_zip_family_alias;
19use crate::inspection::ArchiveManifest;
20use crate::inspection::VerificationReport;
21
22pub fn extract_archive<P: AsRef<Path>, Q: AsRef<Path>>(
55 archive_path: P,
56 output_dir: Q,
57 config: &SecurityConfig,
58) -> Result<ExtractionReport> {
59 let mut noop = NoopProgress;
60 extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
61}
62
63pub fn extract_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
100 archive_path: P,
101 output_dir: Q,
102 config: &SecurityConfig,
103 progress: &mut dyn ProgressCallback,
104) -> Result<ExtractionReport> {
105 let options = ExtractionOptions::default();
106 extract_archive_with_options_and_progress(archive_path, output_dir, config, &options, progress)
107}
108
109fn extract_impl<P: AsRef<Path>, Q: AsRef<Path>>(
110 archive_path: P,
111 output_dir: Q,
112 config: &SecurityConfig,
113 options: &ExtractionOptions,
114 progress: &mut dyn ProgressCallback,
115) -> Result<ExtractionReport> {
116 let config = config.clone().validate()?;
117 let config = &config;
118
119 let archive_path = archive_path.as_ref();
120 let output_dir = output_dir.as_ref();
121
122 let format = detect_format(archive_path)?;
124
125 match format {
127 ArchiveType::Tar => {
128 extract_tar_with_decoder(archive_path, output_dir, config, options, progress, Ok)
129 }
130 ArchiveType::TarGz => {
131 extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
132 Ok(flate2::read::GzDecoder::new(r))
133 })
134 }
135 ArchiveType::TarBz2 => {
136 extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
137 Ok(bzip2::read::BzDecoder::new(r))
138 })
139 }
140 ArchiveType::TarXz => {
141 extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
142 Ok(xz2::read::XzDecoder::new(r))
143 })
144 }
145 ArchiveType::TarZst => {
146 extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
147 Ok(zstd::stream::read::Decoder::new(r)?)
148 })
149 }
150 ArchiveType::Zip => extract_zip(archive_path, output_dir, config, options, progress),
151 ArchiveType::SevenZ => extract_7z(archive_path, output_dir, config, options, progress),
152 }
153}
154
155pub fn extract_archive_with_options_and_progress<P: AsRef<Path>, Q: AsRef<Path>>(
203 archive_path: P,
204 output_dir: Q,
205 config: &SecurityConfig,
206 options: &ExtractionOptions,
207 progress: &mut dyn ProgressCallback,
208) -> Result<ExtractionReport> {
209 if options.atomic {
210 extract_atomic(archive_path, output_dir, config, options, progress)
211 } else {
212 extract_impl(archive_path, output_dir, config, options, progress)
213 }
214}
215
216pub fn extract_archive_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
247 archive_path: P,
248 output_dir: Q,
249 config: &SecurityConfig,
250 options: &ExtractionOptions,
251) -> Result<ExtractionReport> {
252 let mut noop = NoopProgress;
253 extract_archive_with_options_and_progress(archive_path, output_dir, config, options, &mut noop)
254}
255
256fn extract_atomic<P: AsRef<Path>, Q: AsRef<Path>>(
257 archive_path: P,
258 output_dir: Q,
259 config: &SecurityConfig,
260 options: &ExtractionOptions,
261 progress: &mut dyn ProgressCallback,
262) -> Result<ExtractionReport> {
263 let output_dir = output_dir.as_ref();
264
265 let canonical_output = if output_dir.exists() {
269 output_dir.canonicalize().map_err(ArchiveError::Io)?
270 } else {
271 output_dir.to_path_buf()
272 };
273
274 let parent = canonical_output
275 .parent()
276 .ok_or_else(|| ArchiveError::InvalidConfiguration {
277 reason: "output directory has no parent".into(),
278 })?;
279
280 std::fs::create_dir_all(parent).map_err(ArchiveError::Io)?;
281
282 let temp_dir = tempfile::tempdir_in(parent).map_err(|e| {
283 ArchiveError::Io(std::io::Error::new(
284 e.kind(),
285 format!(
286 "failed to create temp directory in {}: {e}",
287 parent.display()
288 ),
289 ))
290 })?;
291
292 let result = extract_impl(archive_path, temp_dir.path(), config, options, progress);
293
294 match result {
295 Ok(report) => {
296 let temp_path = temp_dir.keep();
298 std::fs::rename(&temp_path, output_dir).map_err(|e| {
299 let _ = std::fs::remove_dir_all(&temp_path);
301 if e.kind() == std::io::ErrorKind::AlreadyExists {
303 ArchiveError::OutputExists {
304 path: output_dir.to_path_buf(),
305 }
306 } else {
307 ArchiveError::Io(std::io::Error::new(
308 e.kind(),
309 format!("failed to rename temp dir to {}: {e}", output_dir.display()),
310 ))
311 }
312 })?;
313
314 Ok(report)
315 }
316 Err(e) => {
317 Err(e)
319 }
320 }
321}
322
323fn extract_tar_with_decoder<R, F>(
331 archive_path: &Path,
332 output_dir: &Path,
333 config: &SecurityConfig<Validated>,
334 options: &ExtractionOptions,
335 progress: &mut dyn ProgressCallback,
336 make_decoder: F,
337) -> Result<ExtractionReport>
338where
339 R: std::io::Read,
340 F: FnOnce(std::io::BufReader<std::fs::File>) -> Result<R>,
341{
342 use crate::formats::TarArchive;
343 use crate::formats::traits::ArchiveFormat;
344
345 let file = std::fs::File::open(archive_path)?;
346 let reader = std::io::BufReader::new(file);
347 let decoder = make_decoder(reader)?;
348 let mut archive = TarArchive::new(decoder);
349 archive.extract(output_dir, config, options, progress)
350}
351
352fn extract_zip(
353 archive_path: &Path,
354 output_dir: &Path,
355 config: &SecurityConfig<Validated>,
356 options: &ExtractionOptions,
357 progress: &mut dyn ProgressCallback,
358) -> Result<ExtractionReport> {
359 use crate::formats::ZipArchive;
360 use crate::formats::traits::ArchiveFormat;
361 use std::fs::File;
362
363 let file = File::open(archive_path)?;
364 let mut archive = ZipArchive::new(file)?;
365 archive.extract(output_dir, config, options, progress)
366}
367
368fn extract_7z(
369 archive_path: &Path,
370 output_dir: &Path,
371 config: &SecurityConfig<Validated>,
372 options: &ExtractionOptions,
373 progress: &mut dyn ProgressCallback,
374) -> Result<ExtractionReport> {
375 use crate::formats::SevenZArchive;
376 use crate::formats::traits::ArchiveFormat;
377 use std::fs::File;
378
379 let file = File::open(archive_path)?;
380 let mut archive = SevenZArchive::new(file)?;
381 archive.extract(output_dir, config, options, progress)
382}
383
384pub fn create_archive<P: AsRef<Path>, Q: AsRef<Path>>(
417 output_path: P,
418 sources: &[Q],
419 config: &CreationConfig,
420) -> Result<CreationReport> {
421 let mut noop = NoopProgress;
422 create_archive_with_progress(output_path, sources, config, &mut noop)
423}
424
425pub fn create_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
466 output_path: P,
467 sources: &[Q],
468 config: &CreationConfig,
469 progress: &mut dyn ProgressCallback,
470) -> Result<CreationReport> {
471 let config = config.clone().validate()?;
472 let config = &config;
473
474 let output = output_path.as_ref();
475
476 if config.format.is_none() {
485 reject_zip_family_creation(output)?;
486 }
487
488 let format = determine_creation_format(output, config)?;
490
491 let source_refs: Vec<&Path> = sources.iter().map(AsRef::as_ref).collect();
492 let creator = creator_for_format(format)?;
493 creator.create(output, &source_refs, config, progress)
494}
495
496fn creator_for_format(
497 format: ArchiveType,
498) -> Result<Box<dyn crate::formats::traits::FormatCreator>> {
499 match format {
500 ArchiveType::Tar => Ok(Box::new(crate::creation::TarCreator)),
501 ArchiveType::TarGz => Ok(Box::new(crate::creation::TarGzCreator)),
502 ArchiveType::TarBz2 => Ok(Box::new(crate::creation::TarBz2Creator)),
503 ArchiveType::TarXz => Ok(Box::new(crate::creation::TarXzCreator)),
504 ArchiveType::TarZst => Ok(Box::new(crate::creation::TarZstCreator)),
505 ArchiveType::Zip => Ok(Box::new(crate::creation::ZipCreator)),
506 ArchiveType::SevenZ => Err(ArchiveError::InvalidConfiguration {
507 reason: "7z archive creation is not supported".into(),
508 }),
509 }
510}
511
512pub fn list_archive<P: AsRef<Path>>(
547 archive_path: P,
548 config: &SecurityConfig,
549) -> Result<ArchiveManifest> {
550 crate::inspection::list_archive(archive_path, config)
551}
552
553pub fn verify_archive<P: AsRef<Path>>(
597 archive_path: P,
598 config: &SecurityConfig,
599) -> Result<VerificationReport> {
600 crate::inspection::verify_archive(archive_path, config)
601}
602
603fn reject_zip_family_creation(output: &Path) -> Result<()> {
609 let Some(ext) = output.extension().and_then(|e| e.to_str()) else {
610 return Ok(());
611 };
612 if is_zip_family_alias(ext) {
613 let ext_lower = ext.to_ascii_lowercase();
614 return Err(ArchiveError::InvalidArchive(format!(
615 "creation for .{ext_lower} isn't supported: the format is ZIP-based but \
616 requires extra structure (signing, manifests, ordering) that exarch \
617 doesn't produce. Use .zip, or set CreationConfig::format = Some(\
618 exarch_core::formats::detect::ArchiveType::Zip) to override."
619 )));
620 }
621 Ok(())
622}
623
624fn determine_creation_format<State>(
630 output: &Path,
631 config: &CreationConfig<State>,
632) -> Result<ArchiveType> {
633 if let Some(format) = config.format {
635 return Ok(format);
636 }
637
638 detect_format_from_extension(output)
640}
641
642#[cfg(test)]
643#[allow(clippy::unwrap_used)]
644mod tests {
645 use super::*;
646 use std::assert_matches;
647 use std::path::PathBuf;
648
649 #[test]
650 fn test_extract_archive_nonexistent_file() {
651 let config = SecurityConfig::default();
652 let result = extract_archive(
653 PathBuf::from("nonexistent_test.tar"),
654 PathBuf::from("/tmp/test"),
655 &config,
656 );
657 assert!(result.is_err());
659 }
660
661 #[test]
662 fn test_determine_creation_format_tar() {
663 let config = CreationConfig::default();
664 let path = PathBuf::from("archive.tar");
665 let format = determine_creation_format(&path, &config).unwrap();
666 assert_eq!(format, ArchiveType::Tar);
667 }
668
669 #[test]
670 fn test_determine_creation_format_tar_gz() {
671 let config = CreationConfig::default();
672 let path = PathBuf::from("archive.tar.gz");
673 let format = determine_creation_format(&path, &config).unwrap();
674 assert_eq!(format, ArchiveType::TarGz);
675
676 let path2 = PathBuf::from("archive.tgz");
677 let format2 = determine_creation_format(&path2, &config).unwrap();
678 assert_eq!(format2, ArchiveType::TarGz);
679 }
680
681 #[test]
682 fn test_determine_creation_format_tar_bz2() {
683 let config = CreationConfig::default();
684 let path = PathBuf::from("archive.tar.bz2");
685 let format = determine_creation_format(&path, &config).unwrap();
686 assert_eq!(format, ArchiveType::TarBz2);
687 }
688
689 #[test]
690 fn test_determine_creation_format_tar_xz() {
691 let config = CreationConfig::default();
692 let path = PathBuf::from("archive.tar.xz");
693 let format = determine_creation_format(&path, &config).unwrap();
694 assert_eq!(format, ArchiveType::TarXz);
695 }
696
697 #[test]
698 fn test_determine_creation_format_tar_zst() {
699 let config = CreationConfig::default();
700 let path = PathBuf::from("archive.tar.zst");
701 let format = determine_creation_format(&path, &config).unwrap();
702 assert_eq!(format, ArchiveType::TarZst);
703 }
704
705 #[test]
706 fn test_determine_creation_format_zip() {
707 let config = CreationConfig::default();
708 let path = PathBuf::from("archive.zip");
709 let format = determine_creation_format(&path, &config).unwrap();
710 assert_eq!(format, ArchiveType::Zip);
711 }
712
713 #[test]
714 fn test_determine_creation_format_explicit() {
715 let config = CreationConfig::default().with_format(Some(ArchiveType::TarGz));
716 let path = PathBuf::from("archive.xyz");
717 let format = determine_creation_format(&path, &config).unwrap();
718 assert_eq!(format, ArchiveType::TarGz);
719 }
720
721 #[test]
722 fn test_determine_creation_format_unknown() {
723 let config = CreationConfig::default();
724 let path = PathBuf::from("archive.rar");
725 let result = determine_creation_format(&path, &config);
726 assert!(result.is_err());
727 }
728
729 #[test]
730 fn test_determine_creation_format_ignores_stale_magic_bytes() {
731 let dir = tempfile::tempdir().unwrap();
734 let path = dir.path().join("backup.zip");
735 std::fs::write(&path, b"\x1f\x8b\x08\x00\x00\x00\x00\x00").unwrap();
737
738 let config = CreationConfig::default();
739 let format = determine_creation_format(&path, &config).unwrap();
740 assert_eq!(
741 format,
742 ArchiveType::Zip,
743 "creation format must follow extension, not stale on-disk magic bytes"
744 );
745 }
746
747 #[test]
748 fn test_extract_archive_7z_not_implemented() {
749 let dest = tempfile::TempDir::new().unwrap();
750 let path = PathBuf::from("test.7z");
751
752 let result = extract_archive(&path, dest.path(), &SecurityConfig::default());
753
754 assert!(result.is_err());
755 }
756
757 #[test]
758 fn test_create_archive_invalid_compression_level_rejected_before_io() {
759 for ext in ["tar.gz", "tar.bz2", "tar.xz", "tar.zst"] {
760 let dest = tempfile::TempDir::new().unwrap();
761 let archive_path = dest.path().join(format!("output.{ext}"));
762 let mut config = CreationConfig::default();
763 config.compression_level = Some(200);
764 let result = create_archive(&archive_path, &[] as &[&str], &config);
765 assert_matches!(
766 result,
767 Err(ArchiveError::InvalidCompressionLevel { level: 200 }),
768 "{ext}: expected InvalidCompressionLevel, got {result:?}",
769 );
770 assert!(
772 !archive_path.exists(),
773 "{ext}: output file must not be created"
774 );
775 }
776 }
777
778 #[test]
779 fn test_create_archive_zip_family_not_supported() {
780 let dest = tempfile::TempDir::new().unwrap();
784 for ext in ["apk", "whl", "EPUB"] {
785 let archive_path = dest.path().join(format!("output.{ext}"));
786 let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
787 assert_matches!(
788 result,
789 Err(ArchiveError::InvalidArchive(_)),
790 ".{ext} should be rejected, got {result:?}",
791 );
792 }
793 }
794
795 #[test]
796 fn test_create_archive_zip_family_override_bypasses_guard() {
797 let dest = tempfile::TempDir::new().unwrap();
801 let src = dest.path().join("source.txt");
802 std::fs::write(&src, b"hello").unwrap();
803 let archive_path = dest.path().join("output.apk");
804 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
805 let result = create_archive(&archive_path, &[&src], &config);
806 assert!(
807 result.is_ok(),
808 "explicit format override should bypass the guard, got {result:?}",
809 );
810 }
811
812 #[test]
813 fn test_create_archive_7z_not_supported() {
814 let dest = tempfile::TempDir::new().unwrap();
815 let archive_path = dest.path().join("output.7z");
816
817 let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
818
819 assert!(result.is_err());
820 assert_matches!(
821 result.unwrap_err(),
822 ArchiveError::InvalidConfiguration { .. }
823 );
824 }
825
826 #[test]
827 fn test_extract_archive_with_options_and_progress_non_atomic_delegates_to_normal() {
828 let dest = tempfile::TempDir::new().unwrap();
829 let options = ExtractionOptions {
830 atomic: false,
831 skip_duplicates: true,
832 };
833 let result = extract_archive_with_options_and_progress(
834 PathBuf::from("nonexistent.tar.gz"),
835 dest.path(),
836 &SecurityConfig::default(),
837 &options,
838 &mut NoopProgress,
839 );
840 assert!(result.is_err());
841 }
842
843 #[test]
844 fn test_extract_archive_with_options_delegates() {
845 let dest = tempfile::TempDir::new().unwrap();
846 let options = ExtractionOptions {
847 atomic: false,
848 skip_duplicates: true,
849 };
850 let result = extract_archive_with_options(
851 PathBuf::from("nonexistent.tar.gz"),
852 dest.path(),
853 &SecurityConfig::default(),
854 &options,
855 );
856 assert!(result.is_err());
857 }
858
859 #[test]
860 fn test_extract_atomic_success() {
861 use crate::create_archive;
862 use crate::creation::CreationConfig;
863
864 let archive_dir = tempfile::TempDir::new().unwrap();
866 let archive_path = archive_dir.path().join("test.tar.gz");
867
868 let src_dir = tempfile::TempDir::new().unwrap();
870 std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
871 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
872
873 let parent = tempfile::TempDir::new().unwrap();
874 let output_dir = parent.path().join("extracted");
875
876 let options = ExtractionOptions {
877 atomic: true,
878 skip_duplicates: true,
879 };
880 let result = extract_archive_with_options(
881 &archive_path,
882 &output_dir,
883 &SecurityConfig::default(),
884 &options,
885 );
886
887 assert!(result.is_ok());
888 assert!(output_dir.exists());
889 let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
891 assert_eq!(
892 temp_entries.len(),
893 1,
894 "Expected only the output dir, found temp remnants"
895 );
896 }
897
898 #[test]
899 fn test_extract_atomic_failure_cleans_up() {
900 let parent = tempfile::TempDir::new().unwrap();
901 let output_dir = parent.path().join("extracted");
902
903 let options = ExtractionOptions {
904 atomic: true,
905 skip_duplicates: true,
906 };
907 let result = extract_archive_with_options(
908 PathBuf::from("nonexistent_archive.tar.gz"),
909 &output_dir,
910 &SecurityConfig::default(),
911 &options,
912 );
913
914 assert!(result.is_err());
915 assert!(!output_dir.exists());
917 let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
919 assert!(
920 temp_entries.is_empty(),
921 "Temp dir not cleaned up after failure"
922 );
923 }
924
925 #[test]
926 fn test_extract_atomic_output_already_exists_fails() {
927 use crate::create_archive;
928 use crate::creation::CreationConfig;
929
930 let parent = tempfile::TempDir::new().unwrap();
931 let output_dir = parent.path().join("extracted");
932 std::fs::create_dir_all(&output_dir).unwrap();
933 std::fs::write(output_dir.join("existing.txt"), b"old content").unwrap();
936
937 let archive_dir = tempfile::TempDir::new().unwrap();
938 let archive_path = archive_dir.path().join("test.tar.gz");
939 let src_dir = tempfile::TempDir::new().unwrap();
940 std::fs::write(src_dir.path().join("new.txt"), b"new content").unwrap();
941 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
942
943 let options = ExtractionOptions {
944 atomic: true,
945 skip_duplicates: true,
946 };
947 let result = extract_archive_with_options(
948 &archive_path,
949 &output_dir,
950 &SecurityConfig::default(),
951 &options,
952 );
953
954 assert!(result.is_err());
957 assert!(output_dir.join("existing.txt").exists());
959 }
960
961 #[test]
963 fn test_progress_callback_invoked_during_extraction() {
964 use crate::ProgressCallback;
965 use std::path::Path;
966
967 struct TrackingProgress {
968 started: usize,
969 completed: usize,
970 finished: bool,
971 }
972
973 impl ProgressCallback for TrackingProgress {
974 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
975 self.started += 1;
976 }
977
978 fn on_bytes_written(&mut self, _bytes: u64) {}
979
980 fn on_entry_complete(&mut self, _path: &Path) {
981 self.completed += 1;
982 }
983
984 fn on_complete(&mut self) {
985 self.finished = true;
986 }
987 }
988
989 let archive_dir = tempfile::TempDir::new().unwrap();
990 let archive_path = archive_dir.path().join("test.tar.gz");
991 let src_dir = tempfile::TempDir::new().unwrap();
992 std::fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
993 std::fs::write(src_dir.path().join("b.txt"), b"world").unwrap();
994 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
995
996 let dest = tempfile::TempDir::new().unwrap();
997 let mut progress = TrackingProgress {
998 started: 0,
999 completed: 0,
1000 finished: false,
1001 };
1002
1003 let report = extract_archive_with_progress(
1004 &archive_path,
1005 dest.path(),
1006 &SecurityConfig::default(),
1007 &mut progress,
1008 )
1009 .unwrap();
1010
1011 assert!(report.files_extracted >= 2, "expected at least 2 files");
1012 assert!(progress.started >= 2, "on_entry_start not called");
1013 assert!(progress.completed >= 2, "on_entry_complete not called");
1014 assert!(progress.finished, "on_complete not called");
1015 }
1016
1017 #[test]
1019 fn test_progress_callback_invoked_during_zip_extraction() {
1020 use crate::ProgressCallback;
1021 use std::path::Path;
1022
1023 struct TrackingProgress {
1024 started: usize,
1025 completed: usize,
1026 finished: bool,
1027 }
1028
1029 impl ProgressCallback for TrackingProgress {
1030 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1031 self.started += 1;
1032 }
1033
1034 fn on_bytes_written(&mut self, _bytes: u64) {}
1035
1036 fn on_entry_complete(&mut self, _path: &Path) {
1037 self.completed += 1;
1038 }
1039
1040 fn on_complete(&mut self) {
1041 self.finished = true;
1042 }
1043 }
1044
1045 let tmp = tempfile::TempDir::new().unwrap();
1046 let archive_path = tmp.path().join("test.zip");
1047 let src_dir = tempfile::TempDir::new().unwrap();
1048 std::fs::write(src_dir.path().join("x.txt"), b"foo").unwrap();
1049 std::fs::write(src_dir.path().join("y.txt"), b"bar").unwrap();
1050 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1051 create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1052
1053 let dest = tempfile::TempDir::new().unwrap();
1054 let mut progress = TrackingProgress {
1055 started: 0,
1056 completed: 0,
1057 finished: false,
1058 };
1059 let report = extract_archive_with_progress(
1060 &archive_path,
1061 dest.path(),
1062 &SecurityConfig::default(),
1063 &mut progress,
1064 )
1065 .unwrap();
1066
1067 assert!(report.files_extracted >= 2, "expected at least 2 files");
1068 assert!(progress.started >= 2, "on_entry_start not called for ZIP");
1069 assert!(
1070 progress.completed >= 2,
1071 "on_entry_complete not called for ZIP"
1072 );
1073 assert!(progress.finished, "on_complete not called for ZIP");
1074 }
1075
1076 #[test]
1078 fn test_progress_callback_invoked_during_sevenz_extraction() {
1079 use crate::ProgressCallback;
1080 use std::path::Path;
1081
1082 struct TrackingProgress {
1083 started: usize,
1084 completed: usize,
1085 finished: bool,
1086 }
1087
1088 impl ProgressCallback for TrackingProgress {
1089 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1090 self.started += 1;
1091 }
1092
1093 fn on_bytes_written(&mut self, _bytes: u64) {}
1094
1095 fn on_entry_complete(&mut self, _path: &Path) {
1096 self.completed += 1;
1097 }
1098
1099 fn on_complete(&mut self) {
1100 self.finished = true;
1101 }
1102 }
1103
1104 let fixture =
1105 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1106
1107 let dest = tempfile::TempDir::new().unwrap();
1108 let mut progress = TrackingProgress {
1109 started: 0,
1110 completed: 0,
1111 finished: false,
1112 };
1113 let report = extract_archive_with_progress(
1114 &fixture,
1115 dest.path(),
1116 &SecurityConfig::default(),
1117 &mut progress,
1118 )
1119 .unwrap();
1120
1121 assert!(
1122 report.files_extracted >= 1,
1123 "expected at least 1 file from simple.7z"
1124 );
1125 assert!(progress.started >= 1, "on_entry_start not called for 7z");
1126 assert!(
1127 progress.completed >= 1,
1128 "on_entry_complete not called for 7z"
1129 );
1130 assert!(progress.finished, "on_complete not called for 7z");
1131 }
1132
1133 #[test]
1136 fn test_on_bytes_written_called_for_tar() {
1137 use crate::ProgressCallback;
1138 use std::path::Path;
1139
1140 struct ByteTracker {
1141 total: u64,
1142 }
1143
1144 impl ProgressCallback for ByteTracker {
1145 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1146
1147 fn on_bytes_written(&mut self, bytes: u64) {
1148 self.total += bytes;
1149 }
1150
1151 fn on_entry_complete(&mut self, _path: &Path) {}
1152
1153 fn on_complete(&mut self) {}
1154 }
1155
1156 let archive_dir = tempfile::TempDir::new().unwrap();
1157 let archive_path = archive_dir.path().join("test.tar.gz");
1158 let src_dir = tempfile::TempDir::new().unwrap();
1159 std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
1160 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
1161
1162 let dest = tempfile::TempDir::new().unwrap();
1163 let mut progress = ByteTracker { total: 0 };
1164 let report = extract_archive_with_progress(
1165 &archive_path,
1166 dest.path(),
1167 &SecurityConfig::default(),
1168 &mut progress,
1169 )
1170 .unwrap();
1171
1172 assert!(
1173 report.bytes_written > 0,
1174 "report.bytes_written must be > 0, got {}",
1175 report.bytes_written
1176 );
1177 assert!(
1178 progress.total > 0,
1179 "on_bytes_written must be called with > 0 bytes for TAR, got {}",
1180 progress.total
1181 );
1182 }
1183
1184 #[test]
1187 fn test_on_bytes_written_called_for_zip() {
1188 use crate::ProgressCallback;
1189 use std::path::Path;
1190
1191 struct ByteTracker {
1192 total: u64,
1193 }
1194
1195 impl ProgressCallback for ByteTracker {
1196 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1197
1198 fn on_bytes_written(&mut self, bytes: u64) {
1199 self.total += bytes;
1200 }
1201
1202 fn on_entry_complete(&mut self, _path: &Path) {}
1203
1204 fn on_complete(&mut self) {}
1205 }
1206
1207 let tmp = tempfile::TempDir::new().unwrap();
1208 let archive_path = tmp.path().join("test.zip");
1209 let src_dir = tempfile::TempDir::new().unwrap();
1210 std::fs::write(src_dir.path().join("data.txt"), b"hello world").unwrap();
1211 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1212 create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1213
1214 let dest = tempfile::TempDir::new().unwrap();
1215 let mut progress = ByteTracker { total: 0 };
1216 let report = extract_archive_with_progress(
1217 &archive_path,
1218 dest.path(),
1219 &SecurityConfig::default(),
1220 &mut progress,
1221 )
1222 .unwrap();
1223
1224 assert!(
1225 report.bytes_written > 0,
1226 "report.bytes_written must be > 0, got {}",
1227 report.bytes_written
1228 );
1229 assert!(
1230 progress.total > 0,
1231 "on_bytes_written must be called with > 0 bytes for ZIP, got {}",
1232 progress.total
1233 );
1234 }
1235
1236 #[test]
1239 fn test_on_bytes_written_called_for_sevenz() {
1240 use crate::ProgressCallback;
1241 use std::path::Path;
1242
1243 struct ByteTracker {
1244 total: u64,
1245 }
1246
1247 impl ProgressCallback for ByteTracker {
1248 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1249
1250 fn on_bytes_written(&mut self, bytes: u64) {
1251 self.total += bytes;
1252 }
1253
1254 fn on_entry_complete(&mut self, _path: &Path) {}
1255
1256 fn on_complete(&mut self) {}
1257 }
1258
1259 let fixture =
1260 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1261
1262 let dest = tempfile::TempDir::new().unwrap();
1263 let mut progress = ByteTracker { total: 0 };
1264 let report = extract_archive_with_progress(
1265 &fixture,
1266 dest.path(),
1267 &SecurityConfig::default(),
1268 &mut progress,
1269 )
1270 .unwrap();
1271
1272 assert!(
1273 report.bytes_written > 0,
1274 "report.bytes_written must be > 0, got {}",
1275 report.bytes_written
1276 );
1277 assert!(
1278 progress.total > 0,
1279 "on_bytes_written must be called with > 0 bytes for 7z, got {}",
1280 progress.total
1281 );
1282 }
1283
1284 #[test]
1287 fn test_tar_hardlink_calls_on_bytes_written() {
1288 use crate::ProgressCallback;
1289 use crate::formats::TarArchive;
1290 use crate::formats::traits::ArchiveFormat;
1291 use std::io::Cursor;
1292 use std::path::Path;
1293
1294 struct ByteTracker {
1295 total: u64,
1296 }
1297
1298 impl ProgressCallback for ByteTracker {
1299 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1300
1301 fn on_bytes_written(&mut self, bytes: u64) {
1302 self.total += bytes;
1303 }
1304
1305 fn on_entry_complete(&mut self, _path: &Path) {}
1306
1307 fn on_complete(&mut self) {}
1308 }
1309
1310 let content = b"hello hardlink";
1312 let tar_data = {
1313 let mut builder = tar::Builder::new(Vec::new());
1314
1315 let mut header = tar::Header::new_gnu();
1316 header.set_size(content.len() as u64);
1317 header.set_mode(0o644);
1318 header.set_entry_type(tar::EntryType::Regular);
1319 header.set_cksum();
1320 builder
1321 .append_data(&mut header, "original.txt", content.as_ref())
1322 .unwrap();
1323
1324 let mut hdr = tar::Header::new_gnu();
1325 hdr.set_size(0);
1326 hdr.set_mode(0o644);
1327 hdr.set_entry_type(tar::EntryType::Link);
1328 hdr.set_link_name("original.txt").unwrap();
1329 hdr.set_cksum();
1330 builder
1331 .append_data(&mut hdr, "link.txt", std::io::empty())
1332 .unwrap();
1333
1334 builder.into_inner().unwrap()
1335 };
1336
1337 let temp = tempfile::TempDir::new().unwrap();
1338 let mut config = SecurityConfig::default();
1339 config.allowed.hardlinks = true;
1340 let config = config.validate().unwrap();
1341
1342 let mut archive = TarArchive::new(Cursor::new(tar_data));
1343 let mut progress = ByteTracker { total: 0 };
1344 let report = archive
1345 .extract(
1346 temp.path(),
1347 &config,
1348 &ExtractionOptions::default(),
1349 &mut progress,
1350 )
1351 .unwrap();
1352
1353 let expected = (content.len() as u64) * 2;
1356 assert_eq!(
1357 progress.total, expected,
1358 "on_bytes_written must report bytes for both original and hardlink copy, \
1359 got {} (report.bytes_written={})",
1360 progress.total, report.bytes_written
1361 );
1362 }
1363
1364 #[test]
1367 fn test_tar_on_entry_complete_called_on_path_traversal_error() {
1368 use crate::ProgressCallback;
1369 use crate::formats::TarArchive;
1370 use crate::formats::traits::ArchiveFormat;
1371 use std::io::Cursor;
1372 use std::path::Path;
1373
1374 struct SymmetryTracker {
1375 started: usize,
1376 completed: usize,
1377 }
1378
1379 impl ProgressCallback for SymmetryTracker {
1380 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1381 self.started += 1;
1382 }
1383
1384 fn on_bytes_written(&mut self, _bytes: u64) {}
1385
1386 fn on_entry_complete(&mut self, _path: &Path) {
1387 self.completed += 1;
1388 }
1389
1390 fn on_complete(&mut self) {}
1391 }
1392
1393 let tar_data = make_raw_tar_single(b"../../etc/passwd", b"evil");
1396
1397 let temp = tempfile::TempDir::new().unwrap();
1398 let mut archive = TarArchive::new(Cursor::new(tar_data));
1399 let mut progress = SymmetryTracker {
1400 started: 0,
1401 completed: 0,
1402 };
1403 let result = archive.extract(
1404 temp.path(),
1405 &SecurityConfig::default().validate().unwrap(),
1406 &ExtractionOptions::default(),
1407 &mut progress,
1408 );
1409
1410 assert!(result.is_err(), "traversal entry must be rejected");
1411 assert_eq!(
1412 progress.started, progress.completed,
1413 "on_entry_complete must be called for every on_entry_start, \
1414 even when extraction fails: started={}, completed={}",
1415 progress.started, progress.completed
1416 );
1417 }
1418
1419 #[test]
1422 fn test_zip_on_entry_complete_called_on_path_traversal_error() {
1423 use crate::ProgressCallback;
1424 use crate::formats::ZipArchive;
1425 use crate::formats::traits::ArchiveFormat;
1426 use std::io::Cursor;
1427 use std::path::Path;
1428
1429 struct SymmetryTracker {
1430 started: usize,
1431 completed: usize,
1432 }
1433
1434 impl ProgressCallback for SymmetryTracker {
1435 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1436 self.started += 1;
1437 }
1438
1439 fn on_bytes_written(&mut self, _bytes: u64) {}
1440
1441 fn on_entry_complete(&mut self, _path: &Path) {
1442 self.completed += 1;
1443 }
1444
1445 fn on_complete(&mut self) {}
1446 }
1447
1448 let zip_data = make_zip_with_traversal(b"../../etc/passwd", b"evil");
1450
1451 let temp = tempfile::TempDir::new().unwrap();
1452 let mut archive = ZipArchive::new(Cursor::new(zip_data)).unwrap();
1453 let mut progress = SymmetryTracker {
1454 started: 0,
1455 completed: 0,
1456 };
1457 let result = archive.extract(
1458 temp.path(),
1459 &SecurityConfig::default().validate().unwrap(),
1460 &ExtractionOptions::default(),
1461 &mut progress,
1462 );
1463
1464 assert!(result.is_err(), "traversal entry must be rejected");
1465 assert_eq!(
1466 progress.started, progress.completed,
1467 "on_entry_complete must be called for every on_entry_start in ZIP, \
1468 even when extraction fails: started={}, completed={}",
1469 progress.started, progress.completed
1470 );
1471 }
1472
1473 fn make_raw_tar_single(path: &[u8], data: &[u8]) -> Vec<u8> {
1476 let mut out = Vec::new();
1477 let mut header = [0u8; 512];
1478
1479 let path_len = path.len().min(100);
1480 header[..path_len].copy_from_slice(&path[..path_len]);
1481 header[100..108].copy_from_slice(b"0000644\0");
1482 header[108..116].copy_from_slice(b"0000000\0");
1483 header[116..124].copy_from_slice(b"0000000\0");
1484 let size_str = format!("{:011o}\0", data.len());
1485 header[124..136].copy_from_slice(size_str.as_bytes());
1486 header[136..148].copy_from_slice(b"00000000000\0");
1487 header[156] = b'0';
1488 header[257..263].copy_from_slice(b"ustar ");
1489 header[263..265].copy_from_slice(b" \0");
1490 header[148..156].copy_from_slice(b" ");
1491 let checksum: u32 = header.iter().map(|&b| u32::from(b)).sum();
1492 let ck_str = format!("{checksum:06o}\0 ");
1493 header[148..156].copy_from_slice(ck_str.as_bytes());
1494
1495 out.extend_from_slice(&header);
1496 out.extend_from_slice(data);
1497 let rem = data.len() % 512;
1498 if rem != 0 {
1499 out.extend(std::iter::repeat_n(0u8, 512 - rem));
1500 }
1501 out.extend(std::iter::repeat_n(0u8, 1024));
1502 out
1503 }
1504
1505 #[allow(clippy::cast_possible_truncation)]
1508 fn make_zip_with_traversal(path: &[u8], data: &[u8]) -> Vec<u8> {
1509 let mut buf: Vec<u8> = Vec::new();
1510
1511 let crc = crc32_ieee(data);
1512 let name_len = path.len() as u16;
1513 let content_len = data.len() as u32;
1514
1515 let local_offset: u32 = 0;
1516
1517 buf.extend_from_slice(b"PK\x03\x04");
1519 buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
1525 buf.extend_from_slice(&content_len.to_le_bytes());
1526 buf.extend_from_slice(&content_len.to_le_bytes());
1527 buf.extend_from_slice(&name_len.to_le_bytes());
1528 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(path);
1530 buf.extend_from_slice(data);
1531
1532 let central_dir_offset = buf.len() as u32;
1533
1534 buf.extend_from_slice(b"PK\x01\x02");
1536 buf.extend_from_slice(&0x031eu16.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
1543 buf.extend_from_slice(&content_len.to_le_bytes());
1544 buf.extend_from_slice(&content_len.to_le_bytes());
1545 buf.extend_from_slice(&name_len.to_le_bytes());
1546 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&(0o100_644u32 << 16).to_le_bytes()); buf.extend_from_slice(&local_offset.to_le_bytes());
1552 buf.extend_from_slice(path);
1553
1554 let central_dir_size = (buf.len() as u32) - central_dir_offset;
1555
1556 buf.extend_from_slice(b"PK\x05\x06");
1558 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(¢ral_dir_size.to_le_bytes());
1563 buf.extend_from_slice(¢ral_dir_offset.to_le_bytes());
1564 buf.extend_from_slice(&0u16.to_le_bytes()); buf
1566 }
1567
1568 fn crc32_ieee(data: &[u8]) -> u32 {
1570 let mut crc: u32 = 0xFFFF_FFFF;
1571 for &byte in data {
1572 let mut val = crc ^ u32::from(byte);
1573 for _ in 0..8 {
1574 let mask = (val & 1).wrapping_neg();
1575 val = (val >> 1) ^ (0xEDB8_8320 & mask);
1576 }
1577 crc = val;
1578 }
1579 !crc
1580 }
1581}