Skip to main content

exarch_core/
api.rs

1//! High-level public API for archive extraction, creation, and inspection.
2
3use 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
20/// Extracts an archive to the specified output directory.
21///
22/// This is the main high-level API for extracting archives with security
23/// validation. The archive format is automatically detected.
24///
25/// # Arguments
26///
27/// * `archive_path` - Path to the archive file
28/// * `output_dir` - Directory where files will be extracted
29/// * `config` - Security configuration for the extraction
30///
31/// # Errors
32///
33/// Returns an error if:
34/// - Archive file cannot be opened
35/// - Archive format is unsupported
36/// - Security validation fails
37/// - I/O operations fail
38///
39/// # Examples
40///
41/// ```no_run
42/// use exarch_core::SecurityConfig;
43/// use exarch_core::extract_archive;
44///
45/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
46/// let config = SecurityConfig::default();
47/// let report = extract_archive("archive.tar.gz", "/tmp/output", &config)?;
48/// println!("Extracted {} files", report.files_extracted);
49/// # Ok(())
50/// # }
51/// ```
52pub 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
61/// Extracts an archive with progress reporting.
62///
63/// Same as `extract_archive` but accepts a `ProgressCallback` for
64/// real-time progress updates during extraction.
65///
66/// # Arguments
67///
68/// * `archive_path` - Path to the archive file
69/// * `output_dir` - Directory where files will be extracted
70/// * `config` - Security configuration for the extraction
71/// * `progress` - Callback for progress updates
72///
73/// # Errors
74///
75/// Returns an error if:
76/// - Archive file cannot be opened
77/// - Archive format is unsupported
78/// - Security validation fails
79/// - I/O operations fail
80///
81/// # Examples
82///
83/// ```no_run
84/// use exarch_core::NoopProgress;
85/// use exarch_core::SecurityConfig;
86/// use exarch_core::extract_archive_with_progress;
87///
88/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
89/// let config = SecurityConfig::default();
90/// let mut progress = NoopProgress;
91/// let report =
92///     extract_archive_with_progress("archive.tar.gz", "/tmp/output", &config, &mut progress)?;
93/// println!("Extracted {} files", report.files_extracted);
94/// # Ok(())
95/// # }
96/// ```
97pub 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    // Detect archive format from file extension
118    let format = detect_format(archive_path)?;
119
120    // Dispatch to format-specific extraction
121    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
132/// Extracts an archive with extraction options and optional progress reporting.
133///
134/// This is the most flexible extraction API. Use this when you need both
135/// `ExtractionOptions` (e.g., atomic mode) and progress reporting.
136///
137/// # Arguments
138///
139/// * `archive_path` - Path to the archive file
140/// * `output_dir` - Directory where files will be extracted
141/// * `config` - Security configuration for the extraction
142/// * `options` - Extraction behavior options (e.g., atomic mode)
143/// * `progress` - Callback for progress updates
144///
145/// # Errors
146///
147/// Returns an error if:
148/// - Archive file cannot be opened
149/// - Archive format is unsupported
150/// - Security validation fails
151/// - I/O operations fail
152/// - Atomic temp dir creation or rename fails
153pub 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
173/// Extracts an archive with extraction options (no progress reporting).
174///
175/// Same as `extract_archive_full` but uses a no-op progress callback.
176///
177/// # Errors
178///
179/// Returns an error if:
180/// - Archive file cannot be opened
181/// - Archive format is unsupported
182/// - Security validation fails
183/// - I/O operations fail
184/// - Atomic temp dir creation or rename fails
185pub 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    // Canonicalize output_dir to resolve any symlinks in the path before
205    // computing the parent, so temp dir lands on the same filesystem.
206    // If output_dir doesn't exist yet, use its lexical parent.
207    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            // Consume TempDir to prevent Drop cleanup, then rename.
243            let temp_path = temp_dir.keep();
244            std::fs::rename(&temp_path, output_dir).map_err(|e| {
245                // Rename failed: clean up temp dir
246                let _ = std::fs::remove_dir_all(&temp_path);
247                // Map AlreadyExists to OutputExists for caller clarity
248                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            // TempDir Drop runs here: cleans up temp dir automatically.
264            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
392/// Creates an archive from source files and directories.
393///
394/// Format is auto-detected from output file extension, or can be
395/// explicitly set via `config.format`.
396///
397/// # Arguments
398///
399/// * `output_path` - Path to the output archive file
400/// * `sources` - Source files and directories to include
401/// * `config` - Creation configuration
402///
403/// # Errors
404///
405/// Returns an error if:
406/// - Cannot determine archive format
407/// - Source files don't exist
408/// - I/O operations fail
409/// - Configuration is invalid
410///
411/// # Examples
412///
413/// ```no_run
414/// use exarch_core::create_archive;
415/// use exarch_core::creation::CreationConfig;
416///
417/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
418/// let config = CreationConfig::default();
419/// let report = create_archive("output.tar.gz", &["src/", "Cargo.toml"], &config)?;
420/// println!("Created archive with {} files", report.files_added);
421/// # Ok(())
422/// # }
423/// ```
424pub 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
433/// Creates an archive with progress reporting.
434///
435/// Same as `create_archive` but accepts a `ProgressCallback` for
436/// real-time progress updates during creation.
437///
438/// # Arguments
439///
440/// * `output_path` - Path to the output archive file
441/// * `sources` - Source files and directories to include
442/// * `config` - Creation configuration
443/// * `progress` - Callback for progress updates
444///
445/// # Errors
446///
447/// Returns an error if:
448/// - Cannot determine archive format
449/// - Source files don't exist
450/// - I/O operations fail
451/// - Configuration is invalid
452///
453/// # Examples
454///
455/// ```no_run
456/// use exarch_core::NoopProgress;
457/// use exarch_core::create_archive_with_progress;
458/// use exarch_core::creation::CreationConfig;
459///
460/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
461/// let config = CreationConfig::default();
462/// let mut progress = NoopProgress;
463/// let report = create_archive_with_progress(
464///     "output.tar.gz",
465///     &["src/", "Cargo.toml"],
466///     &config,
467///     &mut progress,
468/// )?;
469/// println!("Created archive with {} files", report.files_added);
470/// # Ok(())
471/// # }
472/// ```
473pub 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    // Block creation for the ZIP-family extensions (mirrors the 7z block
482    // below). They're all ZIP underneath but add extra requirements -
483    // signing (apk/aab/ipa/appx/msix), checksum manifests (whl), ordering
484    // and stored-compression rules (epub), descriptor files
485    // (war/ear/vsix/nbm) - which exarch doesn't produce. Silently emitting
486    // a bare ZIP with one of these extensions would be misleading, so we
487    // error instead. Callers who need the override can set
488    // CreationConfig::format = Some(ArchiveType::Zip).
489    if config.format.is_none() {
490        reject_zip_family_creation(output)?;
491    }
492
493    // Determine format from extension or config
494    let format = determine_creation_format(output, config)?;
495
496    // Dispatch to format-specific creator with progress
497    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
522/// Lists archive contents without extracting.
523///
524/// Returns a manifest containing metadata for all entries in the archive.
525/// No files are written to disk during this operation.
526///
527/// # Arguments
528///
529/// * `archive_path` - Path to archive file
530/// * `config` - Security configuration (quota limits apply)
531///
532/// # Errors
533///
534/// Returns error if:
535/// - Archive file cannot be opened
536/// - Archive format is unsupported or corrupted
537/// - Quota limits exceeded (file count, total size)
538///
539/// # Examples
540///
541/// ```no_run
542/// use exarch_core::SecurityConfig;
543/// use exarch_core::list_archive;
544///
545/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
546/// let config = SecurityConfig::default();
547/// let manifest = list_archive("archive.tar.gz", &config)?;
548///
549/// println!("Archive contains {} files", manifest.total_entries);
550/// for entry in manifest.entries {
551///     println!("{}: {} bytes", entry.path.display(), entry.size);
552/// }
553/// # Ok(())
554/// # }
555/// ```
556pub 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
563/// Verifies archive integrity and security without extracting.
564///
565/// Performs comprehensive validation:
566/// - Integrity checks (structure, checksums)
567/// - Security checks (path traversal, zip bombs, CVEs)
568/// - Policy checks (file types, permissions)
569///
570/// # Arguments
571///
572/// * `archive_path` - Path to archive file
573/// * `config` - Security configuration for validation
574///
575/// # Errors
576///
577/// Returns error if:
578/// - Archive file cannot be opened
579/// - Archive is severely corrupted (cannot read structure)
580///
581/// Security violations are reported in `VerificationReport.issues`,
582/// not as errors.
583///
584/// # Examples
585///
586/// ```no_run
587/// use exarch_core::SecurityConfig;
588/// use exarch_core::VerificationStatus;
589/// use exarch_core::verify_archive;
590///
591/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
592/// let config = SecurityConfig::default();
593/// let report = verify_archive("archive.tar.gz", &config)?;
594///
595/// if report.status == VerificationStatus::Pass {
596///     println!("Archive is safe to extract");
597/// } else {
598///     eprintln!("Security issues found:");
599///     for issue in report.issues {
600///         eprintln!("  [{}] {}", issue.severity, issue.message);
601///     }
602/// }
603/// # Ok(())
604/// # }
605/// ```
606pub 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
613/// Rejects creation for ZIP-family extensions that aren't plain `.zip`.
614///
615/// See the call site in `create_archive_with_progress` for the rationale.
616/// Returns `Ok(())` for anything else - `.zip`, tar variants, unknown
617/// extensions (those get caught later by `detect_format`).
618fn 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
634/// Determines archive format from output path or config.
635fn determine_creation_format(output: &Path, config: &CreationConfig) -> Result<ArchiveType> {
636    // If format explicitly set in config, use it
637    if let Some(format) = config.format {
638        return Ok(format);
639    }
640
641    // Auto-detect from extension
642    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        // Should fail because file doesn't exist
660        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        // Mirrors test_create_archive_7z_not_supported. Spot-checks a couple
744        // of extensions rather than every one - the integration test
745        // covers the full list.
746        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        // Explicit CreationConfig::format = Some(Zip) is the escape hatch -
760        // skips the ZIP-family guard. Caller takes responsibility for the
761        // resulting file not being spec-valid.
762        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        // Create a valid tar.gz to extract
827        let archive_dir = tempfile::TempDir::new().unwrap();
828        let archive_path = archive_dir.path().join("test.tar.gz");
829
830        // Create a simple archive with one file
831        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        // No temp dir remnants
852        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        // Output dir must not exist
878        assert!(!output_dir.exists());
879        // No temp dir remnants in parent
880        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        // Create a file in output_dir so it's non-empty (rename over non-empty dir
896        // fails on most OSes)
897        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        // Should fail with OutputExists or Io (platform dependent rename semantics)
917        assert!(result.is_err());
918        // Output dir must still have old content (not corrupted)
919        assert!(output_dir.join("existing.txt").exists());
920    }
921}