1use std::path::Path;
4
5use crate::ExtractionError;
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::is_zip_family_alias;
17use crate::inspection::ArchiveManifest;
18use crate::inspection::VerificationReport;
19
20pub fn extract_archive<P: AsRef<Path>, Q: AsRef<Path>>(
53 archive_path: P,
54 output_dir: Q,
55 config: &SecurityConfig,
56) -> Result<ExtractionReport> {
57 let mut noop = NoopProgress;
58 extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
59}
60
61pub fn extract_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
98 archive_path: P,
99 output_dir: Q,
100 config: &SecurityConfig,
101 progress: &mut dyn ProgressCallback,
102) -> Result<ExtractionReport> {
103 let options = ExtractionOptions::default();
104 extract_archive_with_progress_and_options(archive_path, output_dir, config, &options, progress)
105}
106
107fn extract_archive_with_progress_and_options<P: AsRef<Path>, Q: AsRef<Path>>(
108 archive_path: P,
109 output_dir: Q,
110 config: &SecurityConfig,
111 options: &ExtractionOptions,
112 _progress: &mut dyn ProgressCallback,
113) -> Result<ExtractionReport> {
114 let archive_path = archive_path.as_ref();
115 let output_dir = output_dir.as_ref();
116
117 let format = detect_format(archive_path)?;
119
120 match format {
122 ArchiveType::Tar => extract_tar(archive_path, output_dir, config, options),
123 ArchiveType::TarGz => extract_tar_gz(archive_path, output_dir, config, options),
124 ArchiveType::TarBz2 => extract_tar_bz2(archive_path, output_dir, config, options),
125 ArchiveType::TarXz => extract_tar_xz(archive_path, output_dir, config, options),
126 ArchiveType::TarZst => extract_tar_zst(archive_path, output_dir, config, options),
127 ArchiveType::Zip => extract_zip(archive_path, output_dir, config, options),
128 ArchiveType::SevenZ => extract_7z(archive_path, output_dir, config, options),
129 }
130}
131
132pub fn extract_archive_full<P: AsRef<Path>, Q: AsRef<Path>>(
154 archive_path: P,
155 output_dir: Q,
156 config: &SecurityConfig,
157 options: &ExtractionOptions,
158 progress: &mut dyn ProgressCallback,
159) -> Result<ExtractionReport> {
160 if options.atomic {
161 extract_atomic(archive_path, output_dir, config, options, progress)
162 } else {
163 extract_archive_with_progress_and_options(
164 archive_path,
165 output_dir,
166 config,
167 options,
168 progress,
169 )
170 }
171}
172
173pub fn extract_archive_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
186 archive_path: P,
187 output_dir: Q,
188 config: &SecurityConfig,
189 options: &ExtractionOptions,
190) -> Result<ExtractionReport> {
191 let mut noop = NoopProgress;
192 extract_archive_full(archive_path, output_dir, config, options, &mut noop)
193}
194
195fn extract_atomic<P: AsRef<Path>, Q: AsRef<Path>>(
196 archive_path: P,
197 output_dir: Q,
198 config: &SecurityConfig,
199 options: &ExtractionOptions,
200 progress: &mut dyn ProgressCallback,
201) -> Result<ExtractionReport> {
202 let output_dir = output_dir.as_ref();
203
204 let canonical_output = if output_dir.exists() {
208 output_dir.canonicalize().map_err(ExtractionError::Io)?
209 } else {
210 output_dir.to_path_buf()
211 };
212
213 let parent =
214 canonical_output
215 .parent()
216 .ok_or_else(|| ExtractionError::InvalidConfiguration {
217 reason: "output directory has no parent".into(),
218 })?;
219
220 std::fs::create_dir_all(parent).map_err(ExtractionError::Io)?;
221
222 let temp_dir = tempfile::tempdir_in(parent).map_err(|e| {
223 ExtractionError::Io(std::io::Error::new(
224 e.kind(),
225 format!(
226 "failed to create temp directory in {}: {e}",
227 parent.display()
228 ),
229 ))
230 })?;
231
232 let result = extract_archive_with_progress_and_options(
233 archive_path,
234 temp_dir.path(),
235 config,
236 options,
237 progress,
238 );
239
240 match result {
241 Ok(report) => {
242 let temp_path = temp_dir.keep();
244 std::fs::rename(&temp_path, output_dir).map_err(|e| {
245 let _ = std::fs::remove_dir_all(&temp_path);
247 if e.kind() == std::io::ErrorKind::AlreadyExists {
249 ExtractionError::OutputExists {
250 path: output_dir.to_path_buf(),
251 }
252 } else {
253 ExtractionError::Io(std::io::Error::new(
254 e.kind(),
255 format!("failed to rename temp dir to {}: {e}", output_dir.display()),
256 ))
257 }
258 })?;
259
260 Ok(report)
261 }
262 Err(e) => {
263 Err(e)
265 }
266 }
267}
268
269fn extract_tar(
270 archive_path: &Path,
271 output_dir: &Path,
272 config: &SecurityConfig,
273 options: &ExtractionOptions,
274) -> Result<ExtractionReport> {
275 use crate::formats::TarArchive;
276 use crate::formats::traits::ArchiveFormat;
277 use std::fs::File;
278 use std::io::BufReader;
279
280 let file = File::open(archive_path)?;
281 let reader = BufReader::new(file);
282 let mut archive = TarArchive::new(reader);
283 archive.extract(output_dir, config, options)
284}
285
286fn extract_tar_gz(
287 archive_path: &Path,
288 output_dir: &Path,
289 config: &SecurityConfig,
290 options: &ExtractionOptions,
291) -> Result<ExtractionReport> {
292 use crate::formats::TarArchive;
293 use crate::formats::traits::ArchiveFormat;
294 use flate2::read::GzDecoder;
295 use std::fs::File;
296 use std::io::BufReader;
297
298 let file = File::open(archive_path)?;
299 let reader = BufReader::new(file);
300 let decoder = GzDecoder::new(reader);
301 let mut archive = TarArchive::new(decoder);
302 archive.extract(output_dir, config, options)
303}
304
305fn extract_tar_bz2(
306 archive_path: &Path,
307 output_dir: &Path,
308 config: &SecurityConfig,
309 options: &ExtractionOptions,
310) -> Result<ExtractionReport> {
311 use crate::formats::TarArchive;
312 use crate::formats::traits::ArchiveFormat;
313 use bzip2::read::BzDecoder;
314 use std::fs::File;
315 use std::io::BufReader;
316
317 let file = File::open(archive_path)?;
318 let reader = BufReader::new(file);
319 let decoder = BzDecoder::new(reader);
320 let mut archive = TarArchive::new(decoder);
321 archive.extract(output_dir, config, options)
322}
323
324fn extract_tar_xz(
325 archive_path: &Path,
326 output_dir: &Path,
327 config: &SecurityConfig,
328 options: &ExtractionOptions,
329) -> Result<ExtractionReport> {
330 use crate::formats::TarArchive;
331 use crate::formats::traits::ArchiveFormat;
332 use std::fs::File;
333 use std::io::BufReader;
334 use xz2::read::XzDecoder;
335
336 let file = File::open(archive_path)?;
337 let reader = BufReader::new(file);
338 let decoder = XzDecoder::new(reader);
339 let mut archive = TarArchive::new(decoder);
340 archive.extract(output_dir, config, options)
341}
342
343fn extract_tar_zst(
344 archive_path: &Path,
345 output_dir: &Path,
346 config: &SecurityConfig,
347 options: &ExtractionOptions,
348) -> Result<ExtractionReport> {
349 use crate::formats::TarArchive;
350 use crate::formats::traits::ArchiveFormat;
351 use std::fs::File;
352 use std::io::BufReader;
353 use zstd::stream::read::Decoder as ZstdDecoder;
354
355 let file = File::open(archive_path)?;
356 let reader = BufReader::new(file);
357 let decoder = ZstdDecoder::new(reader)?;
358 let mut archive = TarArchive::new(decoder);
359 archive.extract(output_dir, config, options)
360}
361
362fn extract_zip(
363 archive_path: &Path,
364 output_dir: &Path,
365 config: &SecurityConfig,
366 options: &ExtractionOptions,
367) -> Result<ExtractionReport> {
368 use crate::formats::ZipArchive;
369 use crate::formats::traits::ArchiveFormat;
370 use std::fs::File;
371
372 let file = File::open(archive_path)?;
373 let mut archive = ZipArchive::new(file)?;
374 archive.extract(output_dir, config, options)
375}
376
377fn extract_7z(
378 archive_path: &Path,
379 output_dir: &Path,
380 config: &SecurityConfig,
381 options: &ExtractionOptions,
382) -> Result<ExtractionReport> {
383 use crate::formats::SevenZArchive;
384 use crate::formats::traits::ArchiveFormat;
385 use std::fs::File;
386
387 let file = File::open(archive_path)?;
388 let mut archive = SevenZArchive::new(file)?;
389 archive.extract(output_dir, config, options)
390}
391
392pub fn create_archive<P: AsRef<Path>, Q: AsRef<Path>>(
425 output_path: P,
426 sources: &[Q],
427 config: &CreationConfig,
428) -> Result<CreationReport> {
429 let mut noop = NoopProgress;
430 create_archive_with_progress(output_path, sources, config, &mut noop)
431}
432
433pub fn create_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
474 output_path: P,
475 sources: &[Q],
476 config: &CreationConfig,
477 progress: &mut dyn ProgressCallback,
478) -> Result<CreationReport> {
479 let output = output_path.as_ref();
480
481 if config.format.is_none() {
490 reject_zip_family_creation(output)?;
491 }
492
493 let format = determine_creation_format(output, config)?;
495
496 match format {
498 ArchiveType::Tar => {
499 crate::creation::tar::create_tar_with_progress(output, sources, config, progress)
500 }
501 ArchiveType::TarGz => {
502 crate::creation::tar::create_tar_gz_with_progress(output, sources, config, progress)
503 }
504 ArchiveType::TarBz2 => {
505 crate::creation::tar::create_tar_bz2_with_progress(output, sources, config, progress)
506 }
507 ArchiveType::TarXz => {
508 crate::creation::tar::create_tar_xz_with_progress(output, sources, config, progress)
509 }
510 ArchiveType::TarZst => {
511 crate::creation::tar::create_tar_zst_with_progress(output, sources, config, progress)
512 }
513 ArchiveType::Zip => {
514 crate::creation::zip::create_zip_with_progress(output, sources, config, progress)
515 }
516 ArchiveType::SevenZ => Err(ExtractionError::InvalidArchive(
517 "7z archive creation not yet supported".into(),
518 )),
519 }
520}
521
522pub fn list_archive<P: AsRef<Path>>(
557 archive_path: P,
558 config: &SecurityConfig,
559) -> Result<ArchiveManifest> {
560 crate::inspection::list_archive(archive_path, config)
561}
562
563pub fn verify_archive<P: AsRef<Path>>(
607 archive_path: P,
608 config: &SecurityConfig,
609) -> Result<VerificationReport> {
610 crate::inspection::verify_archive(archive_path, config)
611}
612
613fn reject_zip_family_creation(output: &Path) -> Result<()> {
619 let Some(ext) = output.extension().and_then(|e| e.to_str()) else {
620 return Ok(());
621 };
622 if is_zip_family_alias(ext) {
623 let ext_lower = ext.to_ascii_lowercase();
624 return Err(ExtractionError::InvalidArchive(format!(
625 "creation for .{ext_lower} isn't supported: the format is ZIP-based but \
626 requires extra structure (signing, manifests, ordering) that exarch \
627 doesn't produce. Use .zip, or set CreationConfig::format = Some(\
628 exarch_core::formats::detect::ArchiveType::Zip) to override."
629 )));
630 }
631 Ok(())
632}
633
634fn determine_creation_format(output: &Path, config: &CreationConfig) -> Result<ArchiveType> {
636 if let Some(format) = config.format {
638 return Ok(format);
639 }
640
641 detect_format(output)
643}
644
645#[cfg(test)]
646#[allow(clippy::unwrap_used)]
647mod tests {
648 use super::*;
649 use std::path::PathBuf;
650
651 #[test]
652 fn test_extract_archive_nonexistent_file() {
653 let config = SecurityConfig::default();
654 let result = extract_archive(
655 PathBuf::from("nonexistent_test.tar"),
656 PathBuf::from("/tmp/test"),
657 &config,
658 );
659 assert!(result.is_err());
661 }
662
663 #[test]
664 fn test_determine_creation_format_tar() {
665 let config = CreationConfig::default();
666 let path = PathBuf::from("archive.tar");
667 let format = determine_creation_format(&path, &config).unwrap();
668 assert_eq!(format, ArchiveType::Tar);
669 }
670
671 #[test]
672 fn test_determine_creation_format_tar_gz() {
673 let config = CreationConfig::default();
674 let path = PathBuf::from("archive.tar.gz");
675 let format = determine_creation_format(&path, &config).unwrap();
676 assert_eq!(format, ArchiveType::TarGz);
677
678 let path2 = PathBuf::from("archive.tgz");
679 let format2 = determine_creation_format(&path2, &config).unwrap();
680 assert_eq!(format2, ArchiveType::TarGz);
681 }
682
683 #[test]
684 fn test_determine_creation_format_tar_bz2() {
685 let config = CreationConfig::default();
686 let path = PathBuf::from("archive.tar.bz2");
687 let format = determine_creation_format(&path, &config).unwrap();
688 assert_eq!(format, ArchiveType::TarBz2);
689 }
690
691 #[test]
692 fn test_determine_creation_format_tar_xz() {
693 let config = CreationConfig::default();
694 let path = PathBuf::from("archive.tar.xz");
695 let format = determine_creation_format(&path, &config).unwrap();
696 assert_eq!(format, ArchiveType::TarXz);
697 }
698
699 #[test]
700 fn test_determine_creation_format_tar_zst() {
701 let config = CreationConfig::default();
702 let path = PathBuf::from("archive.tar.zst");
703 let format = determine_creation_format(&path, &config).unwrap();
704 assert_eq!(format, ArchiveType::TarZst);
705 }
706
707 #[test]
708 fn test_determine_creation_format_zip() {
709 let config = CreationConfig::default();
710 let path = PathBuf::from("archive.zip");
711 let format = determine_creation_format(&path, &config).unwrap();
712 assert_eq!(format, ArchiveType::Zip);
713 }
714
715 #[test]
716 fn test_determine_creation_format_explicit() {
717 let config = CreationConfig::default().with_format(Some(ArchiveType::TarGz));
718 let path = PathBuf::from("archive.xyz");
719 let format = determine_creation_format(&path, &config).unwrap();
720 assert_eq!(format, ArchiveType::TarGz);
721 }
722
723 #[test]
724 fn test_determine_creation_format_unknown() {
725 let config = CreationConfig::default();
726 let path = PathBuf::from("archive.rar");
727 let result = determine_creation_format(&path, &config);
728 assert!(result.is_err());
729 }
730
731 #[test]
732 fn test_extract_archive_7z_not_implemented() {
733 let dest = tempfile::TempDir::new().unwrap();
734 let path = PathBuf::from("test.7z");
735
736 let result = extract_archive(&path, dest.path(), &SecurityConfig::default());
737
738 assert!(result.is_err());
739 }
740
741 #[test]
742 fn test_create_archive_zip_family_not_supported() {
743 let dest = tempfile::TempDir::new().unwrap();
747 for ext in ["apk", "whl", "EPUB"] {
748 let archive_path = dest.path().join(format!("output.{ext}"));
749 let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
750 assert!(
751 matches!(result, Err(ExtractionError::InvalidArchive(_))),
752 ".{ext} should be rejected, got {result:?}",
753 );
754 }
755 }
756
757 #[test]
758 fn test_create_archive_zip_family_override_bypasses_guard() {
759 let dest = tempfile::TempDir::new().unwrap();
763 let src = dest.path().join("source.txt");
764 std::fs::write(&src, b"hello").unwrap();
765 let archive_path = dest.path().join("output.apk");
766 let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
767 let result = create_archive(&archive_path, &[&src], &config);
768 assert!(
769 result.is_ok(),
770 "explicit format override should bypass the guard, got {result:?}",
771 );
772 }
773
774 #[test]
775 fn test_create_archive_7z_not_supported() {
776 let dest = tempfile::TempDir::new().unwrap();
777 let archive_path = dest.path().join("output.7z");
778
779 let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
780
781 assert!(result.is_err());
782 assert!(matches!(
783 result.unwrap_err(),
784 ExtractionError::InvalidArchive(_)
785 ));
786 }
787
788 #[test]
789 fn test_extract_archive_full_non_atomic_delegates_to_normal() {
790 let dest = tempfile::TempDir::new().unwrap();
791 let options = ExtractionOptions {
792 atomic: false,
793 skip_duplicates: true,
794 };
795 let result = extract_archive_full(
796 PathBuf::from("nonexistent.tar.gz"),
797 dest.path(),
798 &SecurityConfig::default(),
799 &options,
800 &mut NoopProgress,
801 );
802 assert!(result.is_err());
803 }
804
805 #[test]
806 fn test_extract_archive_with_options_delegates() {
807 let dest = tempfile::TempDir::new().unwrap();
808 let options = ExtractionOptions {
809 atomic: false,
810 skip_duplicates: true,
811 };
812 let result = extract_archive_with_options(
813 PathBuf::from("nonexistent.tar.gz"),
814 dest.path(),
815 &SecurityConfig::default(),
816 &options,
817 );
818 assert!(result.is_err());
819 }
820
821 #[test]
822 fn test_extract_atomic_success() {
823 use crate::create_archive;
824 use crate::creation::CreationConfig;
825
826 let archive_dir = tempfile::TempDir::new().unwrap();
828 let archive_path = archive_dir.path().join("test.tar.gz");
829
830 let src_dir = tempfile::TempDir::new().unwrap();
832 std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
833 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
834
835 let parent = tempfile::TempDir::new().unwrap();
836 let output_dir = parent.path().join("extracted");
837
838 let options = ExtractionOptions {
839 atomic: true,
840 skip_duplicates: true,
841 };
842 let result = extract_archive_with_options(
843 &archive_path,
844 &output_dir,
845 &SecurityConfig::default(),
846 &options,
847 );
848
849 assert!(result.is_ok());
850 assert!(output_dir.exists());
851 let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
853 assert_eq!(
854 temp_entries.len(),
855 1,
856 "Expected only the output dir, found temp remnants"
857 );
858 }
859
860 #[test]
861 fn test_extract_atomic_failure_cleans_up() {
862 let parent = tempfile::TempDir::new().unwrap();
863 let output_dir = parent.path().join("extracted");
864
865 let options = ExtractionOptions {
866 atomic: true,
867 skip_duplicates: true,
868 };
869 let result = extract_archive_with_options(
870 PathBuf::from("nonexistent_archive.tar.gz"),
871 &output_dir,
872 &SecurityConfig::default(),
873 &options,
874 );
875
876 assert!(result.is_err());
877 assert!(!output_dir.exists());
879 let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
881 assert!(
882 temp_entries.is_empty(),
883 "Temp dir not cleaned up after failure"
884 );
885 }
886
887 #[test]
888 fn test_extract_atomic_output_already_exists_fails() {
889 use crate::create_archive;
890 use crate::creation::CreationConfig;
891
892 let parent = tempfile::TempDir::new().unwrap();
893 let output_dir = parent.path().join("extracted");
894 std::fs::create_dir_all(&output_dir).unwrap();
895 std::fs::write(output_dir.join("existing.txt"), b"old content").unwrap();
898
899 let archive_dir = tempfile::TempDir::new().unwrap();
900 let archive_path = archive_dir.path().join("test.tar.gz");
901 let src_dir = tempfile::TempDir::new().unwrap();
902 std::fs::write(src_dir.path().join("new.txt"), b"new content").unwrap();
903 create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
904
905 let options = ExtractionOptions {
906 atomic: true,
907 skip_duplicates: true,
908 };
909 let result = extract_archive_with_options(
910 &archive_path,
911 &output_dir,
912 &SecurityConfig::default(),
913 &options,
914 );
915
916 assert!(result.is_err());
918 assert!(output_dir.join("existing.txt").exists());
920 }
921}