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