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::detect_format_from_extension;
17use crate::formats::detect::is_zip_family_alias;
18use crate::inspection::ArchiveManifest;
19use crate::inspection::VerificationReport;
20
21/// Extracts an archive to the specified output directory.
22///
23/// This is the main high-level API for extracting archives with security
24/// validation. The archive format is automatically detected.
25///
26/// # Arguments
27///
28/// * `archive_path` - Path to the archive file
29/// * `output_dir` - Directory where files will be extracted
30/// * `config` - Security configuration for the extraction
31///
32/// # Errors
33///
34/// Returns an error if:
35/// - Archive file cannot be opened
36/// - Archive format is unsupported
37/// - Security validation fails
38/// - I/O operations fail
39///
40/// # Examples
41///
42/// ```no_run
43/// use exarch_core::SecurityConfig;
44/// use exarch_core::extract_archive;
45///
46/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
47/// let config = SecurityConfig::default();
48/// let report = extract_archive("archive.tar.gz", "/tmp/output", &config)?;
49/// println!("Extracted {} files", report.files_extracted);
50/// # Ok(())
51/// # }
52/// ```
53pub fn extract_archive<P: AsRef<Path>, Q: AsRef<Path>>(
54    archive_path: P,
55    output_dir: Q,
56    config: &SecurityConfig,
57) -> Result<ExtractionReport> {
58    let mut noop = NoopProgress;
59    extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
60}
61
62/// Extracts an archive with progress reporting.
63///
64/// Same as `extract_archive` but accepts a `ProgressCallback` for
65/// real-time progress updates during extraction.
66///
67/// # Arguments
68///
69/// * `archive_path` - Path to the archive file
70/// * `output_dir` - Directory where files will be extracted
71/// * `config` - Security configuration for the extraction
72/// * `progress` - Callback for progress updates
73///
74/// # Errors
75///
76/// Returns an error if:
77/// - Archive file cannot be opened
78/// - Archive format is unsupported
79/// - Security validation fails
80/// - I/O operations fail
81///
82/// # Examples
83///
84/// ```no_run
85/// use exarch_core::NoopProgress;
86/// use exarch_core::SecurityConfig;
87/// use exarch_core::extract_archive_with_progress;
88///
89/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
90/// let config = SecurityConfig::default();
91/// let mut progress = NoopProgress;
92/// let report =
93///     extract_archive_with_progress("archive.tar.gz", "/tmp/output", &config, &mut progress)?;
94/// println!("Extracted {} files", report.files_extracted);
95/// # Ok(())
96/// # }
97/// ```
98pub fn extract_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
99    archive_path: P,
100    output_dir: Q,
101    config: &SecurityConfig,
102    progress: &mut dyn ProgressCallback,
103) -> Result<ExtractionReport> {
104    let options = ExtractionOptions::default();
105    extract_archive_with_options_and_progress(archive_path, output_dir, config, &options, progress)
106}
107
108fn extract_impl<P: AsRef<Path>, Q: AsRef<Path>>(
109    archive_path: P,
110    output_dir: Q,
111    config: &SecurityConfig,
112    options: &ExtractionOptions,
113    progress: &mut dyn ProgressCallback,
114) -> Result<ExtractionReport> {
115    config.validate()?;
116
117    let archive_path = archive_path.as_ref();
118    let output_dir = output_dir.as_ref();
119
120    // Detect archive format from file extension
121    let format = detect_format(archive_path)?;
122
123    // Dispatch to format-specific extraction
124    match format {
125        ArchiveType::Tar => {
126            extract_tar_with_decoder(archive_path, output_dir, config, options, progress, Ok)
127        }
128        ArchiveType::TarGz => {
129            extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
130                Ok(flate2::read::GzDecoder::new(r))
131            })
132        }
133        ArchiveType::TarBz2 => {
134            extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
135                Ok(bzip2::read::BzDecoder::new(r))
136            })
137        }
138        ArchiveType::TarXz => {
139            extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
140                Ok(xz2::read::XzDecoder::new(r))
141            })
142        }
143        ArchiveType::TarZst => {
144            extract_tar_with_decoder(archive_path, output_dir, config, options, progress, |r| {
145                Ok(zstd::stream::read::Decoder::new(r)?)
146            })
147        }
148        ArchiveType::Zip => extract_zip(archive_path, output_dir, config, options, progress),
149        ArchiveType::SevenZ => extract_7z(archive_path, output_dir, config, options, progress),
150    }
151}
152
153/// Extracts an archive with extraction options and optional progress reporting.
154///
155/// This is the canonical extraction implementation. All other
156/// `extract_archive*` functions are thin wrappers that delegate here. Use this
157/// directly when you need both [`ExtractionOptions`] (e.g., atomic mode) and a
158/// progress callback.
159///
160/// # Arguments
161///
162/// * `archive_path` - Path to the archive file
163/// * `output_dir` - Directory where files will be extracted
164/// * `config` - Security configuration for the extraction
165/// * `options` - Extraction behavior options (e.g., atomic mode)
166/// * `progress` - Callback for progress updates
167///
168/// # Errors
169///
170/// Returns an error if:
171/// - Archive file cannot be opened
172/// - Archive format is unsupported
173/// - Security validation fails
174/// - I/O operations fail
175/// - Atomic temp dir creation or rename fails
176///
177/// # Examples
178///
179/// ```no_run
180/// use exarch_core::ExtractionOptions;
181/// use exarch_core::NoopProgress;
182/// use exarch_core::SecurityConfig;
183/// use exarch_core::extract_archive_with_options_and_progress;
184///
185/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
186/// let config = SecurityConfig::default();
187/// let options = ExtractionOptions::default().with_atomic(true);
188/// let mut progress = NoopProgress;
189/// let report = extract_archive_with_options_and_progress(
190///     "archive.tar.gz",
191///     "/tmp/output",
192///     &config,
193///     &options,
194///     &mut progress,
195/// )?;
196/// println!("Extracted {} files", report.files_extracted);
197/// # Ok(())
198/// # }
199/// ```
200pub fn extract_archive_with_options_and_progress<P: AsRef<Path>, Q: AsRef<Path>>(
201    archive_path: P,
202    output_dir: Q,
203    config: &SecurityConfig,
204    options: &ExtractionOptions,
205    progress: &mut dyn ProgressCallback,
206) -> Result<ExtractionReport> {
207    if options.atomic {
208        extract_atomic(archive_path, output_dir, config, options, progress)
209    } else {
210        extract_impl(archive_path, output_dir, config, options, progress)
211    }
212}
213
214/// Extracts an archive with extraction options (no progress reporting).
215///
216/// Convenience wrapper around [`extract_archive_with_options_and_progress`]
217/// that passes a no-op progress callback. Use this when you need
218/// [`ExtractionOptions`] but do not require progress updates.
219///
220/// # Errors
221///
222/// Returns an error if:
223/// - Archive file cannot be opened
224/// - Archive format is unsupported
225/// - Security validation fails
226/// - I/O operations fail
227/// - Atomic temp dir creation or rename fails
228///
229/// # Examples
230///
231/// ```no_run
232/// use exarch_core::ExtractionOptions;
233/// use exarch_core::SecurityConfig;
234/// use exarch_core::extract_archive_with_options;
235///
236/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
237/// let config = SecurityConfig::default();
238/// let options = ExtractionOptions::default().with_atomic(true);
239/// let report = extract_archive_with_options("archive.tar.gz", "/tmp/output", &config, &options)?;
240/// println!("Extracted {} files", report.files_extracted);
241/// # Ok(())
242/// # }
243/// ```
244pub fn extract_archive_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
245    archive_path: P,
246    output_dir: Q,
247    config: &SecurityConfig,
248    options: &ExtractionOptions,
249) -> Result<ExtractionReport> {
250    let mut noop = NoopProgress;
251    extract_archive_with_options_and_progress(archive_path, output_dir, config, options, &mut noop)
252}
253
254fn extract_atomic<P: AsRef<Path>, Q: AsRef<Path>>(
255    archive_path: P,
256    output_dir: Q,
257    config: &SecurityConfig,
258    options: &ExtractionOptions,
259    progress: &mut dyn ProgressCallback,
260) -> Result<ExtractionReport> {
261    let output_dir = output_dir.as_ref();
262
263    // Canonicalize output_dir to resolve any symlinks in the path before
264    // computing the parent, so temp dir lands on the same filesystem.
265    // If output_dir doesn't exist yet, use its lexical parent.
266    let canonical_output = if output_dir.exists() {
267        output_dir.canonicalize().map_err(ArchiveError::Io)?
268    } else {
269        output_dir.to_path_buf()
270    };
271
272    let parent = canonical_output
273        .parent()
274        .ok_or_else(|| ArchiveError::InvalidConfiguration {
275            reason: "output directory has no parent".into(),
276        })?;
277
278    std::fs::create_dir_all(parent).map_err(ArchiveError::Io)?;
279
280    let temp_dir = tempfile::tempdir_in(parent).map_err(|e| {
281        ArchiveError::Io(std::io::Error::new(
282            e.kind(),
283            format!(
284                "failed to create temp directory in {}: {e}",
285                parent.display()
286            ),
287        ))
288    })?;
289
290    let result = extract_impl(archive_path, temp_dir.path(), config, options, progress);
291
292    match result {
293        Ok(report) => {
294            // Consume TempDir to prevent Drop cleanup, then rename.
295            let temp_path = temp_dir.keep();
296            std::fs::rename(&temp_path, output_dir).map_err(|e| {
297                // Rename failed: clean up temp dir
298                let _ = std::fs::remove_dir_all(&temp_path);
299                // Map AlreadyExists to OutputExists for caller clarity
300                if e.kind() == std::io::ErrorKind::AlreadyExists {
301                    ArchiveError::OutputExists {
302                        path: output_dir.to_path_buf(),
303                    }
304                } else {
305                    ArchiveError::Io(std::io::Error::new(
306                        e.kind(),
307                        format!("failed to rename temp dir to {}: {e}", output_dir.display()),
308                    ))
309                }
310            })?;
311
312            Ok(report)
313        }
314        Err(e) => {
315            // TempDir Drop runs here: cleans up temp dir automatically.
316            Err(e)
317        }
318    }
319}
320
321/// Opens `archive_path`, wraps it in a `BufReader`, passes it to
322/// `make_decoder`, and extracts the resulting TAR stream.
323///
324/// `make_decoder` builds a decoder (e.g. `GzDecoder`, `XzDecoder`) from the
325/// buffered file reader. For uncompressed TAR pass `Ok` as the identity
326/// closure. The closure may be fallible (e.g. zstd requires a constructor call
327/// that can fail with an I/O error).
328fn extract_tar_with_decoder<R, F>(
329    archive_path: &Path,
330    output_dir: &Path,
331    config: &SecurityConfig,
332    options: &ExtractionOptions,
333    progress: &mut dyn ProgressCallback,
334    make_decoder: F,
335) -> Result<ExtractionReport>
336where
337    R: std::io::Read,
338    F: FnOnce(std::io::BufReader<std::fs::File>) -> Result<R>,
339{
340    use crate::formats::TarArchive;
341    use crate::formats::traits::ArchiveFormat;
342
343    let file = std::fs::File::open(archive_path)?;
344    let reader = std::io::BufReader::new(file);
345    let decoder = make_decoder(reader)?;
346    let mut archive = TarArchive::new(decoder);
347    archive.extract(output_dir, config, options, progress)
348}
349
350fn extract_zip(
351    archive_path: &Path,
352    output_dir: &Path,
353    config: &SecurityConfig,
354    options: &ExtractionOptions,
355    progress: &mut dyn ProgressCallback,
356) -> Result<ExtractionReport> {
357    use crate::formats::ZipArchive;
358    use crate::formats::traits::ArchiveFormat;
359    use std::fs::File;
360
361    let file = File::open(archive_path)?;
362    let mut archive = ZipArchive::new(file)?;
363    archive.extract(output_dir, config, options, progress)
364}
365
366fn extract_7z(
367    archive_path: &Path,
368    output_dir: &Path,
369    config: &SecurityConfig,
370    options: &ExtractionOptions,
371    progress: &mut dyn ProgressCallback,
372) -> Result<ExtractionReport> {
373    use crate::formats::SevenZArchive;
374    use crate::formats::traits::ArchiveFormat;
375    use std::fs::File;
376
377    let file = File::open(archive_path)?;
378    let mut archive = SevenZArchive::new(file)?;
379    archive.extract(output_dir, config, options, progress)
380}
381
382/// Creates an archive from source files and directories.
383///
384/// Format is auto-detected from output file extension, or can be
385/// explicitly set via `config.format`.
386///
387/// # Arguments
388///
389/// * `output_path` - Path to the output archive file
390/// * `sources` - Source files and directories to include
391/// * `config` - Creation configuration
392///
393/// # Errors
394///
395/// Returns an error if:
396/// - Cannot determine archive format
397/// - Source files don't exist
398/// - I/O operations fail
399/// - Configuration is invalid
400///
401/// # Examples
402///
403/// ```no_run
404/// use exarch_core::create_archive;
405/// use exarch_core::creation::CreationConfig;
406///
407/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
408/// let config = CreationConfig::default();
409/// let report = create_archive("output.tar.gz", &["src/", "Cargo.toml"], &config)?;
410/// println!("Created archive with {} files", report.files_added);
411/// # Ok(())
412/// # }
413/// ```
414pub fn create_archive<P: AsRef<Path>, Q: AsRef<Path>>(
415    output_path: P,
416    sources: &[Q],
417    config: &CreationConfig,
418) -> Result<CreationReport> {
419    let mut noop = NoopProgress;
420    create_archive_with_progress(output_path, sources, config, &mut noop)
421}
422
423/// Creates an archive with progress reporting.
424///
425/// Same as `create_archive` but accepts a `ProgressCallback` for
426/// real-time progress updates during creation.
427///
428/// # Arguments
429///
430/// * `output_path` - Path to the output archive file
431/// * `sources` - Source files and directories to include
432/// * `config` - Creation configuration
433/// * `progress` - Callback for progress updates
434///
435/// # Errors
436///
437/// Returns an error if:
438/// - Cannot determine archive format
439/// - Source files don't exist
440/// - I/O operations fail
441/// - Configuration is invalid
442///
443/// # Examples
444///
445/// ```no_run
446/// use exarch_core::NoopProgress;
447/// use exarch_core::create_archive_with_progress;
448/// use exarch_core::creation::CreationConfig;
449///
450/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
451/// let config = CreationConfig::default();
452/// let mut progress = NoopProgress;
453/// let report = create_archive_with_progress(
454///     "output.tar.gz",
455///     &["src/", "Cargo.toml"],
456///     &config,
457///     &mut progress,
458/// )?;
459/// println!("Created archive with {} files", report.files_added);
460/// # Ok(())
461/// # }
462/// ```
463pub fn create_archive_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
464    output_path: P,
465    sources: &[Q],
466    config: &CreationConfig,
467    progress: &mut dyn ProgressCallback,
468) -> Result<CreationReport> {
469    config.validate()?;
470
471    let output = output_path.as_ref();
472
473    // Block creation for the ZIP-family extensions (mirrors the 7z block
474    // below). They're all ZIP underneath but add extra requirements -
475    // signing (apk/aab/ipa/appx/msix), checksum manifests (whl), ordering
476    // and stored-compression rules (epub), descriptor files
477    // (war/ear/vsix/nbm) - which exarch doesn't produce. Silently emitting
478    // a bare ZIP with one of these extensions would be misleading, so we
479    // error instead. Callers who need the override can set
480    // CreationConfig::format = Some(ArchiveType::Zip).
481    if config.format.is_none() {
482        reject_zip_family_creation(output)?;
483    }
484
485    // Determine format from extension or config
486    let format = determine_creation_format(output, config)?;
487
488    let source_refs: Vec<&Path> = sources.iter().map(AsRef::as_ref).collect();
489    let creator = creator_for_format(format)?;
490    creator.create(output, &source_refs, config, progress)
491}
492
493fn creator_for_format(
494    format: ArchiveType,
495) -> Result<Box<dyn crate::formats::traits::FormatCreator>> {
496    match format {
497        ArchiveType::Tar => Ok(Box::new(crate::creation::TarCreator)),
498        ArchiveType::TarGz => Ok(Box::new(crate::creation::TarGzCreator)),
499        ArchiveType::TarBz2 => Ok(Box::new(crate::creation::TarBz2Creator)),
500        ArchiveType::TarXz => Ok(Box::new(crate::creation::TarXzCreator)),
501        ArchiveType::TarZst => Ok(Box::new(crate::creation::TarZstCreator)),
502        ArchiveType::Zip => Ok(Box::new(crate::creation::ZipCreator)),
503        ArchiveType::SevenZ => Err(ArchiveError::InvalidConfiguration {
504            reason: "7z archive creation is not supported".into(),
505        }),
506    }
507}
508
509/// Lists archive contents without extracting.
510///
511/// Returns a manifest containing metadata for all entries in the archive.
512/// No files are written to disk during this operation.
513///
514/// # Arguments
515///
516/// * `archive_path` - Path to archive file
517/// * `config` - Security configuration (quota limits apply)
518///
519/// # Errors
520///
521/// Returns error if:
522/// - Archive file cannot be opened
523/// - Archive format is unsupported or corrupted
524/// - Quota limits exceeded (file count, total size)
525///
526/// # Examples
527///
528/// ```no_run
529/// use exarch_core::SecurityConfig;
530/// use exarch_core::list_archive;
531///
532/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
533/// let config = SecurityConfig::default();
534/// let manifest = list_archive("archive.tar.gz", &config)?;
535///
536/// println!("Archive contains {} files", manifest.total_entries);
537/// for entry in manifest.entries {
538///     println!("{}: {} bytes", entry.path.display(), entry.size);
539/// }
540/// # Ok(())
541/// # }
542/// ```
543pub fn list_archive<P: AsRef<Path>>(
544    archive_path: P,
545    config: &SecurityConfig,
546) -> Result<ArchiveManifest> {
547    crate::inspection::list_archive(archive_path, config)
548}
549
550/// Verifies archive integrity and security without extracting.
551///
552/// Performs comprehensive validation:
553/// - Integrity checks (structure, checksums)
554/// - Security checks (path traversal, zip bombs, CVEs)
555/// - Policy checks (file types, permissions)
556///
557/// # Arguments
558///
559/// * `archive_path` - Path to archive file
560/// * `config` - Security configuration for validation
561///
562/// # Errors
563///
564/// Returns error if:
565/// - Archive file cannot be opened
566/// - Archive is severely corrupted (cannot read structure)
567///
568/// Security violations are reported in `VerificationReport.issues`,
569/// not as errors.
570///
571/// # Examples
572///
573/// ```no_run
574/// use exarch_core::SecurityConfig;
575/// use exarch_core::VerificationStatus;
576/// use exarch_core::verify_archive;
577///
578/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
579/// let config = SecurityConfig::default();
580/// let report = verify_archive("archive.tar.gz", &config)?;
581///
582/// if report.status == VerificationStatus::Pass {
583///     println!("Archive is safe to extract");
584/// } else {
585///     eprintln!("Security issues found:");
586///     for issue in report.issues {
587///         eprintln!("  [{}] {}", issue.severity, issue.message);
588///     }
589/// }
590/// # Ok(())
591/// # }
592/// ```
593pub fn verify_archive<P: AsRef<Path>>(
594    archive_path: P,
595    config: &SecurityConfig,
596) -> Result<VerificationReport> {
597    crate::inspection::verify_archive(archive_path, config)
598}
599
600/// Rejects creation for ZIP-family extensions that aren't plain `.zip`.
601///
602/// See the call site in `create_archive_with_progress` for the rationale.
603/// Returns `Ok(())` for anything else - `.zip`, tar variants, unknown
604/// extensions (those get caught later by `detect_format`).
605fn reject_zip_family_creation(output: &Path) -> Result<()> {
606    let Some(ext) = output.extension().and_then(|e| e.to_str()) else {
607        return Ok(());
608    };
609    if is_zip_family_alias(ext) {
610        let ext_lower = ext.to_ascii_lowercase();
611        return Err(ArchiveError::InvalidArchive(format!(
612            "creation for .{ext_lower} isn't supported: the format is ZIP-based but \
613             requires extra structure (signing, manifests, ordering) that exarch \
614             doesn't produce. Use .zip, or set CreationConfig::format = Some(\
615             exarch_core::formats::detect::ArchiveType::Zip) to override."
616        )));
617    }
618    Ok(())
619}
620
621/// Determines archive format from output path or config.
622///
623/// Uses extension-only detection; magic-byte detection is intentionally
624/// excluded so that a pre-existing output file with stale bytes cannot
625/// override the caller's intended format.
626fn determine_creation_format(output: &Path, config: &CreationConfig) -> Result<ArchiveType> {
627    // If format explicitly set in config, use it
628    if let Some(format) = config.format {
629        return Ok(format);
630    }
631
632    // Auto-detect from extension only — never from magic bytes.
633    detect_format_from_extension(output)
634}
635
636#[cfg(test)]
637#[allow(clippy::unwrap_used)]
638mod tests {
639    use super::*;
640    use std::path::PathBuf;
641
642    #[test]
643    fn test_extract_archive_nonexistent_file() {
644        let config = SecurityConfig::default();
645        let result = extract_archive(
646            PathBuf::from("nonexistent_test.tar"),
647            PathBuf::from("/tmp/test"),
648            &config,
649        );
650        // Should fail because file doesn't exist
651        assert!(result.is_err());
652    }
653
654    #[test]
655    fn test_determine_creation_format_tar() {
656        let config = CreationConfig::default();
657        let path = PathBuf::from("archive.tar");
658        let format = determine_creation_format(&path, &config).unwrap();
659        assert_eq!(format, ArchiveType::Tar);
660    }
661
662    #[test]
663    fn test_determine_creation_format_tar_gz() {
664        let config = CreationConfig::default();
665        let path = PathBuf::from("archive.tar.gz");
666        let format = determine_creation_format(&path, &config).unwrap();
667        assert_eq!(format, ArchiveType::TarGz);
668
669        let path2 = PathBuf::from("archive.tgz");
670        let format2 = determine_creation_format(&path2, &config).unwrap();
671        assert_eq!(format2, ArchiveType::TarGz);
672    }
673
674    #[test]
675    fn test_determine_creation_format_tar_bz2() {
676        let config = CreationConfig::default();
677        let path = PathBuf::from("archive.tar.bz2");
678        let format = determine_creation_format(&path, &config).unwrap();
679        assert_eq!(format, ArchiveType::TarBz2);
680    }
681
682    #[test]
683    fn test_determine_creation_format_tar_xz() {
684        let config = CreationConfig::default();
685        let path = PathBuf::from("archive.tar.xz");
686        let format = determine_creation_format(&path, &config).unwrap();
687        assert_eq!(format, ArchiveType::TarXz);
688    }
689
690    #[test]
691    fn test_determine_creation_format_tar_zst() {
692        let config = CreationConfig::default();
693        let path = PathBuf::from("archive.tar.zst");
694        let format = determine_creation_format(&path, &config).unwrap();
695        assert_eq!(format, ArchiveType::TarZst);
696    }
697
698    #[test]
699    fn test_determine_creation_format_zip() {
700        let config = CreationConfig::default();
701        let path = PathBuf::from("archive.zip");
702        let format = determine_creation_format(&path, &config).unwrap();
703        assert_eq!(format, ArchiveType::Zip);
704    }
705
706    #[test]
707    fn test_determine_creation_format_explicit() {
708        let config = CreationConfig::default().with_format(Some(ArchiveType::TarGz));
709        let path = PathBuf::from("archive.xyz");
710        let format = determine_creation_format(&path, &config).unwrap();
711        assert_eq!(format, ArchiveType::TarGz);
712    }
713
714    #[test]
715    fn test_determine_creation_format_unknown() {
716        let config = CreationConfig::default();
717        let path = PathBuf::from("archive.rar");
718        let result = determine_creation_format(&path, &config);
719        assert!(result.is_err());
720    }
721
722    #[test]
723    fn test_determine_creation_format_ignores_stale_magic_bytes() {
724        // Regression for C1: a pre-existing output file whose bytes match a
725        // different format must not override the extension-derived format.
726        let dir = tempfile::tempdir().unwrap();
727        let path = dir.path().join("backup.zip");
728        // Write gzip magic bytes into a file named .zip
729        std::fs::write(&path, b"\x1f\x8b\x08\x00\x00\x00\x00\x00").unwrap();
730
731        let config = CreationConfig::default();
732        let format = determine_creation_format(&path, &config).unwrap();
733        assert_eq!(
734            format,
735            ArchiveType::Zip,
736            "creation format must follow extension, not stale on-disk magic bytes"
737        );
738    }
739
740    #[test]
741    fn test_extract_archive_7z_not_implemented() {
742        let dest = tempfile::TempDir::new().unwrap();
743        let path = PathBuf::from("test.7z");
744
745        let result = extract_archive(&path, dest.path(), &SecurityConfig::default());
746
747        assert!(result.is_err());
748    }
749
750    #[test]
751    fn test_create_archive_invalid_compression_level_rejected_before_io() {
752        let dest = tempfile::TempDir::new().unwrap();
753        let archive_path = dest.path().join("output.tar.gz");
754        let config = CreationConfig {
755            compression_level: Some(15),
756            ..CreationConfig::default()
757        };
758        let result = create_archive(&archive_path, &[] as &[&str], &config);
759        assert!(
760            matches!(
761                result,
762                Err(ArchiveError::InvalidCompressionLevel { level: 15 })
763            ),
764            "expected InvalidCompressionLevel, got {result:?}",
765        );
766        // Verify no I/O happened — output file must not exist
767        assert!(!archive_path.exists(), "output file must not be created");
768    }
769
770    #[test]
771    fn test_create_archive_zip_family_not_supported() {
772        // Mirrors test_create_archive_7z_not_supported. Spot-checks a couple
773        // of extensions rather than every one - the integration test
774        // covers the full list.
775        let dest = tempfile::TempDir::new().unwrap();
776        for ext in ["apk", "whl", "EPUB"] {
777            let archive_path = dest.path().join(format!("output.{ext}"));
778            let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
779            assert!(
780                matches!(result, Err(ArchiveError::InvalidArchive(_))),
781                ".{ext} should be rejected, got {result:?}",
782            );
783        }
784    }
785
786    #[test]
787    fn test_create_archive_zip_family_override_bypasses_guard() {
788        // Explicit CreationConfig::format = Some(Zip) is the escape hatch -
789        // skips the ZIP-family guard. Caller takes responsibility for the
790        // resulting file not being spec-valid.
791        let dest = tempfile::TempDir::new().unwrap();
792        let src = dest.path().join("source.txt");
793        std::fs::write(&src, b"hello").unwrap();
794        let archive_path = dest.path().join("output.apk");
795        let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
796        let result = create_archive(&archive_path, &[&src], &config);
797        assert!(
798            result.is_ok(),
799            "explicit format override should bypass the guard, got {result:?}",
800        );
801    }
802
803    #[test]
804    fn test_create_archive_7z_not_supported() {
805        let dest = tempfile::TempDir::new().unwrap();
806        let archive_path = dest.path().join("output.7z");
807
808        let result = create_archive(&archive_path, &[] as &[&str], &CreationConfig::default());
809
810        assert!(result.is_err());
811        assert!(matches!(
812            result.unwrap_err(),
813            ArchiveError::InvalidConfiguration { .. }
814        ));
815    }
816
817    #[test]
818    fn test_extract_archive_with_options_and_progress_non_atomic_delegates_to_normal() {
819        let dest = tempfile::TempDir::new().unwrap();
820        let options = ExtractionOptions {
821            atomic: false,
822            skip_duplicates: true,
823        };
824        let result = extract_archive_with_options_and_progress(
825            PathBuf::from("nonexistent.tar.gz"),
826            dest.path(),
827            &SecurityConfig::default(),
828            &options,
829            &mut NoopProgress,
830        );
831        assert!(result.is_err());
832    }
833
834    #[test]
835    fn test_extract_archive_with_options_delegates() {
836        let dest = tempfile::TempDir::new().unwrap();
837        let options = ExtractionOptions {
838            atomic: false,
839            skip_duplicates: true,
840        };
841        let result = extract_archive_with_options(
842            PathBuf::from("nonexistent.tar.gz"),
843            dest.path(),
844            &SecurityConfig::default(),
845            &options,
846        );
847        assert!(result.is_err());
848    }
849
850    #[test]
851    fn test_extract_atomic_success() {
852        use crate::create_archive;
853        use crate::creation::CreationConfig;
854
855        // Create a valid tar.gz to extract
856        let archive_dir = tempfile::TempDir::new().unwrap();
857        let archive_path = archive_dir.path().join("test.tar.gz");
858
859        // Create a simple archive with one file
860        let src_dir = tempfile::TempDir::new().unwrap();
861        std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
862        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
863
864        let parent = tempfile::TempDir::new().unwrap();
865        let output_dir = parent.path().join("extracted");
866
867        let options = ExtractionOptions {
868            atomic: true,
869            skip_duplicates: true,
870        };
871        let result = extract_archive_with_options(
872            &archive_path,
873            &output_dir,
874            &SecurityConfig::default(),
875            &options,
876        );
877
878        assert!(result.is_ok());
879        assert!(output_dir.exists());
880        // No temp dir remnants
881        let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
882        assert_eq!(
883            temp_entries.len(),
884            1,
885            "Expected only the output dir, found temp remnants"
886        );
887    }
888
889    #[test]
890    fn test_extract_atomic_failure_cleans_up() {
891        let parent = tempfile::TempDir::new().unwrap();
892        let output_dir = parent.path().join("extracted");
893
894        let options = ExtractionOptions {
895            atomic: true,
896            skip_duplicates: true,
897        };
898        let result = extract_archive_with_options(
899            PathBuf::from("nonexistent_archive.tar.gz"),
900            &output_dir,
901            &SecurityConfig::default(),
902            &options,
903        );
904
905        assert!(result.is_err());
906        // Output dir must not exist
907        assert!(!output_dir.exists());
908        // No temp dir remnants in parent
909        let temp_entries: Vec<_> = std::fs::read_dir(parent.path()).unwrap().collect();
910        assert!(
911            temp_entries.is_empty(),
912            "Temp dir not cleaned up after failure"
913        );
914    }
915
916    #[test]
917    fn test_extract_atomic_output_already_exists_fails() {
918        use crate::create_archive;
919        use crate::creation::CreationConfig;
920
921        let parent = tempfile::TempDir::new().unwrap();
922        let output_dir = parent.path().join("extracted");
923        std::fs::create_dir_all(&output_dir).unwrap();
924        // Create a file in output_dir so it's non-empty (rename over non-empty dir
925        // fails on most OSes)
926        std::fs::write(output_dir.join("existing.txt"), b"old content").unwrap();
927
928        let archive_dir = tempfile::TempDir::new().unwrap();
929        let archive_path = archive_dir.path().join("test.tar.gz");
930        let src_dir = tempfile::TempDir::new().unwrap();
931        std::fs::write(src_dir.path().join("new.txt"), b"new content").unwrap();
932        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
933
934        let options = ExtractionOptions {
935            atomic: true,
936            skip_duplicates: true,
937        };
938        let result = extract_archive_with_options(
939            &archive_path,
940            &output_dir,
941            &SecurityConfig::default(),
942            &options,
943        );
944
945        // Should fail with OutputExists or Io (platform dependent rename semantics)
946        assert!(result.is_err());
947        // Output dir must still have old content (not corrupted)
948        assert!(output_dir.join("existing.txt").exists());
949    }
950
951    // Regression test for issue #170: progress callback silently dropped
952    #[test]
953    fn test_progress_callback_invoked_during_extraction() {
954        use crate::ProgressCallback;
955        use std::path::Path;
956
957        struct TrackingProgress {
958            started: usize,
959            completed: usize,
960            finished: bool,
961        }
962
963        impl ProgressCallback for TrackingProgress {
964            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
965                self.started += 1;
966            }
967
968            fn on_bytes_written(&mut self, _bytes: u64) {}
969
970            fn on_entry_complete(&mut self, _path: &Path) {
971                self.completed += 1;
972            }
973
974            fn on_complete(&mut self) {
975                self.finished = true;
976            }
977        }
978
979        let archive_dir = tempfile::TempDir::new().unwrap();
980        let archive_path = archive_dir.path().join("test.tar.gz");
981        let src_dir = tempfile::TempDir::new().unwrap();
982        std::fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
983        std::fs::write(src_dir.path().join("b.txt"), b"world").unwrap();
984        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
985
986        let dest = tempfile::TempDir::new().unwrap();
987        let mut progress = TrackingProgress {
988            started: 0,
989            completed: 0,
990            finished: false,
991        };
992
993        let report = extract_archive_with_progress(
994            &archive_path,
995            dest.path(),
996            &SecurityConfig::default(),
997            &mut progress,
998        )
999        .unwrap();
1000
1001        assert!(report.files_extracted >= 2, "expected at least 2 files");
1002        assert!(progress.started >= 2, "on_entry_start not called");
1003        assert!(progress.completed >= 2, "on_entry_complete not called");
1004        assert!(progress.finished, "on_complete not called");
1005    }
1006
1007    // Regression test for issue #170: ZIP format
1008    #[test]
1009    fn test_progress_callback_invoked_during_zip_extraction() {
1010        use crate::ProgressCallback;
1011        use std::path::Path;
1012
1013        struct TrackingProgress {
1014            started: usize,
1015            completed: usize,
1016            finished: bool,
1017        }
1018
1019        impl ProgressCallback for TrackingProgress {
1020            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1021                self.started += 1;
1022            }
1023
1024            fn on_bytes_written(&mut self, _bytes: u64) {}
1025
1026            fn on_entry_complete(&mut self, _path: &Path) {
1027                self.completed += 1;
1028            }
1029
1030            fn on_complete(&mut self) {
1031                self.finished = true;
1032            }
1033        }
1034
1035        let tmp = tempfile::TempDir::new().unwrap();
1036        let archive_path = tmp.path().join("test.zip");
1037        let src_dir = tempfile::TempDir::new().unwrap();
1038        std::fs::write(src_dir.path().join("x.txt"), b"foo").unwrap();
1039        std::fs::write(src_dir.path().join("y.txt"), b"bar").unwrap();
1040        let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1041        create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1042
1043        let dest = tempfile::TempDir::new().unwrap();
1044        let mut progress = TrackingProgress {
1045            started: 0,
1046            completed: 0,
1047            finished: false,
1048        };
1049        let report = extract_archive_with_progress(
1050            &archive_path,
1051            dest.path(),
1052            &SecurityConfig::default(),
1053            &mut progress,
1054        )
1055        .unwrap();
1056
1057        assert!(report.files_extracted >= 2, "expected at least 2 files");
1058        assert!(progress.started >= 2, "on_entry_start not called for ZIP");
1059        assert!(
1060            progress.completed >= 2,
1061            "on_entry_complete not called for ZIP"
1062        );
1063        assert!(progress.finished, "on_complete not called for ZIP");
1064    }
1065
1066    // Regression test for issue #170: 7z format
1067    #[test]
1068    fn test_progress_callback_invoked_during_sevenz_extraction() {
1069        use crate::ProgressCallback;
1070        use std::path::Path;
1071
1072        struct TrackingProgress {
1073            started: usize,
1074            completed: usize,
1075            finished: bool,
1076        }
1077
1078        impl ProgressCallback for TrackingProgress {
1079            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1080                self.started += 1;
1081            }
1082
1083            fn on_bytes_written(&mut self, _bytes: u64) {}
1084
1085            fn on_entry_complete(&mut self, _path: &Path) {
1086                self.completed += 1;
1087            }
1088
1089            fn on_complete(&mut self) {
1090                self.finished = true;
1091            }
1092        }
1093
1094        let fixture =
1095            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1096
1097        let dest = tempfile::TempDir::new().unwrap();
1098        let mut progress = TrackingProgress {
1099            started: 0,
1100            completed: 0,
1101            finished: false,
1102        };
1103        let report = extract_archive_with_progress(
1104            &fixture,
1105            dest.path(),
1106            &SecurityConfig::default(),
1107            &mut progress,
1108        )
1109        .unwrap();
1110
1111        assert!(
1112            report.files_extracted >= 1,
1113            "expected at least 1 file from simple.7z"
1114        );
1115        assert!(progress.started >= 1, "on_entry_start not called for 7z");
1116        assert!(
1117            progress.completed >= 1,
1118            "on_entry_complete not called for 7z"
1119        );
1120        assert!(progress.finished, "on_complete not called for 7z");
1121    }
1122
1123    // Regression test for issue #304: on_bytes_written must be called with > 0
1124    // bytes when extracting non-empty files from TAR archives.
1125    #[test]
1126    fn test_on_bytes_written_called_for_tar() {
1127        use crate::ProgressCallback;
1128        use std::path::Path;
1129
1130        struct ByteTracker {
1131            total: u64,
1132        }
1133
1134        impl ProgressCallback for ByteTracker {
1135            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1136
1137            fn on_bytes_written(&mut self, bytes: u64) {
1138                self.total += bytes;
1139            }
1140
1141            fn on_entry_complete(&mut self, _path: &Path) {}
1142
1143            fn on_complete(&mut self) {}
1144        }
1145
1146        let archive_dir = tempfile::TempDir::new().unwrap();
1147        let archive_path = archive_dir.path().join("test.tar.gz");
1148        let src_dir = tempfile::TempDir::new().unwrap();
1149        std::fs::write(src_dir.path().join("hello.txt"), b"hello world").unwrap();
1150        create_archive(&archive_path, &[src_dir.path()], &CreationConfig::default()).unwrap();
1151
1152        let dest = tempfile::TempDir::new().unwrap();
1153        let mut progress = ByteTracker { total: 0 };
1154        let report = extract_archive_with_progress(
1155            &archive_path,
1156            dest.path(),
1157            &SecurityConfig::default(),
1158            &mut progress,
1159        )
1160        .unwrap();
1161
1162        assert!(
1163            report.bytes_written > 0,
1164            "report.bytes_written must be > 0, got {}",
1165            report.bytes_written
1166        );
1167        assert!(
1168            progress.total > 0,
1169            "on_bytes_written must be called with > 0 bytes for TAR, got {}",
1170            progress.total
1171        );
1172    }
1173
1174    // Regression test for issue #304: on_bytes_written must be called with > 0
1175    // bytes when extracting non-empty files from ZIP archives.
1176    #[test]
1177    fn test_on_bytes_written_called_for_zip() {
1178        use crate::ProgressCallback;
1179        use std::path::Path;
1180
1181        struct ByteTracker {
1182            total: u64,
1183        }
1184
1185        impl ProgressCallback for ByteTracker {
1186            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1187
1188            fn on_bytes_written(&mut self, bytes: u64) {
1189                self.total += bytes;
1190            }
1191
1192            fn on_entry_complete(&mut self, _path: &Path) {}
1193
1194            fn on_complete(&mut self) {}
1195        }
1196
1197        let tmp = tempfile::TempDir::new().unwrap();
1198        let archive_path = tmp.path().join("test.zip");
1199        let src_dir = tempfile::TempDir::new().unwrap();
1200        std::fs::write(src_dir.path().join("data.txt"), b"hello world").unwrap();
1201        let config = CreationConfig::default().with_format(Some(ArchiveType::Zip));
1202        create_archive(&archive_path, &[src_dir.path()], &config).unwrap();
1203
1204        let dest = tempfile::TempDir::new().unwrap();
1205        let mut progress = ByteTracker { total: 0 };
1206        let report = extract_archive_with_progress(
1207            &archive_path,
1208            dest.path(),
1209            &SecurityConfig::default(),
1210            &mut progress,
1211        )
1212        .unwrap();
1213
1214        assert!(
1215            report.bytes_written > 0,
1216            "report.bytes_written must be > 0, got {}",
1217            report.bytes_written
1218        );
1219        assert!(
1220            progress.total > 0,
1221            "on_bytes_written must be called with > 0 bytes for ZIP, got {}",
1222            progress.total
1223        );
1224    }
1225
1226    // Regression test for issue #304: on_bytes_written must be called with > 0
1227    // bytes when extracting non-empty files from 7z archives.
1228    #[test]
1229    fn test_on_bytes_written_called_for_sevenz() {
1230        use crate::ProgressCallback;
1231        use std::path::Path;
1232
1233        struct ByteTracker {
1234            total: u64,
1235        }
1236
1237        impl ProgressCallback for ByteTracker {
1238            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1239
1240            fn on_bytes_written(&mut self, bytes: u64) {
1241                self.total += bytes;
1242            }
1243
1244            fn on_entry_complete(&mut self, _path: &Path) {}
1245
1246            fn on_complete(&mut self) {}
1247        }
1248
1249        let fixture =
1250            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/simple.7z");
1251
1252        let dest = tempfile::TempDir::new().unwrap();
1253        let mut progress = ByteTracker { total: 0 };
1254        let report = extract_archive_with_progress(
1255            &fixture,
1256            dest.path(),
1257            &SecurityConfig::default(),
1258            &mut progress,
1259        )
1260        .unwrap();
1261
1262        assert!(
1263            report.bytes_written > 0,
1264            "report.bytes_written must be > 0, got {}",
1265            report.bytes_written
1266        );
1267        assert!(
1268            progress.total > 0,
1269            "on_bytes_written must be called with > 0 bytes for 7z, got {}",
1270            progress.total
1271        );
1272    }
1273
1274    // Regression test for BYTES-1: on_bytes_written must be called when TAR
1275    // hardlinks are extracted (copy path in create_hardlink).
1276    #[test]
1277    fn test_tar_hardlink_calls_on_bytes_written() {
1278        use crate::ProgressCallback;
1279        use crate::formats::TarArchive;
1280        use crate::formats::traits::ArchiveFormat;
1281        use std::io::Cursor;
1282        use std::path::Path;
1283
1284        struct ByteTracker {
1285            total: u64,
1286        }
1287
1288        impl ProgressCallback for ByteTracker {
1289            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {}
1290
1291            fn on_bytes_written(&mut self, bytes: u64) {
1292                self.total += bytes;
1293            }
1294
1295            fn on_entry_complete(&mut self, _path: &Path) {}
1296
1297            fn on_complete(&mut self) {}
1298        }
1299
1300        // Build a TAR with one regular file and one hardlink pointing to it.
1301        let content = b"hello hardlink";
1302        let tar_data = {
1303            let mut builder = tar::Builder::new(Vec::new());
1304
1305            let mut header = tar::Header::new_gnu();
1306            header.set_size(content.len() as u64);
1307            header.set_mode(0o644);
1308            header.set_entry_type(tar::EntryType::Regular);
1309            header.set_cksum();
1310            builder
1311                .append_data(&mut header, "original.txt", content.as_ref())
1312                .unwrap();
1313
1314            let mut hdr = tar::Header::new_gnu();
1315            hdr.set_size(0);
1316            hdr.set_mode(0o644);
1317            hdr.set_entry_type(tar::EntryType::Link);
1318            hdr.set_link_name("original.txt").unwrap();
1319            hdr.set_cksum();
1320            builder
1321                .append_data(&mut hdr, "link.txt", std::io::empty())
1322                .unwrap();
1323
1324            builder.into_inner().unwrap()
1325        };
1326
1327        let temp = tempfile::TempDir::new().unwrap();
1328        let mut config = SecurityConfig::default();
1329        config.allowed.hardlinks = true;
1330
1331        let mut archive = TarArchive::new(Cursor::new(tar_data));
1332        let mut progress = ByteTracker { total: 0 };
1333        let report = archive
1334            .extract(
1335                temp.path(),
1336                &config,
1337                &ExtractionOptions::default(),
1338                &mut progress,
1339            )
1340            .unwrap();
1341
1342        // The hardlink copies the file content — bytes should be reported twice.
1343        let expected = (content.len() as u64) * 2;
1344        assert_eq!(
1345            progress.total, expected,
1346            "on_bytes_written must report bytes for both original and hardlink copy, \
1347             got {} (report.bytes_written={})",
1348            progress.total, report.bytes_written
1349        );
1350    }
1351
1352    // Regression test for issue #305: on_entry_complete must be called even
1353    // when TAR extraction fails mid-entry due to a path traversal violation.
1354    #[test]
1355    fn test_tar_on_entry_complete_called_on_path_traversal_error() {
1356        use crate::ProgressCallback;
1357        use crate::formats::TarArchive;
1358        use crate::formats::traits::ArchiveFormat;
1359        use std::io::Cursor;
1360        use std::path::Path;
1361
1362        struct SymmetryTracker {
1363            started: usize,
1364            completed: usize,
1365        }
1366
1367        impl ProgressCallback for SymmetryTracker {
1368            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1369                self.started += 1;
1370            }
1371
1372            fn on_bytes_written(&mut self, _bytes: u64) {}
1373
1374            fn on_entry_complete(&mut self, _path: &Path) {
1375                self.completed += 1;
1376            }
1377
1378            fn on_complete(&mut self) {}
1379        }
1380
1381        // Build a minimal TAR with a path-traversal entry at raw bytes level
1382        // (bypassing the `tar` crate's sanitization).
1383        let tar_data = make_raw_tar_single(b"../../etc/passwd", b"evil");
1384
1385        let temp = tempfile::TempDir::new().unwrap();
1386        let mut archive = TarArchive::new(Cursor::new(tar_data));
1387        let mut progress = SymmetryTracker {
1388            started: 0,
1389            completed: 0,
1390        };
1391        let result = archive.extract(
1392            temp.path(),
1393            &SecurityConfig::default(),
1394            &ExtractionOptions::default(),
1395            &mut progress,
1396        );
1397
1398        assert!(result.is_err(), "traversal entry must be rejected");
1399        assert_eq!(
1400            progress.started, progress.completed,
1401            "on_entry_complete must be called for every on_entry_start, \
1402             even when extraction fails: started={}, completed={}",
1403            progress.started, progress.completed
1404        );
1405    }
1406
1407    // Regression test for issue #305: on_entry_complete must be called even
1408    // when ZIP extraction fails mid-entry due to a path traversal violation.
1409    #[test]
1410    fn test_zip_on_entry_complete_called_on_path_traversal_error() {
1411        use crate::ProgressCallback;
1412        use crate::formats::ZipArchive;
1413        use crate::formats::traits::ArchiveFormat;
1414        use std::io::Cursor;
1415        use std::path::Path;
1416
1417        struct SymmetryTracker {
1418            started: usize,
1419            completed: usize,
1420        }
1421
1422        impl ProgressCallback for SymmetryTracker {
1423            fn on_entry_start(&mut self, _path: &Path, _total: usize, _current: usize) {
1424                self.started += 1;
1425            }
1426
1427            fn on_bytes_written(&mut self, _bytes: u64) {}
1428
1429            fn on_entry_complete(&mut self, _path: &Path) {
1430                self.completed += 1;
1431            }
1432
1433            fn on_complete(&mut self) {}
1434        }
1435
1436        // Build a ZIP with a traversal path using zip::ZipWriter.
1437        let zip_data = make_zip_with_traversal(b"../../etc/passwd", b"evil");
1438
1439        let temp = tempfile::TempDir::new().unwrap();
1440        let mut archive = ZipArchive::new(Cursor::new(zip_data)).unwrap();
1441        let mut progress = SymmetryTracker {
1442            started: 0,
1443            completed: 0,
1444        };
1445        let result = archive.extract(
1446            temp.path(),
1447            &SecurityConfig::default(),
1448            &ExtractionOptions::default(),
1449            &mut progress,
1450        );
1451
1452        assert!(result.is_err(), "traversal entry must be rejected");
1453        assert_eq!(
1454            progress.started, progress.completed,
1455            "on_entry_complete must be called for every on_entry_start in ZIP, \
1456             even when extraction fails: started={}, completed={}",
1457            progress.started, progress.completed
1458        );
1459    }
1460
1461    // Builds a single-entry POSIX ustar TAR with an arbitrary raw path,
1462    // bypassing the `tar` crate's path sanitization.
1463    fn make_raw_tar_single(path: &[u8], data: &[u8]) -> Vec<u8> {
1464        let mut out = Vec::new();
1465        let mut header = [0u8; 512];
1466
1467        let path_len = path.len().min(100);
1468        header[..path_len].copy_from_slice(&path[..path_len]);
1469        header[100..108].copy_from_slice(b"0000644\0");
1470        header[108..116].copy_from_slice(b"0000000\0");
1471        header[116..124].copy_from_slice(b"0000000\0");
1472        let size_str = format!("{:011o}\0", data.len());
1473        header[124..136].copy_from_slice(size_str.as_bytes());
1474        header[136..148].copy_from_slice(b"00000000000\0");
1475        header[156] = b'0';
1476        header[257..263].copy_from_slice(b"ustar ");
1477        header[263..265].copy_from_slice(b" \0");
1478        header[148..156].copy_from_slice(b"        ");
1479        let checksum: u32 = header.iter().map(|&b| u32::from(b)).sum();
1480        let ck_str = format!("{checksum:06o}\0 ");
1481        header[148..156].copy_from_slice(ck_str.as_bytes());
1482
1483        out.extend_from_slice(&header);
1484        out.extend_from_slice(data);
1485        let rem = data.len() % 512;
1486        if rem != 0 {
1487            out.extend(std::iter::repeat_n(0u8, 512 - rem));
1488        }
1489        out.extend(std::iter::repeat_n(0u8, 1024));
1490        out
1491    }
1492
1493    // Builds a single-entry ZIP with a raw traversal path by writing the
1494    // local file header and central directory manually.
1495    #[allow(clippy::cast_possible_truncation)]
1496    fn make_zip_with_traversal(path: &[u8], data: &[u8]) -> Vec<u8> {
1497        let mut buf: Vec<u8> = Vec::new();
1498
1499        let crc = crc32_ieee(data);
1500        let name_len = path.len() as u16;
1501        let content_len = data.len() as u32;
1502
1503        let local_offset: u32 = 0;
1504
1505        // Local file header
1506        buf.extend_from_slice(b"PK\x03\x04");
1507        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
1508        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
1509        buf.extend_from_slice(&0u16.to_le_bytes()); // compression: Stored
1510        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
1511        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
1512        buf.extend_from_slice(&crc.to_le_bytes());
1513        buf.extend_from_slice(&content_len.to_le_bytes());
1514        buf.extend_from_slice(&content_len.to_le_bytes());
1515        buf.extend_from_slice(&name_len.to_le_bytes());
1516        buf.extend_from_slice(&0u16.to_le_bytes()); // extra field length
1517        buf.extend_from_slice(path);
1518        buf.extend_from_slice(data);
1519
1520        let central_dir_offset = buf.len() as u32;
1521
1522        // Central directory file header
1523        buf.extend_from_slice(b"PK\x01\x02");
1524        buf.extend_from_slice(&0x031eu16.to_le_bytes()); // version made by: Unix
1525        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
1526        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
1527        buf.extend_from_slice(&0u16.to_le_bytes()); // compression
1528        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
1529        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
1530        buf.extend_from_slice(&crc.to_le_bytes());
1531        buf.extend_from_slice(&content_len.to_le_bytes());
1532        buf.extend_from_slice(&content_len.to_le_bytes());
1533        buf.extend_from_slice(&name_len.to_le_bytes());
1534        buf.extend_from_slice(&0u16.to_le_bytes()); // extra field len
1535        buf.extend_from_slice(&0u16.to_le_bytes()); // file comment len
1536        buf.extend_from_slice(&0u16.to_le_bytes()); // disk number start
1537        buf.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
1538        buf.extend_from_slice(&(0o100_644u32 << 16).to_le_bytes()); // external attrs
1539        buf.extend_from_slice(&local_offset.to_le_bytes());
1540        buf.extend_from_slice(path);
1541
1542        let central_dir_size = (buf.len() as u32) - central_dir_offset;
1543
1544        // End of central directory
1545        buf.extend_from_slice(b"PK\x05\x06");
1546        buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
1547        buf.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir
1548        buf.extend_from_slice(&1u16.to_le_bytes()); // entries on this disk
1549        buf.extend_from_slice(&1u16.to_le_bytes()); // total entries
1550        buf.extend_from_slice(&central_dir_size.to_le_bytes());
1551        buf.extend_from_slice(&central_dir_offset.to_le_bytes());
1552        buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
1553        buf
1554    }
1555
1556    // CRC-32 (IEEE 802.3) used to produce valid ZIP checksums in helpers above.
1557    fn crc32_ieee(data: &[u8]) -> u32 {
1558        let mut crc: u32 = 0xFFFF_FFFF;
1559        for &byte in data {
1560            let mut val = crc ^ u32::from(byte);
1561            for _ in 0..8 {
1562                let mask = (val & 1).wrapping_neg();
1563                val = (val >> 1) ^ (0xEDB8_8320 & mask);
1564            }
1565            crc = val;
1566        }
1567        !crc
1568    }
1569}