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_impl(archive_path, output_dir, config, &options, progress)
105}
106
107fn extract_impl<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    config.validate()?;
115
116    let archive_path = archive_path.as_ref();
117    let output_dir = output_dir.as_ref();
118
119    // Detect archive format from file extension
120    let format = detect_format(archive_path)?;
121
122    // Dispatch to format-specific extraction
123    match format {
124        ArchiveType::Tar => extract_tar(archive_path, output_dir, config, options, progress),
125        ArchiveType::TarGz => extract_tar_gz(archive_path, output_dir, config, options, progress),
126        ArchiveType::TarBz2 => extract_tar_bz2(archive_path, output_dir, config, options, progress),
127        ArchiveType::TarXz => extract_tar_xz(archive_path, output_dir, config, options, progress),
128        ArchiveType::TarZst => extract_tar_zst(archive_path, output_dir, config, options, progress),
129        ArchiveType::Zip => extract_zip(archive_path, output_dir, config, options, progress),
130        ArchiveType::SevenZ => extract_7z(archive_path, output_dir, config, options, progress),
131    }
132}
133
134/// Extracts an archive with extraction options and optional progress reporting.
135///
136/// This is the most flexible extraction API. Use this when you need both
137/// `ExtractionOptions` (e.g., atomic mode) and progress reporting.
138///
139/// # Arguments
140///
141/// * `archive_path` - Path to the archive file
142/// * `output_dir` - Directory where files will be extracted
143/// * `config` - Security configuration for the extraction
144/// * `options` - Extraction behavior options (e.g., atomic mode)
145/// * `progress` - Callback for progress updates
146///
147/// # Errors
148///
149/// Returns an error if:
150/// - Archive file cannot be opened
151/// - Archive format is unsupported
152/// - Security validation fails
153/// - I/O operations fail
154/// - Atomic temp dir creation or rename fails
155pub fn extract_archive_with_options_and_progress<P: AsRef<Path>, Q: AsRef<Path>>(
156    archive_path: P,
157    output_dir: Q,
158    config: &SecurityConfig,
159    options: &ExtractionOptions,
160    progress: &mut dyn ProgressCallback,
161) -> Result<ExtractionReport> {
162    if options.atomic {
163        extract_atomic(archive_path, output_dir, config, options, progress)
164    } else {
165        extract_impl(archive_path, output_dir, config, options, progress)
166    }
167}
168
169/// Extracts an archive with extraction options (no progress reporting).
170///
171/// Same as `extract_archive_with_options_and_progress` but uses a no-op
172/// progress callback.
173///
174/// # Errors
175///
176/// Returns an error if:
177/// - Archive file cannot be opened
178/// - Archive format is unsupported
179/// - Security validation fails
180/// - I/O operations fail
181/// - Atomic temp dir creation or rename fails
182pub fn extract_archive_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
183    archive_path: P,
184    output_dir: Q,
185    config: &SecurityConfig,
186    options: &ExtractionOptions,
187) -> Result<ExtractionReport> {
188    let mut noop = NoopProgress;
189    extract_archive_with_options_and_progress(archive_path, output_dir, config, options, &mut noop)
190}
191
192fn extract_atomic<P: AsRef<Path>, Q: AsRef<Path>>(
193    archive_path: P,
194    output_dir: Q,
195    config: &SecurityConfig,
196    options: &ExtractionOptions,
197    progress: &mut dyn ProgressCallback,
198) -> Result<ExtractionReport> {
199    let output_dir = output_dir.as_ref();
200
201    // Canonicalize output_dir to resolve any symlinks in the path before
202    // computing the parent, so temp dir lands on the same filesystem.
203    // If output_dir doesn't exist yet, use its lexical parent.
204    let canonical_output = if output_dir.exists() {
205        output_dir.canonicalize().map_err(ExtractionError::Io)?
206    } else {
207        output_dir.to_path_buf()
208    };
209
210    let parent =
211        canonical_output
212            .parent()
213            .ok_or_else(|| ExtractionError::InvalidConfiguration {
214                reason: "output directory has no parent".into(),
215            })?;
216
217    std::fs::create_dir_all(parent).map_err(ExtractionError::Io)?;
218
219    let temp_dir = tempfile::tempdir_in(parent).map_err(|e| {
220        ExtractionError::Io(std::io::Error::new(
221            e.kind(),
222            format!(
223                "failed to create temp directory in {}: {e}",
224                parent.display()
225            ),
226        ))
227    })?;
228
229    let result = extract_impl(archive_path, temp_dir.path(), config, options, progress);
230
231    match result {
232        Ok(report) => {
233            // Consume TempDir to prevent Drop cleanup, then rename.
234            let temp_path = temp_dir.keep();
235            std::fs::rename(&temp_path, output_dir).map_err(|e| {
236                // Rename failed: clean up temp dir
237                let _ = std::fs::remove_dir_all(&temp_path);
238                // Map AlreadyExists to OutputExists for caller clarity
239                if e.kind() == std::io::ErrorKind::AlreadyExists {
240                    ExtractionError::OutputExists {
241                        path: output_dir.to_path_buf(),
242                    }
243                } else {
244                    ExtractionError::Io(std::io::Error::new(
245                        e.kind(),
246                        format!("failed to rename temp dir to {}: {e}", output_dir.display()),
247                    ))
248                }
249            })?;
250
251            Ok(report)
252        }
253        Err(e) => {
254            // TempDir Drop runs here: cleans up temp dir automatically.
255            Err(e)
256        }
257    }
258}
259
260fn extract_tar(
261    archive_path: &Path,
262    output_dir: &Path,
263    config: &SecurityConfig,
264    options: &ExtractionOptions,
265    progress: &mut dyn ProgressCallback,
266) -> Result<ExtractionReport> {
267    use crate::formats::TarArchive;
268    use crate::formats::traits::ArchiveFormat;
269    use std::fs::File;
270    use std::io::BufReader;
271
272    let file = File::open(archive_path)?;
273    let reader = BufReader::new(file);
274    let mut archive = TarArchive::new(reader);
275    archive.extract(output_dir, config, options, progress)
276}
277
278fn extract_tar_gz(
279    archive_path: &Path,
280    output_dir: &Path,
281    config: &SecurityConfig,
282    options: &ExtractionOptions,
283    progress: &mut dyn ProgressCallback,
284) -> Result<ExtractionReport> {
285    use crate::formats::TarArchive;
286    use crate::formats::traits::ArchiveFormat;
287    use flate2::read::GzDecoder;
288    use std::fs::File;
289    use std::io::BufReader;
290
291    let file = File::open(archive_path)?;
292    let reader = BufReader::new(file);
293    let decoder = GzDecoder::new(reader);
294    let mut archive = TarArchive::new(decoder);
295    archive.extract(output_dir, config, options, progress)
296}
297
298fn extract_tar_bz2(
299    archive_path: &Path,
300    output_dir: &Path,
301    config: &SecurityConfig,
302    options: &ExtractionOptions,
303    progress: &mut dyn ProgressCallback,
304) -> Result<ExtractionReport> {
305    use crate::formats::TarArchive;
306    use crate::formats::traits::ArchiveFormat;
307    use bzip2::read::BzDecoder;
308    use std::fs::File;
309    use std::io::BufReader;
310
311    let file = File::open(archive_path)?;
312    let reader = BufReader::new(file);
313    let decoder = BzDecoder::new(reader);
314    let mut archive = TarArchive::new(decoder);
315    archive.extract(output_dir, config, options, progress)
316}
317
318fn extract_tar_xz(
319    archive_path: &Path,
320    output_dir: &Path,
321    config: &SecurityConfig,
322    options: &ExtractionOptions,
323    progress: &mut dyn ProgressCallback,
324) -> Result<ExtractionReport> {
325    use crate::formats::TarArchive;
326    use crate::formats::traits::ArchiveFormat;
327    use std::fs::File;
328    use std::io::BufReader;
329    use xz2::read::XzDecoder;
330
331    let file = File::open(archive_path)?;
332    let reader = BufReader::new(file);
333    let decoder = XzDecoder::new(reader);
334    let mut archive = TarArchive::new(decoder);
335    archive.extract(output_dir, config, options, progress)
336}
337
338fn extract_tar_zst(
339    archive_path: &Path,
340    output_dir: &Path,
341    config: &SecurityConfig,
342    options: &ExtractionOptions,
343    progress: &mut dyn ProgressCallback,
344) -> Result<ExtractionReport> {
345    use crate::formats::TarArchive;
346    use crate::formats::traits::ArchiveFormat;
347    use std::fs::File;
348    use std::io::BufReader;
349    use zstd::stream::read::Decoder as ZstdDecoder;
350
351    let file = File::open(archive_path)?;
352    let reader = BufReader::new(file);
353    let decoder = ZstdDecoder::new(reader)?;
354    let mut archive = TarArchive::new(decoder);
355    archive.extract(output_dir, config, options, progress)
356}
357
358fn extract_zip(
359    archive_path: &Path,
360    output_dir: &Path,
361    config: &SecurityConfig,
362    options: &ExtractionOptions,
363    progress: &mut dyn ProgressCallback,
364) -> Result<ExtractionReport> {
365    use crate::formats::ZipArchive;
366    use crate::formats::traits::ArchiveFormat;
367    use std::fs::File;
368
369    let file = File::open(archive_path)?;
370    let mut archive = ZipArchive::new(file)?;
371    archive.extract(output_dir, config, options, progress)
372}
373
374fn extract_7z(
375    archive_path: &Path,
376    output_dir: &Path,
377    config: &SecurityConfig,
378    options: &ExtractionOptions,
379    progress: &mut dyn ProgressCallback,
380) -> Result<ExtractionReport> {
381    use crate::formats::SevenZArchive;
382    use crate::formats::traits::ArchiveFormat;
383    use std::fs::File;
384
385    let file = File::open(archive_path)?;
386    let mut archive = SevenZArchive::new(file)?;
387    archive.extract(output_dir, config, options, progress)
388}
389
390/// Creates an archive from source files and directories.
391///
392/// Format is auto-detected from output file extension, or can be
393/// explicitly set via `config.format`.
394///
395/// # Arguments
396///
397/// * `output_path` - Path to the output archive file
398/// * `sources` - Source files and directories to include
399/// * `config` - Creation configuration
400///
401/// # Errors
402///
403/// Returns an error if:
404/// - Cannot determine archive format
405/// - Source files don't exist
406/// - I/O operations fail
407/// - Configuration is invalid
408///
409/// # Examples
410///
411/// ```no_run
412/// use exarch_core::create_archive;
413/// use exarch_core::creation::CreationConfig;
414///
415/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
416/// let config = CreationConfig::default();
417/// let report = create_archive("output.tar.gz", &["src/", "Cargo.toml"], &config)?;
418/// println!("Created archive with {} files", report.files_added);
419/// # Ok(())
420/// # }
421/// ```
422pub fn create_archive<P: AsRef<Path>, Q: AsRef<Path>>(
423    output_path: P,
424    sources: &[Q],
425    config: &CreationConfig,
426) -> Result<CreationReport> {
427    let mut noop = NoopProgress;
428    create_archive_with_progress(output_path, sources, config, &mut noop)
429}
430
431/// Creates an archive with progress reporting.
432///
433/// Same as `create_archive` but accepts a `ProgressCallback` for
434/// real-time progress updates during creation.
435///
436/// # Arguments
437///
438/// * `output_path` - Path to the output archive file
439/// * `sources` - Source files and directories to include
440/// * `config` - Creation configuration
441/// * `progress` - Callback for progress updates
442///
443/// # Errors
444///
445/// Returns an error if:
446/// - Cannot determine archive format
447/// - Source files don't exist
448/// - I/O operations fail
449/// - Configuration is invalid
450///
451/// # Examples
452///
453/// ```no_run
454/// use exarch_core::NoopProgress;
455/// use exarch_core::create_archive_with_progress;
456/// use exarch_core::creation::CreationConfig;
457///
458/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
459/// let config = CreationConfig::default();
460/// let mut progress = NoopProgress;
461/// let report = create_archive_with_progress(
462///     "output.tar.gz",
463///     &["src/", "Cargo.toml"],
464///     &config,
465///     &mut progress,
466/// )?;
467/// println!("Created archive with {} files", report.files_added);
468/// # Ok(())
469/// # }
470/// ```
471pub fn create_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
472    output_path: P,
473    sources: &[Q],
474    config: &CreationConfig,
475    progress: &mut dyn ProgressCallback,
476) -> Result<CreationReport> {
477    config.validate()?;
478
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    let source_refs: Vec<&Path> = sources.iter().map(AsRef::as_ref).collect();
497    let creator = creator_for_format(format)?;
498    creator.create(output, &source_refs, config, progress)
499}
500
501fn creator_for_format(
502    format: ArchiveType,
503) -> Result<Box<dyn crate::formats::traits::FormatCreator>> {
504    match format {
505        ArchiveType::Tar => Ok(Box::new(crate::creation::TarCreator)),
506        ArchiveType::TarGz => Ok(Box::new(crate::creation::TarGzCreator)),
507        ArchiveType::TarBz2 => Ok(Box::new(crate::creation::TarBz2Creator)),
508        ArchiveType::TarXz => Ok(Box::new(crate::creation::TarXzCreator)),
509        ArchiveType::TarZst => Ok(Box::new(crate::creation::TarZstCreator)),
510        ArchiveType::Zip => Ok(Box::new(crate::creation::ZipCreator)),
511        ArchiveType::SevenZ => Err(ExtractionError::UnsupportedFormat),
512    }
513}
514
515/// Lists archive contents without extracting.
516///
517/// Returns a manifest containing metadata for all entries in the archive.
518/// No files are written to disk during this operation.
519///
520/// # Arguments
521///
522/// * `archive_path` - Path to archive file
523/// * `config` - Security configuration (quota limits apply)
524///
525/// # Errors
526///
527/// Returns error if:
528/// - Archive file cannot be opened
529/// - Archive format is unsupported or corrupted
530/// - Quota limits exceeded (file count, total size)
531///
532/// # Examples
533///
534/// ```no_run
535/// use exarch_core::SecurityConfig;
536/// use exarch_core::list_archive;
537///
538/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
539/// let config = SecurityConfig::default();
540/// let manifest = list_archive("archive.tar.gz", &config)?;
541///
542/// println!("Archive contains {} files", manifest.total_entries);
543/// for entry in manifest.entries {
544///     println!("{}: {} bytes", entry.path.display(), entry.size);
545/// }
546/// # Ok(())
547/// # }
548/// ```
549pub fn list_archive<P: AsRef<Path>>(
550    archive_path: P,
551    config: &SecurityConfig,
552) -> Result<ArchiveManifest> {
553    crate::inspection::list_archive(archive_path, config)
554}
555
556/// Verifies archive integrity and security without extracting.
557///
558/// Performs comprehensive validation:
559/// - Integrity checks (structure, checksums)
560/// - Security checks (path traversal, zip bombs, CVEs)
561/// - Policy checks (file types, permissions)
562///
563/// # Arguments
564///
565/// * `archive_path` - Path to archive file
566/// * `config` - Security configuration for validation
567///
568/// # Errors
569///
570/// Returns error if:
571/// - Archive file cannot be opened
572/// - Archive is severely corrupted (cannot read structure)
573///
574/// Security violations are reported in `VerificationReport.issues`,
575/// not as errors.
576///
577/// # Examples
578///
579/// ```no_run
580/// use exarch_core::SecurityConfig;
581/// use exarch_core::VerificationStatus;
582/// use exarch_core::verify_archive;
583///
584/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
585/// let config = SecurityConfig::default();
586/// let report = verify_archive("archive.tar.gz", &config)?;
587///
588/// if report.status == VerificationStatus::Pass {
589///     println!("Archive is safe to extract");
590/// } else {
591///     eprintln!("Security issues found:");
592///     for issue in report.issues {
593///         eprintln!("  [{}] {}", issue.severity, issue.message);
594///     }
595/// }
596/// # Ok(())
597/// # }
598/// ```
599pub fn verify_archive<P: AsRef<Path>>(
600    archive_path: P,
601    config: &SecurityConfig,
602) -> Result<VerificationReport> {
603    crate::inspection::verify_archive(archive_path, config)
604}
605
606/// Rejects creation for ZIP-family extensions that aren't plain `.zip`.
607///
608/// See the call site in `create_archive_with_progress` for the rationale.
609/// Returns `Ok(())` for anything else - `.zip`, tar variants, unknown
610/// extensions (those get caught later by `detect_format`).
611fn reject_zip_family_creation(output: &Path) -> Result<()> {
612    let Some(ext) = output.extension().and_then(|e| e.to_str()) else {
613        return Ok(());
614    };
615    if is_zip_family_alias(ext) {
616        let ext_lower = ext.to_ascii_lowercase();
617        return Err(ExtractionError::InvalidArchive(format!(
618            "creation for .{ext_lower} isn't supported: the format is ZIP-based but \
619             requires extra structure (signing, manifests, ordering) that exarch \
620             doesn't produce. Use .zip, or set CreationConfig::format = Some(\
621             exarch_core::formats::detect::ArchiveType::Zip) to override."
622        )));
623    }
624    Ok(())
625}
626
627/// Determines archive format from output path or config.
628fn determine_creation_format(output: &Path, config: &CreationConfig) -> Result<ArchiveType> {
629    // If format explicitly set in config, use it
630    if let Some(format) = config.format {
631        return Ok(format);
632    }
633
634    // Auto-detect from extension
635    detect_format(output)
636}
637
638#[cfg(test)]
639#[allow(clippy::unwrap_used)]
640mod tests {
641    use super::*;
642    use std::path::PathBuf;
643
644    #[test]
645    fn test_extract_archive_nonexistent_file() {
646        let config = SecurityConfig::default();
647        let result = extract_archive(
648            PathBuf::from("nonexistent_test.tar"),
649            PathBuf::from("/tmp/test"),
650            &config,
651        );
652        // Should fail because file doesn't exist
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_determine_creation_format_tar() {
658        let config = CreationConfig::default();
659        let path = PathBuf::from("archive.tar");
660        let format = determine_creation_format(&path, &config).unwrap();
661        assert_eq!(format, ArchiveType::Tar);
662    }
663
664    #[test]
665    fn test_determine_creation_format_tar_gz() {
666        let config = CreationConfig::default();
667        let path = PathBuf::from("archive.tar.gz");
668        let format = determine_creation_format(&path, &config).unwrap();
669        assert_eq!(format, ArchiveType::TarGz);
670
671        let path2 = PathBuf::from("archive.tgz");
672        let format2 = determine_creation_format(&path2, &config).unwrap();
673        assert_eq!(format2, ArchiveType::TarGz);
674    }
675
676    #[test]
677    fn test_determine_creation_format_tar_bz2() {
678        let config = CreationConfig::default();
679        let path = PathBuf::from("archive.tar.bz2");
680        let format = determine_creation_format(&path, &config).unwrap();
681        assert_eq!(format, ArchiveType::TarBz2);
682    }
683
684    #[test]
685    fn test_determine_creation_format_tar_xz() {
686        let config = CreationConfig::default();
687        let path = PathBuf::from("archive.tar.xz");
688        let format = determine_creation_format(&path, &config).unwrap();
689        assert_eq!(format, ArchiveType::TarXz);
690    }
691
692    #[test]
693    fn test_determine_creation_format_tar_zst() {
694        let config = CreationConfig::default();
695        let path = PathBuf::from("archive.tar.zst");
696        let format = determine_creation_format(&path, &config).unwrap();
697        assert_eq!(format, ArchiveType::TarZst);
698    }
699
700    #[test]
701    fn test_determine_creation_format_zip() {
702        let config = CreationConfig::default();
703        let path = PathBuf::from("archive.zip");
704        let format = determine_creation_format(&path, &config).unwrap();
705        assert_eq!(format, ArchiveType::Zip);
706    }
707
708    #[test]
709    fn test_determine_creation_format_explicit() {
710        let config = CreationConfig::default().with_format(Some(ArchiveType::TarGz));
711        let path = PathBuf::from("archive.xyz");
712        let format = determine_creation_format(&path, &config).unwrap();
713        assert_eq!(format, ArchiveType::TarGz);
714    }
715
716    #[test]
717    fn test_determine_creation_format_unknown() {
718        let config = CreationConfig::default();
719        let path = PathBuf::from("archive.rar");
720        let result = determine_creation_format(&path, &config);
721        assert!(result.is_err());
722    }
723
724    #[test]
725    fn test_extract_archive_7z_not_implemented() {
726        let dest = tempfile::TempDir::new().unwrap();
727        let path = PathBuf::from("test.7z");
728
729        let result = extract_archive(&path, dest.path(), &SecurityConfig::default());
730
731        assert!(result.is_err());
732    }
733
734    #[test]
735    fn test_create_archive_invalid_compression_level_rejected_before_io() {
736        let dest = tempfile::TempDir::new().unwrap();
737        let archive_path = dest.path().join("output.tar.gz");
738        let config = CreationConfig {
739            compression_level: Some(15),
740            ..CreationConfig::default()
741        };
742        let result = create_archive(&archive_path, &[] as &[&str], &config);
743        assert!(
744            matches!(
745                result,
746                Err(ExtractionError::InvalidCompressionLevel { level: 15 })
747            ),
748            "expected InvalidCompressionLevel, got {result:?}",
749        );
750        // Verify no I/O happened — output file must not exist
751        assert!(!archive_path.exists(), "output file must not be created");
752    }
753
754    #[test]
755    fn test_create_archive_zip_family_not_supported() {
756        // Mirrors test_create_archive_7z_not_supported. Spot-checks a couple
757        // of extensions rather than every one - the integration test
758        // covers the full list.
759        let dest = tempfile::TempDir::new().unwrap();
760        for ext in ["apk", "whl", "EPUB"] {
761            let archive_path = dest.path().join(format!("output.{ext}"));
762            let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
763            assert!(
764                matches!(result, Err(ExtractionError::InvalidArchive(_))),
765                ".{ext} should be rejected, got {result:?}",
766            );
767        }
768    }
769
770    #[test]
771    fn test_create_archive_zip_family_override_bypasses_guard() {
772        // Explicit CreationConfig::format = Some(Zip) is the escape hatch -
773        // skips the ZIP-family guard. Caller takes responsibility for the
774        // resulting file not being spec-valid.
775        let dest = tempfile::TempDir::new().unwrap();
776        let src = dest.path().join("source.txt");
777        std::fs::write(&src, b"hello").unwrap();
778        let archive_path = dest.path().join("output.apk");
779        let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
780        let result = create_archive(&archive_path, &[&src], &config);
781        assert!(
782            result.is_ok(),
783            "explicit format override should bypass the guard, got {result:?}",
784        );
785    }
786
787    #[test]
788    fn test_create_archive_7z_not_supported() {
789        let dest = tempfile::TempDir::new().unwrap();
790        let archive_path = dest.path().join("output.7z");
791
792        let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
793
794        assert!(result.is_err());
795        assert!(matches!(
796            result.unwrap_err(),
797            ExtractionError::UnsupportedFormat
798        ));
799    }
800
801    #[test]
802    fn test_extract_archive_with_options_and_progress_non_atomic_delegates_to_normal() {
803        let dest = tempfile::TempDir::new().unwrap();
804        let options = ExtractionOptions {
805            atomic: false,
806            skip_duplicates: true,
807        };
808        let result = extract_archive_with_options_and_progress(
809            PathBuf::from("nonexistent.tar.gz"),
810            dest.path(),
811            &SecurityConfig::default(),
812            &options,
813            &mut NoopProgress,
814        );
815        assert!(result.is_err());
816    }
817
818    #[test]
819    fn test_extract_archive_with_options_delegates() {
820        let dest = tempfile::TempDir::new().unwrap();
821        let options = ExtractionOptions {
822            atomic: false,
823            skip_duplicates: true,
824        };
825        let result = extract_archive_with_options(
826            PathBuf::from("nonexistent.tar.gz"),
827            dest.path(),
828            &SecurityConfig::default(),
829            &options,
830        );
831        assert!(result.is_err());
832    }
833
834    #[test]
835    fn test_extract_atomic_success() {
836        use crate::create_archive;
837        use crate::creation::CreationConfig;
838
839        // Create a valid tar.gz to extract
840        let archive_dir = tempfile::TempDir::new().unwrap();
841        let archive_path = archive_dir.path().join("test.tar.gz");
842
843        // Create a simple archive with one file
844        let src_dir = tempfile::TempDir::new().unwrap();
845        std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
846        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
847
848        let parent = tempfile::TempDir::new().unwrap();
849        let output_dir = parent.path().join("extracted");
850
851        let options = ExtractionOptions {
852            atomic: true,
853            skip_duplicates: true,
854        };
855        let result = extract_archive_with_options(
856            &archive_path,
857            &output_dir,
858            &SecurityConfig::default(),
859            &options,
860        );
861
862        assert!(result.is_ok());
863        assert!(output_dir.exists());
864        // No temp dir remnants
865        let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
866        assert_eq!(
867            temp_entries.len(),
868            1,
869            "Expected only the output dir, found temp remnants"
870        );
871    }
872
873    #[test]
874    fn test_extract_atomic_failure_cleans_up() {
875        let parent = tempfile::TempDir::new().unwrap();
876        let output_dir = parent.path().join("extracted");
877
878        let options = ExtractionOptions {
879            atomic: true,
880            skip_duplicates: true,
881        };
882        let result = extract_archive_with_options(
883            PathBuf::from("nonexistent_archive.tar.gz"),
884            &output_dir,
885            &SecurityConfig::default(),
886            &options,
887        );
888
889        assert!(result.is_err());
890        // Output dir must not exist
891        assert!(!output_dir.exists());
892        // No temp dir remnants in parent
893        let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
894        assert!(
895            temp_entries.is_empty(),
896            "Temp dir not cleaned up after failure"
897        );
898    }
899
900    #[test]
901    fn test_extract_atomic_output_already_exists_fails() {
902        use crate::create_archive;
903        use crate::creation::CreationConfig;
904
905        let parent = tempfile::TempDir::new().unwrap();
906        let output_dir = parent.path().join("extracted");
907        std::fs::create_dir_all(&output_dir).unwrap();
908        // Create a file in output_dir so it's non-empty (rename over non-empty dir
909        // fails on most OSes)
910        std::fs::write(output_dir.join("existing.txt"), b"old content").unwrap();
911
912        let archive_dir = tempfile::TempDir::new().unwrap();
913        let archive_path = archive_dir.path().join("test.tar.gz");
914        let src_dir = tempfile::TempDir::new().unwrap();
915        std::fs::write(src_dir.path().join("new.txt"), b"new content").unwrap();
916        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
917
918        let options = ExtractionOptions {
919            atomic: true,
920            skip_duplicates: true,
921        };
922        let result = extract_archive_with_options(
923            &archive_path,
924            &output_dir,
925            &SecurityConfig::default(),
926            &options,
927        );
928
929        // Should fail with OutputExists or Io (platform dependent rename semantics)
930        assert!(result.is_err());
931        // Output dir must still have old content (not corrupted)
932        assert!(output_dir.join("existing.txt").exists());
933    }
934
935    // Regression test for issue #170: progress callback silently dropped
936    #[test]
937    fn test_progress_callback_invoked_during_extraction() {
938        use crate::ProgressCallback;
939        use std::path::Path;
940
941        struct TrackingProgress {
942            started: usize,
943            completed: usize,
944            finished: bool,
945        }
946
947        impl ProgressCallback for TrackingProgress {
948            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
949                self.started += 1;
950            }
951
952            fn on_bytes_written(&mut self, _bytes: u64) {}
953
954            fn on_entry_complete(&mut self, _path: &Path) {
955                self.completed += 1;
956            }
957
958            fn on_complete(&mut self) {
959                self.finished = true;
960            }
961        }
962
963        let archive_dir = tempfile::TempDir::new().unwrap();
964        let archive_path = archive_dir.path().join("test.tar.gz");
965        let src_dir = tempfile::TempDir::new().unwrap();
966        std::fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
967        std::fs::write(src_dir.path().join("b.txt"), b"world").unwrap();
968        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
969
970        let dest = tempfile::TempDir::new().unwrap();
971        let mut progress = TrackingProgress {
972            started: 0,
973            completed: 0,
974            finished: false,
975        };
976
977        let report = extract_archive_with_progress(
978            &archive_path,
979            dest.path(),
980            &SecurityConfig::default(),
981            &mut progress,
982        )
983        .unwrap();
984
985        assert!(report.files_extracted >= 2, "expected at least 2 files");
986        assert!(progress.started >= 2, "on_entry_start not called");
987        assert!(progress.completed >= 2, "on_entry_complete not called");
988        assert!(progress.finished, "on_complete not called");
989    }
990
991    // Regression test for issue #170: ZIP format
992    #[test]
993    fn test_progress_callback_invoked_during_zip_extraction() {
994        use crate::ProgressCallback;
995        use std::path::Path;
996
997        struct TrackingProgress {
998            started: usize,
999            completed: usize,
1000            finished: bool,
1001        }
1002
1003        impl ProgressCallback for TrackingProgress {
1004            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1005                self.started += 1;
1006            }
1007
1008            fn on_bytes_written(&mut self, _bytes: u64) {}
1009
1010            fn on_entry_complete(&mut self, _path: &Path) {
1011                self.completed += 1;
1012            }
1013
1014            fn on_complete(&mut self) {
1015                self.finished = true;
1016            }
1017        }
1018
1019        let tmp = tempfile::TempDir::new().unwrap();
1020        let archive_path = tmp.path().join("test.zip");
1021        let src_dir = tempfile::TempDir::new().unwrap();
1022        std::fs::write(src_dir.path().join("x.txt"), b"foo").unwrap();
1023        std::fs::write(src_dir.path().join("y.txt"), b"bar").unwrap();
1024        let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1025        create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1026
1027        let dest = tempfile::TempDir::new().unwrap();
1028        let mut progress = TrackingProgress {
1029            started: 0,
1030            completed: 0,
1031            finished: false,
1032        };
1033        let report = extract_archive_with_progress(
1034            &archive_path,
1035            dest.path(),
1036            &SecurityConfig::default(),
1037            &mut progress,
1038        )
1039        .unwrap();
1040
1041        assert!(report.files_extracted >= 2, "expected at least 2 files");
1042        assert!(progress.started >= 2, "on_entry_start not called for ZIP");
1043        assert!(
1044            progress.completed >= 2,
1045            "on_entry_complete not called for ZIP"
1046        );
1047        assert!(progress.finished, "on_complete not called for ZIP");
1048    }
1049
1050    // Regression test for issue #170: 7z format
1051    #[test]
1052    fn test_progress_callback_invoked_during_sevenz_extraction() {
1053        use crate::ProgressCallback;
1054        use std::path::Path;
1055
1056        struct TrackingProgress {
1057            started: usize,
1058            completed: usize,
1059            finished: bool,
1060        }
1061
1062        impl ProgressCallback for TrackingProgress {
1063            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1064                self.started += 1;
1065            }
1066
1067            fn on_bytes_written(&mut self, _bytes: u64) {}
1068
1069            fn on_entry_complete(&mut self, _path: &Path) {
1070                self.completed += 1;
1071            }
1072
1073            fn on_complete(&mut self) {
1074                self.finished = true;
1075            }
1076        }
1077
1078        let fixture =
1079            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1080
1081        let dest = tempfile::TempDir::new().unwrap();
1082        let mut progress = TrackingProgress {
1083            started: 0,
1084            completed: 0,
1085            finished: false,
1086        };
1087        let report = extract_archive_with_progress(
1088            &fixture,
1089            dest.path(),
1090            &SecurityConfig::default(),
1091            &mut progress,
1092        )
1093        .unwrap();
1094
1095        assert!(
1096            report.files_extracted >= 1,
1097            "expected at least 1 file from simple.7z"
1098        );
1099        assert!(progress.started >= 1, "on_entry_start not called for 7z");
1100        assert!(
1101            progress.completed >= 1,
1102            "on_entry_complete not called for 7z"
1103        );
1104        assert!(progress.finished, "on_complete not called for 7z");
1105    }
1106}