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