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