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::inspection::ArchiveManifest;
17use crate::inspection::VerificationReport;
18
19/// Extracts an archive to the specified output directory.
20///
21/// This is the main high-level API for extracting archives with security
22/// validation. The archive format is automatically detected.
23///
24/// # Arguments
25///
26/// * `archive_path` - Path to the archive file
27/// * `output_dir` - Directory where files will be extracted
28/// * `config` - Security configuration for the extraction
29///
30/// # Errors
31///
32/// Returns an error if:
33/// - Archive file cannot be opened
34/// - Archive format is unsupported
35/// - Security validation fails
36/// - I/O operations fail
37///
38/// # Examples
39///
40/// ```no_run
41/// use exarch_core::SecurityConfig;
42/// use exarch_core::extract_archive;
43///
44/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
45/// let config = SecurityConfig::default();
46/// let report = extract_archive("archive.tar.gz", "/tmp/output", &config)?;
47/// println!("Extracted {} files", report.files_extracted);
48/// # Ok(())
49/// # }
50/// ```
51pub fn extract_archive<P: AsRef<Path>, Q: AsRef<Path>>(
52    archive_path: P,
53    output_dir: Q,
54    config: &SecurityConfig,
55) -> Result<ExtractionReport> {
56    let mut noop = NoopProgress;
57    extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
58}
59
60/// Extracts an archive with progress reporting.
61///
62/// Same as `extract_archive` but accepts a `ProgressCallback` for
63/// real-time progress updates during extraction.
64///
65/// # Arguments
66///
67/// * `archive_path` - Path to the archive file
68/// * `output_dir` - Directory where files will be extracted
69/// * `config` - Security configuration for the extraction
70/// * `progress` - Callback for progress updates
71///
72/// # Errors
73///
74/// Returns an error if:
75/// - Archive file cannot be opened
76/// - Archive format is unsupported
77/// - Security validation fails
78/// - I/O operations fail
79///
80/// # Examples
81///
82/// ```no_run
83/// use exarch_core::NoopProgress;
84/// use exarch_core::SecurityConfig;
85/// use exarch_core::extract_archive_with_progress;
86///
87/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
88/// let config = SecurityConfig::default();
89/// let mut progress = NoopProgress;
90/// let report =
91///     extract_archive_with_progress("archive.tar.gz", "/tmp/output", &config, &mut progress)?;
92/// println!("Extracted {} files", report.files_extracted);
93/// # Ok(())
94/// # }
95/// ```
96pub fn extract_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
97    archive_path: P,
98    output_dir: Q,
99    config: &SecurityConfig,
100    progress: &mut dyn ProgressCallback,
101) -> Result<ExtractionReport> {
102    let options = ExtractionOptions::default();
103    extract_archive_with_progress_and_options(archive_path, output_dir, config, &options, progress)
104}
105
106fn extract_archive_with_progress_and_options<P: AsRef<Path>, Q: AsRef<Path>>(
107    archive_path: P,
108    output_dir: Q,
109    config: &SecurityConfig,
110    options: &ExtractionOptions,
111    _progress: &mut dyn ProgressCallback,
112) -> Result<ExtractionReport> {
113    let archive_path = archive_path.as_ref();
114    let output_dir = output_dir.as_ref();
115
116    // Detect archive format from file extension
117    let format = detect_format(archive_path)?;
118
119    // Dispatch to format-specific extraction
120    match format {
121        ArchiveType::Tar => extract_tar(archive_path, output_dir, config, options),
122        ArchiveType::TarGz => extract_tar_gz(archive_path, output_dir, config, options),
123        ArchiveType::TarBz2 => extract_tar_bz2(archive_path, output_dir, config, options),
124        ArchiveType::TarXz => extract_tar_xz(archive_path, output_dir, config, options),
125        ArchiveType::TarZst => extract_tar_zst(archive_path, output_dir, config, options),
126        ArchiveType::Zip => extract_zip(archive_path, output_dir, config, options),
127        ArchiveType::SevenZ => extract_7z(archive_path, output_dir, config, options),
128    }
129}
130
131/// Extracts an archive with extraction options and optional progress reporting.
132///
133/// This is the most flexible extraction API. Use this when you need both
134/// `ExtractionOptions` (e.g., atomic mode) and progress reporting.
135///
136/// # Arguments
137///
138/// * `archive_path` - Path to the archive file
139/// * `output_dir` - Directory where files will be extracted
140/// * `config` - Security configuration for the extraction
141/// * `options` - Extraction behavior options (e.g., atomic mode)
142/// * `progress` - Callback for progress updates
143///
144/// # Errors
145///
146/// Returns an error if:
147/// - Archive file cannot be opened
148/// - Archive format is unsupported
149/// - Security validation fails
150/// - I/O operations fail
151/// - Atomic temp dir creation or rename fails
152pub fn extract_archive_full<P: AsRef<Path>, Q: AsRef<Path>>(
153    archive_path: P,
154    output_dir: Q,
155    config: &SecurityConfig,
156    options: &ExtractionOptions,
157    progress: &mut dyn ProgressCallback,
158) -> Result<ExtractionReport> {
159    if options.atomic {
160        extract_atomic(archive_path, output_dir, config, options, progress)
161    } else {
162        extract_archive_with_progress_and_options(
163            archive_path,
164            output_dir,
165            config,
166            options,
167            progress,
168        )
169    }
170}
171
172/// Extracts an archive with extraction options (no progress reporting).
173///
174/// Same as `extract_archive_full` but uses a no-op progress callback.
175///
176/// # Errors
177///
178/// Returns an error if:
179/// - Archive file cannot be opened
180/// - Archive format is unsupported
181/// - Security validation fails
182/// - I/O operations fail
183/// - Atomic temp dir creation or rename fails
184pub fn extract_archive_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
185    archive_path: P,
186    output_dir: Q,
187    config: &SecurityConfig,
188    options: &ExtractionOptions,
189) -> Result<ExtractionReport> {
190    let mut noop = NoopProgress;
191    extract_archive_full(archive_path, output_dir, config, options, &mut noop)
192}
193
194fn extract_atomic<P: AsRef<Path>, Q: AsRef<Path>>(
195    archive_path: P,
196    output_dir: Q,
197    config: &SecurityConfig,
198    options: &ExtractionOptions,
199    progress: &mut dyn ProgressCallback,
200) -> Result<ExtractionReport> {
201    let output_dir = output_dir.as_ref();
202
203    // Canonicalize output_dir to resolve any symlinks in the path before
204    // computing the parent, so temp dir lands on the same filesystem.
205    // If output_dir doesn't exist yet, use its lexical parent.
206    let canonical_output = if output_dir.exists() {
207        output_dir.canonicalize().map_err(ExtractionError::Io)?
208    } else {
209        output_dir.to_path_buf()
210    };
211
212    let parent =
213        canonical_output
214            .parent()
215            .ok_or_else(|| ExtractionError::InvalidConfiguration {
216                reason: "output directory has no parent".into(),
217            })?;
218
219    std::fs::create_dir_all(parent).map_err(ExtractionError::Io)?;
220
221    let temp_dir = tempfile::tempdir_in(parent).map_err(|e| {
222        ExtractionError::Io(std::io::Error::new(
223            e.kind(),
224            format!(
225                "failed to create temp directory in {}: {e}",
226                parent.display()
227            ),
228        ))
229    })?;
230
231    let result = extract_archive_with_progress_and_options(
232        archive_path,
233        temp_dir.path(),
234        config,
235        options,
236        progress,
237    );
238
239    match result {
240        Ok(report) => {
241            // Consume TempDir to prevent Drop cleanup, then rename.
242            let temp_path = temp_dir.keep();
243            std::fs::rename(&temp_path, output_dir).map_err(|e| {
244                // Rename failed: clean up temp dir
245                let _ = std::fs::remove_dir_all(&temp_path);
246                // Map AlreadyExists to OutputExists for caller clarity
247                if e.kind() == std::io::ErrorKind::AlreadyExists {
248                    ExtractionError::OutputExists {
249                        path: output_dir.to_path_buf(),
250                    }
251                } else {
252                    ExtractionError::Io(std::io::Error::new(
253                        e.kind(),
254                        format!("failed to rename temp dir to {}: {e}", output_dir.display()),
255                    ))
256                }
257            })?;
258
259            Ok(report)
260        }
261        Err(e) => {
262            // TempDir Drop runs here: cleans up temp dir automatically.
263            Err(e)
264        }
265    }
266}
267
268fn extract_tar(
269    archive_path: &Path,
270    output_dir: &Path,
271    config: &SecurityConfig,
272    options: &ExtractionOptions,
273) -> Result<ExtractionReport> {
274    use crate::formats::TarArchive;
275    use crate::formats::traits::ArchiveFormat;
276    use std::fs::File;
277    use std::io::BufReader;
278
279    let file = File::open(archive_path)?;
280    let reader = BufReader::new(file);
281    let mut archive = TarArchive::new(reader);
282    archive.extract(output_dir, config, options)
283}
284
285fn extract_tar_gz(
286    archive_path: &Path,
287    output_dir: &Path,
288    config: &SecurityConfig,
289    options: &ExtractionOptions,
290) -> Result<ExtractionReport> {
291    use crate::formats::TarArchive;
292    use crate::formats::traits::ArchiveFormat;
293    use flate2::read::GzDecoder;
294    use std::fs::File;
295    use std::io::BufReader;
296
297    let file = File::open(archive_path)?;
298    let reader = BufReader::new(file);
299    let decoder = GzDecoder::new(reader);
300    let mut archive = TarArchive::new(decoder);
301    archive.extract(output_dir, config, options)
302}
303
304fn extract_tar_bz2(
305    archive_path: &Path,
306    output_dir: &Path,
307    config: &SecurityConfig,
308    options: &ExtractionOptions,
309) -> Result<ExtractionReport> {
310    use crate::formats::TarArchive;
311    use crate::formats::traits::ArchiveFormat;
312    use bzip2::read::BzDecoder;
313    use std::fs::File;
314    use std::io::BufReader;
315
316    let file = File::open(archive_path)?;
317    let reader = BufReader::new(file);
318    let decoder = BzDecoder::new(reader);
319    let mut archive = TarArchive::new(decoder);
320    archive.extract(output_dir, config, options)
321}
322
323fn extract_tar_xz(
324    archive_path: &Path,
325    output_dir: &Path,
326    config: &SecurityConfig,
327    options: &ExtractionOptions,
328) -> Result<ExtractionReport> {
329    use crate::formats::TarArchive;
330    use crate::formats::traits::ArchiveFormat;
331    use std::fs::File;
332    use std::io::BufReader;
333    use xz2::read::XzDecoder;
334
335    let file = File::open(archive_path)?;
336    let reader = BufReader::new(file);
337    let decoder = XzDecoder::new(reader);
338    let mut archive = TarArchive::new(decoder);
339    archive.extract(output_dir, config, options)
340}
341
342fn extract_tar_zst(
343    archive_path: &Path,
344    output_dir: &Path,
345    config: &SecurityConfig,
346    options: &ExtractionOptions,
347) -> Result<ExtractionReport> {
348    use crate::formats::TarArchive;
349    use crate::formats::traits::ArchiveFormat;
350    use std::fs::File;
351    use std::io::BufReader;
352    use zstd::stream::read::Decoder as ZstdDecoder;
353
354    let file = File::open(archive_path)?;
355    let reader = BufReader::new(file);
356    let decoder = ZstdDecoder::new(reader)?;
357    let mut archive = TarArchive::new(decoder);
358    archive.extract(output_dir, config, options)
359}
360
361fn extract_zip(
362    archive_path: &Path,
363    output_dir: &Path,
364    config: &SecurityConfig,
365    options: &ExtractionOptions,
366) -> Result<ExtractionReport> {
367    use crate::formats::ZipArchive;
368    use crate::formats::traits::ArchiveFormat;
369    use std::fs::File;
370
371    let file = File::open(archive_path)?;
372    let mut archive = ZipArchive::new(file)?;
373    archive.extract(output_dir, config, options)
374}
375
376fn extract_7z(
377    archive_path: &Path,
378    output_dir: &Path,
379    config: &SecurityConfig,
380    options: &ExtractionOptions,
381) -> Result<ExtractionReport> {
382    use crate::formats::SevenZArchive;
383    use crate::formats::traits::ArchiveFormat;
384    use std::fs::File;
385
386    let file = File::open(archive_path)?;
387    let mut archive = SevenZArchive::new(file)?;
388    archive.extract(output_dir, config, options)
389}
390
391/// Creates an archive from source files and directories.
392///
393/// Format is auto-detected from output file extension, or can be
394/// explicitly set via `config.format`.
395///
396/// # Arguments
397///
398/// * `output_path` - Path to the output archive file
399/// * `sources` - Source files and directories to include
400/// * `config` - Creation configuration
401///
402/// # Errors
403///
404/// Returns an error if:
405/// - Cannot determine archive format
406/// - Source files don't exist
407/// - I/O operations fail
408/// - Configuration is invalid
409///
410/// # Examples
411///
412/// ```no_run
413/// use exarch_core::create_archive;
414/// use exarch_core::creation::CreationConfig;
415///
416/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
417/// let config = CreationConfig::default();
418/// let report = create_archive("output.tar.gz", &["src/", "Cargo.toml"], &config)?;
419/// println!("Created archive with {} files", report.files_added);
420/// # Ok(())
421/// # }
422/// ```
423pub fn create_archive<P: AsRef<Path>, Q: AsRef<Path>>(
424    output_path: P,
425    sources: &[Q],
426    config: &CreationConfig,
427) -> Result<CreationReport> {
428    let mut noop = NoopProgress;
429    create_archive_with_progress(output_path, sources, config, &mut noop)
430}
431
432/// Creates an archive with progress reporting.
433///
434/// Same as `create_archive` but accepts a `ProgressCallback` for
435/// real-time progress updates during creation.
436///
437/// # Arguments
438///
439/// * `output_path` - Path to the output archive file
440/// * `sources` - Source files and directories to include
441/// * `config` - Creation configuration
442/// * `progress` - Callback for progress updates
443///
444/// # Errors
445///
446/// Returns an error if:
447/// - Cannot determine archive format
448/// - Source files don't exist
449/// - I/O operations fail
450/// - Configuration is invalid
451///
452/// # Examples
453///
454/// ```no_run
455/// use exarch_core::NoopProgress;
456/// use exarch_core::create_archive_with_progress;
457/// use exarch_core::creation::CreationConfig;
458///
459/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
460/// let config = CreationConfig::default();
461/// let mut progress = NoopProgress;
462/// let report = create_archive_with_progress(
463///     "output.tar.gz",
464///     &["src/", "Cargo.toml"],
465///     &config,
466///     &mut progress,
467/// )?;
468/// println!("Created archive with {} files", report.files_added);
469/// # Ok(())
470/// # }
471/// ```
472pub fn create_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
473    output_path: P,
474    sources: &[Q],
475    config: &CreationConfig,
476    progress: &mut dyn ProgressCallback,
477) -> Result<CreationReport> {
478    let output = output_path.as_ref();
479
480    // Determine format from extension or config
481    let format = determine_creation_format(output, config)?;
482
483    // Dispatch to format-specific creator with progress
484    match format {
485        ArchiveType::Tar => {
486            crate::creation::tar::create_tar_with_progress(output, sources, config, progress)
487        }
488        ArchiveType::TarGz => {
489            crate::creation::tar::create_tar_gz_with_progress(output, sources, config, progress)
490        }
491        ArchiveType::TarBz2 => {
492            crate::creation::tar::create_tar_bz2_with_progress(output, sources, config, progress)
493        }
494        ArchiveType::TarXz => {
495            crate::creation::tar::create_tar_xz_with_progress(output, sources, config, progress)
496        }
497        ArchiveType::TarZst => {
498            crate::creation::tar::create_tar_zst_with_progress(output, sources, config, progress)
499        }
500        ArchiveType::Zip => {
501            crate::creation::zip::create_zip_with_progress(output, sources, config, progress)
502        }
503        ArchiveType::SevenZ => Err(ExtractionError::InvalidArchive(
504            "7z archive creation not yet supported".into(),
505        )),
506    }
507}
508
509/// Lists archive contents without extracting.
510///
511/// Returns a manifest containing metadata for all entries in the archive.
512/// No files are written to disk during this operation.
513///
514/// # Arguments
515///
516/// * `archive_path` - Path to archive file
517/// * `config` - Security configuration (quota limits apply)
518///
519/// # Errors
520///
521/// Returns error if:
522/// - Archive file cannot be opened
523/// - Archive format is unsupported or corrupted
524/// - Quota limits exceeded (file count, total size)
525///
526/// # Examples
527///
528/// ```no_run
529/// use exarch_core::SecurityConfig;
530/// use exarch_core::list_archive;
531///
532/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
533/// let config = SecurityConfig::default();
534/// let manifest = list_archive("archive.tar.gz", &config)?;
535///
536/// println!("Archive contains {} files", manifest.total_entries);
537/// for entry in manifest.entries {
538///     println!("{}: {} bytes", entry.path.display(), entry.size);
539/// }
540/// # Ok(())
541/// # }
542/// ```
543pub 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
550/// Verifies archive integrity and security without extracting.
551///
552/// Performs comprehensive validation:
553/// - Integrity checks (structure, checksums)
554/// - Security checks (path traversal, zip bombs, CVEs)
555/// - Policy checks (file types, permissions)
556///
557/// # Arguments
558///
559/// * `archive_path` - Path to archive file
560/// * `config` - Security configuration for validation
561///
562/// # Errors
563///
564/// Returns error if:
565/// - Archive file cannot be opened
566/// - Archive is severely corrupted (cannot read structure)
567///
568/// Security violations are reported in `VerificationReport.issues`,
569/// not as errors.
570///
571/// # Examples
572///
573/// ```no_run
574/// use exarch_core::SecurityConfig;
575/// use exarch_core::VerificationStatus;
576/// use exarch_core::verify_archive;
577///
578/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
579/// let config = SecurityConfig::default();
580/// let report = verify_archive("archive.tar.gz", &config)?;
581///
582/// if report.status == VerificationStatus::Pass {
583///     println!("Archive is safe to extract");
584/// } else {
585///     eprintln!("Security issues found:");
586///     for issue in report.issues {
587///         eprintln!("  [{}] {}", issue.severity, issue.message);
588///     }
589/// }
590/// # Ok(())
591/// # }
592/// ```
593pub 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
600/// Determines archive format from output path or config.
601fn determine_creation_format(output: &Path, config: &CreationConfig) -> Result<ArchiveType> {
602    // If format explicitly set in config, use it
603    if let Some(format) = config.format {
604        return Ok(format);
605    }
606
607    // Auto-detect from extension
608    detect_format(output)
609}
610
611#[cfg(test)]
612#[allow(clippy::unwrap_used)]
613mod tests {
614    use super::*;
615    use std::path::PathBuf;
616
617    #[test]
618    fn test_extract_archive_nonexistent_file() {
619        let config = SecurityConfig::default();
620        let result = extract_archive(
621            PathBuf::from("nonexistent_test.tar"),
622            PathBuf::from("/tmp/test"),
623            &config,
624        );
625        // Should fail because file doesn't exist
626        assert!(result.is_err());
627    }
628
629    #[test]
630    fn test_determine_creation_format_tar() {
631        let config = CreationConfig::default();
632        let path = PathBuf::from("archive.tar");
633        let format = determine_creation_format(&path, &config).unwrap();
634        assert_eq!(format, ArchiveType::Tar);
635    }
636
637    #[test]
638    fn test_determine_creation_format_tar_gz() {
639        let config = CreationConfig::default();
640        let path = PathBuf::from("archive.tar.gz");
641        let format = determine_creation_format(&path, &config).unwrap();
642        assert_eq!(format, ArchiveType::TarGz);
643
644        let path2 = PathBuf::from("archive.tgz");
645        let format2 = determine_creation_format(&path2, &config).unwrap();
646        assert_eq!(format2, ArchiveType::TarGz);
647    }
648
649    #[test]
650    fn test_determine_creation_format_tar_bz2() {
651        let config = CreationConfig::default();
652        let path = PathBuf::from("archive.tar.bz2");
653        let format = determine_creation_format(&path, &config).unwrap();
654        assert_eq!(format, ArchiveType::TarBz2);
655    }
656
657    #[test]
658    fn test_determine_creation_format_tar_xz() {
659        let config = CreationConfig::default();
660        let path = PathBuf::from("archive.tar.xz");
661        let format = determine_creation_format(&path, &config).unwrap();
662        assert_eq!(format, ArchiveType::TarXz);
663    }
664
665    #[test]
666    fn test_determine_creation_format_tar_zst() {
667        let config = CreationConfig::default();
668        let path = PathBuf::from("archive.tar.zst");
669        let format = determine_creation_format(&path, &config).unwrap();
670        assert_eq!(format, ArchiveType::TarZst);
671    }
672
673    #[test]
674    fn test_determine_creation_format_zip() {
675        let config = CreationConfig::default();
676        let path = PathBuf::from("archive.zip");
677        let format = determine_creation_format(&path, &config).unwrap();
678        assert_eq!(format, ArchiveType::Zip);
679    }
680
681    #[test]
682    fn test_determine_creation_format_explicit() {
683        let config = CreationConfig::default().with_format(Some(ArchiveType::TarGz));
684        let path = PathBuf::from("archive.xyz");
685        let format = determine_creation_format(&path, &config).unwrap();
686        assert_eq!(format, ArchiveType::TarGz);
687    }
688
689    #[test]
690    fn test_determine_creation_format_unknown() {
691        let config = CreationConfig::default();
692        let path = PathBuf::from("archive.rar");
693        let result = determine_creation_format(&path, &config);
694        assert!(result.is_err());
695    }
696
697    #[test]
698    fn test_extract_archive_7z_not_implemented() {
699        let dest = tempfile::TempDir::new().unwrap();
700        let path = PathBuf::from("test.7z");
701
702        let result = extract_archive(&path, dest.path(), &SecurityConfig::default());
703
704        assert!(result.is_err());
705    }
706
707    #[test]
708    fn test_create_archive_7z_not_supported() {
709        let dest = tempfile::TempDir::new().unwrap();
710        let archive_path = dest.path().join("output.7z");
711
712        let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
713
714        assert!(result.is_err());
715        assert!(matches!(
716            result.unwrap_err(),
717            ExtractionError::InvalidArchive(_)
718        ));
719    }
720
721    #[test]
722    fn test_extract_archive_full_non_atomic_delegates_to_normal() {
723        let dest = tempfile::TempDir::new().unwrap();
724        let options = ExtractionOptions {
725            atomic: false,
726            skip_duplicates: true,
727        };
728        let result = extract_archive_full(
729            PathBuf::from("nonexistent.tar.gz"),
730            dest.path(),
731            &SecurityConfig::default(),
732            &options,
733            &mut NoopProgress,
734        );
735        assert!(result.is_err());
736    }
737
738    #[test]
739    fn test_extract_archive_with_options_delegates() {
740        let dest = tempfile::TempDir::new().unwrap();
741        let options = ExtractionOptions {
742            atomic: false,
743            skip_duplicates: true,
744        };
745        let result = extract_archive_with_options(
746            PathBuf::from("nonexistent.tar.gz"),
747            dest.path(),
748            &SecurityConfig::default(),
749            &options,
750        );
751        assert!(result.is_err());
752    }
753
754    #[test]
755    fn test_extract_atomic_success() {
756        use crate::create_archive;
757        use crate::creation::CreationConfig;
758
759        // Create a valid tar.gz to extract
760        let archive_dir = tempfile::TempDir::new().unwrap();
761        let archive_path = archive_dir.path().join("test.tar.gz");
762
763        // Create a simple archive with one file
764        let src_dir = tempfile::TempDir::new().unwrap();
765        std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
766        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
767
768        let parent = tempfile::TempDir::new().unwrap();
769        let output_dir = parent.path().join("extracted");
770
771        let options = ExtractionOptions {
772            atomic: true,
773            skip_duplicates: true,
774        };
775        let result = extract_archive_with_options(
776            &archive_path,
777            &output_dir,
778            &SecurityConfig::default(),
779            &options,
780        );
781
782        assert!(result.is_ok());
783        assert!(output_dir.exists());
784        // No temp dir remnants
785        let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
786        assert_eq!(
787            temp_entries.len(),
788            1,
789            "Expected only the output dir, found temp remnants"
790        );
791    }
792
793    #[test]
794    fn test_extract_atomic_failure_cleans_up() {
795        let parent = tempfile::TempDir::new().unwrap();
796        let output_dir = parent.path().join("extracted");
797
798        let options = ExtractionOptions {
799            atomic: true,
800            skip_duplicates: true,
801        };
802        let result = extract_archive_with_options(
803            PathBuf::from("nonexistent_archive.tar.gz"),
804            &output_dir,
805            &SecurityConfig::default(),
806            &options,
807        );
808
809        assert!(result.is_err());
810        // Output dir must not exist
811        assert!(!output_dir.exists());
812        // No temp dir remnants in parent
813        let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
814        assert!(
815            temp_entries.is_empty(),
816            "Temp dir not cleaned up after failure"
817        );
818    }
819
820    #[test]
821    fn test_extract_atomic_output_already_exists_fails() {
822        use crate::create_archive;
823        use crate::creation::CreationConfig;
824
825        let parent = tempfile::TempDir::new().unwrap();
826        let output_dir = parent.path().join("extracted");
827        std::fs::create_dir_all(&output_dir).unwrap();
828        // Create a file in output_dir so it's non-empty (rename over non-empty dir
829        // fails on most OSes)
830        std::fs::write(output_dir.join("existing.txt"), b"old content").unwrap();
831
832        let archive_dir = tempfile::TempDir::new().unwrap();
833        let archive_path = archive_dir.path().join("test.tar.gz");
834        let src_dir = tempfile::TempDir::new().unwrap();
835        std::fs::write(src_dir.path().join("new.txt"), b"new content").unwrap();
836        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
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        // Should fail with OutputExists or Io (platform dependent rename semantics)
850        assert!(result.is_err());
851        // Output dir must still have old content (not corrupted)
852        assert!(output_dir.join("existing.txt").exists());
853    }
854}