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