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());
956 assert!(output_dir.join("existing.txt").exists());
958 }
959
960 #[test]
962 fn test_progress_callback_invoked_during_extraction() {
963 use crate::ProgressCallback;
964 use std::path::Path;
965
966 struct TrackingProgress {
967 started: usize,
968 completed: usize,
969 finished: bool,
970 }
971
972 impl ProgressCallback for TrackingProgress {
973 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
974 self.started += 1;
975 }
976
977 fn on_bytes_written(&mut self, _bytes: u64) {}
978
979 fn on_entry_complete(&mut self, _path: &Path) {
980 self.completed += 1;
981 }
982
983 fn on_complete(&mut self) {
984 self.finished = true;
985 }
986 }
987
988 let archive_dir = tempfile::TempDir::new().unwrap();
989 let archive_path = archive_dir.path().join("test.tar.gz");
990 let src_dir = tempfile::TempDir::new().unwrap();
991 std::fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
992 std::fs::write(src_dir.path().join("b.txt"), b"world").unwrap();
993 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
994
995 let dest = tempfile::TempDir::new().unwrap();
996 let mut progress = TrackingProgress {
997 started: 0,
998 completed: 0,
999 finished: false,
1000 };
1001
1002 let report = extract_archive_with_progress(
1003 &archive_path,
1004 dest.path(),
1005 &SecurityConfig::default(),
1006 &mut progress,
1007 )
1008 .unwrap();
1009
1010 assert!(report.files_extracted >= 2, "expected at least 2 files");
1011 assert!(progress.started >= 2, "on_entry_start not called");
1012 assert!(progress.completed >= 2, "on_entry_complete not called");
1013 assert!(progress.finished, "on_complete not called");
1014 }
1015
1016 #[test]
1018 fn test_progress_callback_invoked_during_zip_extraction() {
1019 use crate::ProgressCallback;
1020 use std::path::Path;
1021
1022 struct TrackingProgress {
1023 started: usize,
1024 completed: usize,
1025 finished: bool,
1026 }
1027
1028 impl ProgressCallback for TrackingProgress {
1029 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1030 self.started += 1;
1031 }
1032
1033 fn on_bytes_written(&mut self, _bytes: u64) {}
1034
1035 fn on_entry_complete(&mut self, _path: &Path) {
1036 self.completed += 1;
1037 }
1038
1039 fn on_complete(&mut self) {
1040 self.finished = true;
1041 }
1042 }
1043
1044 let tmp = tempfile::TempDir::new().unwrap();
1045 let archive_path = tmp.path().join("test.zip");
1046 let src_dir = tempfile::TempDir::new().unwrap();
1047 std::fs::write(src_dir.path().join("x.txt"), b"foo").unwrap();
1048 std::fs::write(src_dir.path().join("y.txt"), b"bar").unwrap();
1049 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1050 create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1051
1052 let dest = tempfile::TempDir::new().unwrap();
1053 let mut progress = TrackingProgress {
1054 started: 0,
1055 completed: 0,
1056 finished: false,
1057 };
1058 let report = extract_archive_with_progress(
1059 &archive_path,
1060 dest.path(),
1061 &SecurityConfig::default(),
1062 &mut progress,
1063 )
1064 .unwrap();
1065
1066 assert!(report.files_extracted >= 2, "expected at least 2 files");
1067 assert!(progress.started >= 2, "on_entry_start not called for ZIP");
1068 assert!(
1069 progress.completed >= 2,
1070 "on_entry_complete not called for ZIP"
1071 );
1072 assert!(progress.finished, "on_complete not called for ZIP");
1073 }
1074
1075 #[test]
1077 fn test_progress_callback_invoked_during_sevenz_extraction() {
1078 use crate::ProgressCallback;
1079 use std::path::Path;
1080
1081 struct TrackingProgress {
1082 started: usize,
1083 completed: usize,
1084 finished: bool,
1085 }
1086
1087 impl ProgressCallback for TrackingProgress {
1088 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1089 self.started += 1;
1090 }
1091
1092 fn on_bytes_written(&mut self, _bytes: u64) {}
1093
1094 fn on_entry_complete(&mut self, _path: &Path) {
1095 self.completed += 1;
1096 }
1097
1098 fn on_complete(&mut self) {
1099 self.finished = true;
1100 }
1101 }
1102
1103 let fixture =
1104 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
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 &fixture,
1114 dest.path(),
1115 &SecurityConfig::default(),
1116 &mut progress,
1117 )
1118 .unwrap();
1119
1120 assert!(
1121 report.files_extracted >= 1,
1122 "expected at least 1 file from simple.7z"
1123 );
1124 assert!(progress.started >= 1, "on_entry_start not called for 7z");
1125 assert!(
1126 progress.completed >= 1,
1127 "on_entry_complete not called for 7z"
1128 );
1129 assert!(progress.finished, "on_complete not called for 7z");
1130 }
1131
1132 #[test]
1135 fn test_on_bytes_written_called_for_tar() {
1136 use crate::ProgressCallback;
1137 use std::path::Path;
1138
1139 struct ByteTracker {
1140 total: u64,
1141 }
1142
1143 impl ProgressCallback for ByteTracker {
1144 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1145
1146 fn on_bytes_written(&mut self, bytes: u64) {
1147 self.total += bytes;
1148 }
1149
1150 fn on_entry_complete(&mut self, _path: &Path) {}
1151
1152 fn on_complete(&mut self) {}
1153 }
1154
1155 let archive_dir = tempfile::TempDir::new().unwrap();
1156 let archive_path = archive_dir.path().join("test.tar.gz");
1157 let src_dir = tempfile::TempDir::new().unwrap();
1158 std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
1159 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
1160
1161 let dest = tempfile::TempDir::new().unwrap();
1162 let mut progress = ByteTracker { total: 0 };
1163 let report = extract_archive_with_progress(
1164 &archive_path,
1165 dest.path(),
1166 &SecurityConfig::default(),
1167 &mut progress,
1168 )
1169 .unwrap();
1170
1171 assert!(
1172 report.bytes_written > 0,
1173 "report.bytes_written must be > 0, got {}",
1174 report.bytes_written
1175 );
1176 assert!(
1177 progress.total > 0,
1178 "on_bytes_written must be called with > 0 bytes for TAR, got {}",
1179 progress.total
1180 );
1181 }
1182
1183 #[test]
1186 fn test_on_bytes_written_called_for_zip() {
1187 use crate::ProgressCallback;
1188 use std::path::Path;
1189
1190 struct ByteTracker {
1191 total: u64,
1192 }
1193
1194 impl ProgressCallback for ByteTracker {
1195 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1196
1197 fn on_bytes_written(&mut self, bytes: u64) {
1198 self.total += bytes;
1199 }
1200
1201 fn on_entry_complete(&mut self, _path: &Path) {}
1202
1203 fn on_complete(&mut self) {}
1204 }
1205
1206 let tmp = tempfile::TempDir::new().unwrap();
1207 let archive_path = tmp.path().join("test.zip");
1208 let src_dir = tempfile::TempDir::new().unwrap();
1209 std::fs::write(src_dir.path().join("data.txt"), b"hello world").unwrap();
1210 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1211 create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1212
1213 let dest = tempfile::TempDir::new().unwrap();
1214 let mut progress = ByteTracker { total: 0 };
1215 let report = extract_archive_with_progress(
1216 &archive_path,
1217 dest.path(),
1218 &SecurityConfig::default(),
1219 &mut progress,
1220 )
1221 .unwrap();
1222
1223 assert!(
1224 report.bytes_written > 0,
1225 "report.bytes_written must be > 0, got {}",
1226 report.bytes_written
1227 );
1228 assert!(
1229 progress.total > 0,
1230 "on_bytes_written must be called with > 0 bytes for ZIP, got {}",
1231 progress.total
1232 );
1233 }
1234
1235 #[test]
1238 fn test_on_bytes_written_called_for_sevenz() {
1239 use crate::ProgressCallback;
1240 use std::path::Path;
1241
1242 struct ByteTracker {
1243 total: u64,
1244 }
1245
1246 impl ProgressCallback for ByteTracker {
1247 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1248
1249 fn on_bytes_written(&mut self, bytes: u64) {
1250 self.total += bytes;
1251 }
1252
1253 fn on_entry_complete(&mut self, _path: &Path) {}
1254
1255 fn on_complete(&mut self) {}
1256 }
1257
1258 let fixture =
1259 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1260
1261 let dest = tempfile::TempDir::new().unwrap();
1262 let mut progress = ByteTracker { total: 0 };
1263 let report = extract_archive_with_progress(
1264 &fixture,
1265 dest.path(),
1266 &SecurityConfig::default(),
1267 &mut progress,
1268 )
1269 .unwrap();
1270
1271 assert!(
1272 report.bytes_written > 0,
1273 "report.bytes_written must be > 0, got {}",
1274 report.bytes_written
1275 );
1276 assert!(
1277 progress.total > 0,
1278 "on_bytes_written must be called with > 0 bytes for 7z, got {}",
1279 progress.total
1280 );
1281 }
1282
1283 #[test]
1286 fn test_tar_hardlink_calls_on_bytes_written() {
1287 use crate::ProgressCallback;
1288 use crate::formats::TarArchive;
1289 use crate::formats::traits::ArchiveFormat;
1290 use std::io::Cursor;
1291 use std::path::Path;
1292
1293 struct ByteTracker {
1294 total: u64,
1295 }
1296
1297 impl ProgressCallback for ByteTracker {
1298 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1299
1300 fn on_bytes_written(&mut self, bytes: u64) {
1301 self.total += bytes;
1302 }
1303
1304 fn on_entry_complete(&mut self, _path: &Path) {}
1305
1306 fn on_complete(&mut self) {}
1307 }
1308
1309 let content = b"hello hardlink";
1311 let tar_data = {
1312 let mut builder = tar::Builder::new(Vec::new());
1313
1314 let mut header = tar::Header::new_gnu();
1315 header.set_size(content.len() as u64);
1316 header.set_mode(0o644);
1317 header.set_entry_type(tar::EntryType::Regular);
1318 header.set_cksum();
1319 builder
1320 .append_data(&mut header, "original.txt", content.as_ref())
1321 .unwrap();
1322
1323 let mut hdr = tar::Header::new_gnu();
1324 hdr.set_size(0);
1325 hdr.set_mode(0o644);
1326 hdr.set_entry_type(tar::EntryType::Link);
1327 hdr.set_link_name("original.txt").unwrap();
1328 hdr.set_cksum();
1329 builder
1330 .append_data(&mut hdr, "link.txt", std::io::empty())
1331 .unwrap();
1332
1333 builder.into_inner().unwrap()
1334 };
1335
1336 let temp = tempfile::TempDir::new().unwrap();
1337 let mut config = SecurityConfig::default();
1338 config.allowed.hardlinks = true;
1339 let config = config.validate().unwrap();
1340
1341 let mut archive = TarArchive::new(Cursor::new(tar_data));
1342 let mut progress = ByteTracker { total: 0 };
1343 let report = archive
1344 .extract(
1345 temp.path(),
1346 &config,
1347 &ExtractionOptions::default(),
1348 &mut progress,
1349 )
1350 .unwrap();
1351
1352 let expected = (content.len() as u64) * 2;
1354 assert_eq!(
1355 progress.total, expected,
1356 "on_bytes_written must report bytes for both original and hardlink copy, \
1357 got {} (report.bytes_written={})",
1358 progress.total, report.bytes_written
1359 );
1360 }
1361
1362 #[test]
1365 fn test_tar_on_entry_complete_called_on_path_traversal_error() {
1366 use crate::ProgressCallback;
1367 use crate::formats::TarArchive;
1368 use crate::formats::traits::ArchiveFormat;
1369 use std::io::Cursor;
1370 use std::path::Path;
1371
1372 struct SymmetryTracker {
1373 started: usize,
1374 completed: usize,
1375 }
1376
1377 impl ProgressCallback for SymmetryTracker {
1378 fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1379 self.started += 1;
1380 }
1381
1382 fn on_bytes_written(&mut self, _bytes: u64) {}
1383
1384 fn on_entry_complete(&mut self, _path: &Path) {
1385 self.completed += 1;
1386 }
1387
1388 fn on_complete(&mut self) {}
1389 }
1390
1391 let tar_data = make_raw_tar_single(b"../../etc/passwd", b"evil");
1394
1395 let temp = tempfile::TempDir::new().unwrap();
1396 let mut archive = TarArchive::new(Cursor::new(tar_data));
1397 let mut progress = SymmetryTracker {
1398 started: 0,
1399 completed: 0,
1400 };
1401 let result = archive.extract(
1402 temp.path(),
1403 &SecurityConfig::default().validate().unwrap(),
1404 &ExtractionOptions::default(),
1405 &mut progress,
1406 );
1407
1408 assert!(result.is_err(), "traversal entry must be rejected");
1409 assert_eq!(
1410 progress.started, progress.completed,
1411 "on_entry_complete must be called for every on_entry_start, \
1412 even when extraction fails: started={}, completed={}",
1413 progress.started, progress.completed
1414 );
1415 }
1416
1417 #[test]
1420 fn test_zip_on_entry_complete_called_on_path_traversal_error() {
1421 use crate::ProgressCallback;
1422 use crate::formats::ZipArchive;
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 zip_data = make_zip_with_traversal(b"../../etc/passwd", b"evil");
1448
1449 let temp = tempfile::TempDir::new().unwrap();
1450 let mut archive = ZipArchive::new(Cursor::new(zip_data)).unwrap();
1451 let mut progress = SymmetryTracker {
1452 started: 0,
1453 completed: 0,
1454 };
1455 let result = archive.extract(
1456 temp.path(),
1457 &SecurityConfig::default().validate().unwrap(),
1458 &ExtractionOptions::default(),
1459 &mut progress,
1460 );
1461
1462 assert!(result.is_err(), "traversal entry must be rejected");
1463 assert_eq!(
1464 progress.started, progress.completed,
1465 "on_entry_complete must be called for every on_entry_start in ZIP, \
1466 even when extraction fails: started={}, completed={}",
1467 progress.started, progress.completed
1468 );
1469 }
1470
1471 fn make_raw_tar_single(path: &[u8], data: &[u8]) -> Vec<u8> {
1474 let mut out = Vec::new();
1475 let mut header = [0u8; 512];
1476
1477 let path_len = path.len().min(100);
1478 header[..path_len].copy_from_slice(&path[..path_len]);
1479 header[100..108].copy_from_slice(b"0000644\0");
1480 header[108..116].copy_from_slice(b"0000000\0");
1481 header[116..124].copy_from_slice(b"0000000\0");
1482 let size_str = format!("{:011o}\0", data.len());
1483 header[124..136].copy_from_slice(size_str.as_bytes());
1484 header[136..148].copy_from_slice(b"00000000000\0");
1485 header[156] = b'0';
1486 header[257..263].copy_from_slice(b"ustar ");
1487 header[263..265].copy_from_slice(b" \0");
1488 header[148..156].copy_from_slice(b" ");
1489 let checksum: u32 = header.iter().map(|&b| u32::from(b)).sum();
1490 let ck_str = format!("{checksum:06o}\0 ");
1491 header[148..156].copy_from_slice(ck_str.as_bytes());
1492
1493 out.extend_from_slice(&header);
1494 out.extend_from_slice(data);
1495 let rem = data.len() % 512;
1496 if rem != 0 {
1497 out.extend(std::iter::repeat_n(0u8, 512 - rem));
1498 }
1499 out.extend(std::iter::repeat_n(0u8, 1024));
1500 out
1501 }
1502
1503 #[allow(clippy::cast_possible_truncation)]
1506 fn make_zip_with_traversal(path: &[u8], data: &[u8]) -> Vec<u8> {
1507 let mut buf: Vec<u8> = Vec::new();
1508
1509 let crc = crc32_ieee(data);
1510 let name_len = path.len() as u16;
1511 let content_len = data.len() as u32;
1512
1513 let local_offset: u32 = 0;
1514
1515 buf.extend_from_slice(b"PK\x03\x04");
1517 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());
1523 buf.extend_from_slice(&content_len.to_le_bytes());
1524 buf.extend_from_slice(&content_len.to_le_bytes());
1525 buf.extend_from_slice(&name_len.to_le_bytes());
1526 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(path);
1528 buf.extend_from_slice(data);
1529
1530 let central_dir_offset = buf.len() as u32;
1531
1532 buf.extend_from_slice(b"PK\x01\x02");
1534 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());
1541 buf.extend_from_slice(&content_len.to_le_bytes());
1542 buf.extend_from_slice(&content_len.to_le_bytes());
1543 buf.extend_from_slice(&name_len.to_le_bytes());
1544 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());
1550 buf.extend_from_slice(path);
1551
1552 let central_dir_size = (buf.len() as u32) - central_dir_offset;
1553
1554 buf.extend_from_slice(b"PK\x05\x06");
1556 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());
1561 buf.extend_from_slice(¢ral_dir_offset.to_le_bytes());
1562 buf.extend_from_slice(&0u16.to_le_bytes()); buf
1564 }
1565
1566 fn crc32_ieee(data: &[u8]) -> u32 {
1568 let mut crc: u32 = 0xFFFF_FFFF;
1569 for &byte in data {
1570 let mut val = crc ^ u32::from(byte);
1571 for _ in 0..8 {
1572 let mask = (val & 1).wrapping_neg();
1573 val = (val >> 1) ^ (0xEDB8_8320 & mask);
1574 }
1575 crc = val;
1576 }
1577 !crc
1578 }
1579}