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