Skip to main content

rz_archive/
zip.rs

1use std::collections::BTreeMap;
2use std::io;
3
4use camino::{Utf8Path, Utf8PathBuf};
5use rayon::iter::{IntoParallelIterator, ParallelIterator};
6use zip::write::SimpleFileOptions;
7use zip::{AesMode, CompressionMethod, ZipArchive, ZipWriter};
8
9use crate::error::{Error, Result};
10use crate::filter;
11use crate::{ArchiveInfo, CompressOpts, DecompressOpts, Entry};
12
13// ── Compress ──────────────────────────────────────────────────────────────────
14
15/// Apply source-file metadata from `meta` to a zip `FileOptions`: the Unix
16/// permission bits (notably the executable bit) on Unix, and the modification
17/// time everywhere — the crate's default stamps the moment of compression,
18/// which discards the source mtime entirely.  Timestamps outside the DOS
19/// datetime range (pre-1980) keep the crate default.
20///
21/// Not used for symlink entries: the `zip` crate sets their mode (`S_IFLNK`)
22/// itself, and overriding it would break symlink round-tripping.
23pub(crate) fn with_unix_mode<'k>(
24    options: zip::write::FileOptions<'k, ()>,
25    meta: &std::fs::Metadata,
26) -> zip::write::FileOptions<'k, ()> {
27    let options = match zip_datetime_from_meta(meta) {
28        Some(dt) => options.last_modified_time(dt),
29        None => options,
30    };
31    #[cfg(unix)]
32    {
33        use std::os::unix::fs::PermissionsExt;
34        options.unix_permissions(meta.permissions().mode())
35    }
36    #[cfg(not(unix))]
37    {
38        options
39    }
40}
41
42/// Convert a filesystem mtime into a zip `DateTime` (UTC components, the
43/// inverse of [`zip_datetime_to_epoch`]).  `None` only when the metadata
44/// carries no usable time at all.
45///
46/// Timestamps outside the representable 1980–2107 DOS range clamp to the
47/// nearest bound (Info-ZIP behaviour).  Returning `None` there would fall
48/// back to the crate default — which stamps the wall clock at write time, so
49/// a pre-1980 source mtime came out as "now", off by decades and different
50/// on every run.
51fn zip_datetime_from_meta(meta: &std::fs::Metadata) -> Option<zip::DateTime> {
52    // A pre-epoch mtime makes duration_since fail; clamp it like any other
53    // pre-1980 value instead of losing it.
54    let secs = match meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH) {
55        Ok(d) => i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
56        Err(_) => 0,
57    };
58    let dt = time::OffsetDateTime::from_unix_timestamp(secs)
59        .ok()
60        .and_then(|odt| {
61            zip::DateTime::from_date_and_time(
62                odt.year().try_into().ok()?,
63                odt.month() as u8,
64                odt.day(),
65                odt.hour(),
66                odt.minute(),
67                odt.second(),
68            )
69            .ok()
70        });
71    dt.or_else(|| {
72        // 1980-01-01T00:00:00Z, the DOS floor (`DateTime::default()`).
73        if secs < 315_532_800 {
74            Some(zip::DateTime::default())
75        } else {
76            zip::DateTime::from_date_and_time(2107, 12, 31, 23, 59, 58).ok()
77        }
78    })
79}
80
81/// Translate a requested compression level into the method/level pair the
82/// `zip` crate accepts.
83///
84/// Level 0 — what `--store` maps to — means "no compression", but the crate's
85/// deflate range starts at 1 and its `Stored` method rejects *any* explicit
86/// level, so the method and the level have to be chosen together.
87pub(crate) fn compression_settings(level: Option<u32>) -> (CompressionMethod, Option<i64>) {
88    match level {
89        Some(0) => (CompressionMethod::Stored, None),
90        other => (CompressionMethod::Deflated, other.map(i64::from)),
91    }
92}
93
94pub fn compress(inputs: &[Utf8PathBuf], output: &Utf8Path, opts: &CompressOpts<'_>) -> Result<()> {
95    let inputs = filter::validate_inputs(inputs, opts)?;
96
97    let file = fs_err::File::create(output)?;
98    let result = write_archive(file, &inputs, opts);
99    if result.is_err() {
100        // `ZipWriter`'s `Drop` finalises whatever was written, so bailing out
101        // mid-run would otherwise leave a valid-but-empty archive on disk.
102        let _ = fs_err::remove_file(output);
103    }
104    result
105}
106
107fn write_archive(
108    file: fs_err::File,
109    inputs: &[Utf8PathBuf],
110    opts: &CompressOpts<'_>,
111) -> Result<()> {
112    let mut zip = ZipWriter::new(std::io::BufWriter::new(file));
113
114    let (method, level) = compression_settings(opts.level);
115    let base_options = SimpleFileOptions::default()
116        .compression_method(method)
117        .compression_level(level);
118
119    // When encryption is active the lifetime of `FileOptions` is tied to the
120    // password string, so we must hold the two branches separately.
121    // When encryption is active the lifetime of `FileOptions` is tied to the
122    // password string.  Both `FileOptions<'static, ()>` (no password) and
123    // `FileOptions<'_, ()>` (with password) satisfy the same trait bounds, so
124    // we dispatch through the same helpers — just from two branches to keep the
125    // borrow checker happy about the lifetime of `options`.
126    if let Some(ref pwd) = opts.password {
127        let options = base_options.with_aes_encryption(AesMode::Aes256, pwd.as_str());
128        for input in inputs {
129            let meta = filter::input_metadata(input, opts.follow_symlinks)?;
130            let name = filter::input_base_name(input)?;
131            if !opts.follow_symlinks && meta.file_type().is_symlink() {
132                write_symlink_entry(&mut zip, input, &name, options, opts)?;
133            } else if meta.is_dir() {
134                if opts.no_recursion {
135                    zip.add_directory(format!("{name}/"), with_unix_mode(options, &meta))?;
136                } else {
137                    add_dir_walked(&mut zip, input, &name, options, opts)?;
138                }
139            } else if !filter::skip_unarchivable_special(&meta, &name) {
140                zip.start_file(&name, with_unix_mode(options, &meta))?;
141                let mut f = fs_err::File::open(input)?;
142                let size = io::copy(&mut f, &mut zip)?;
143                opts.progress.set_entry(&name);
144                opts.progress.inc(size);
145            }
146        }
147    } else {
148        for input in inputs {
149            let meta = filter::input_metadata(input, opts.follow_symlinks)?;
150            let name = filter::input_base_name(input)?;
151            if !opts.follow_symlinks && meta.file_type().is_symlink() {
152                write_symlink_entry(&mut zip, input, &name, base_options, opts)?;
153            } else if meta.is_dir() {
154                if opts.no_recursion {
155                    zip.add_directory(format!("{name}/"), with_unix_mode(base_options, &meta))?;
156                } else {
157                    add_dir_walked(&mut zip, input, &name, base_options, opts)?;
158                }
159            } else if !filter::skip_unarchivable_special(&meta, &name) {
160                zip.start_file(&name, with_unix_mode(base_options, &meta))?;
161                let mut f = fs_err::File::open(input)?;
162                let size = io::copy(&mut f, &mut zip)?;
163                opts.progress.set_entry(&name);
164                opts.progress.inc(size);
165            }
166        }
167    }
168
169    let file = zip.finish()?.into_inner().map_err(|e| e.into_error())?;
170    file.sync_all()?;
171    Ok(())
172}
173
174/// Walk a directory using [`filter::walk_dir`] and add entries to a zip archive.
175/// Handles symlinks, regular files, and subdirectories.
176fn add_dir_walked<'k>(
177    zip: &mut ZipWriter<std::io::BufWriter<fs_err::File>>,
178    dir: &Utf8Path,
179    prefix: &str,
180    options: zip::write::FileOptions<'k, ()>,
181    opts: &CompressOpts<'_>,
182) -> Result<()> {
183    filter::walk_dir(dir, prefix, opts, &mut |entry| {
184        let link_meta = fs_err::symlink_metadata(&entry.fs_path)?;
185        let is_symlink = !opts.follow_symlinks && link_meta.file_type().is_symlink();
186
187        if is_symlink {
188            write_symlink_entry(zip, &entry.fs_path, &entry.archive_name, options, opts)?;
189        } else {
190            // Store the mode of the object actually being archived — the target
191            // when following a symlink, the entry itself otherwise.
192            let meta = if opts.follow_symlinks && link_meta.file_type().is_symlink() {
193                filter::input_metadata(&entry.fs_path, true)?
194            } else {
195                link_meta
196            };
197            if !entry.is_dir && filter::skip_unarchivable_special(&meta, &entry.archive_name) {
198                return Ok(());
199            }
200            let entry_options = with_unix_mode(options, &meta);
201            if entry.is_dir {
202                zip.add_directory(format!("{}/", entry.archive_name), entry_options)?;
203            } else {
204                zip.start_file(&entry.archive_name, entry_options)?;
205                let mut f = fs_err::File::open(&entry.fs_path)?;
206                let size = io::copy(&mut f, zip)?;
207                opts.progress.set_entry(&entry.archive_name);
208                opts.progress.inc(size);
209            }
210        }
211        Ok(())
212    })
213}
214
215/// Store a symlink as a symlink entry (POSIX-style, with `S_IFLNK` mode and
216/// the link target as the entry content). The `zip` crate sets `0o777`
217/// permissions by default; Windows unzip tools may materialise this as a
218/// regular text file containing the target path.
219///
220/// Generic over the writer so the modify-path rewrite (`ZipWriter<File>`)
221/// shares it with compress (`ZipWriter<BufWriter<File>>`) — appending a
222/// symlink used to go through `File::open`, silently dereferencing it into a
223/// regular file.
224pub(crate) fn write_symlink_entry<'k, W: io::Write + io::Seek>(
225    zip: &mut ZipWriter<W>,
226    link_path: &Utf8Path,
227    archive_name: &str,
228    options: zip::write::FileOptions<'k, ()>,
229    opts: &CompressOpts<'_>,
230) -> Result<()> {
231    let target = fs_err::read_link(link_path)?;
232    let target_str = target
233        .to_str()
234        .ok_or_else(|| Error::InvalidUtf8Path(target.display().to_string()))?;
235    // Stamp the link's own mtime; the default would record the moment of
236    // compression.  The mode is left to the crate (S_IFLNK | 0o777) — see the
237    // doc comment.
238    let options = match fs_err::symlink_metadata(link_path)
239        .ok()
240        .as_ref()
241        .and_then(zip_datetime_from_meta)
242    {
243        Some(dt) => options.last_modified_time(dt),
244        None => options,
245    };
246    zip.add_symlink_from_path(archive_name, target_str, options)?;
247    opts.progress.set_entry(archive_name);
248    opts.progress.inc(target_str.len() as u64);
249    Ok(())
250}
251
252/// Upper bound on a symlink target this crate is willing to read.
253///
254/// Linux caps symlink targets at PATH_MAX (4096) and every other platform is
255/// stricter, so double that is generous for anything legitimate while keeping
256/// a hostile entry from becoming an unbounded read.
257pub(crate) const MAX_SYMLINK_TARGET: u64 = 8 * 1024;
258
259/// Extract a zip symlink entry to `out_path`.
260///
261/// On Unix, creates a real symlink via `std::os::unix::fs::symlink`.  On other
262/// platforms, falls back to writing the link target as a plain text file —
263/// mirroring what typical Windows unzip tools do when they encounter a POSIX
264/// symlink entry they can't materialise.
265///
266/// Rejects absolute targets and any `..` component via
267/// [`filter::safe_link_target`] — the same guard the tar path uses — so a later
268/// entry cannot be written *through* a freshly-created symlink into territory
269/// outside the output root (the classic symlink-based zip-slip).
270///
271/// Any path still present at `out_path` is removed first, because
272/// `symlink(2)` fails if the target already exists.  Presence is re-checked
273/// here rather than trusted from the caller's earlier stat: the `--backup`
274/// branch renames the original away in between, and acting on the stale
275/// answer used to abort the extraction with NotFound.
276fn extract_symlink_entry(
277    entry: &mut zip::read::ZipFile<'_, fs_err::File>,
278    out_path: &Utf8Path,
279    dest_path: &Utf8Path,
280) -> Result<u64> {
281    // Never size this from `entry.size()`.  That is the central directory's
282    // uncompressed_size, which the crate returns verbatim and never validates
283    // against the actual content, so a ZIP64 entry can declare u64::MAX with
284    // six bytes of payload behind it.  A failed `Vec::with_capacity` calls
285    // `handle_alloc_error`, which aborts rather than unwinding — no amount of
286    // panic-free discipline in this crate can catch that.
287    let mut target_bytes = Vec::new();
288    let read = io::copy(
289        &mut io::Read::take(entry, MAX_SYMLINK_TARGET),
290        &mut target_bytes,
291    )?;
292    if read >= MAX_SYMLINK_TARGET {
293        return Err(Error::SymlinkTargetTooLong {
294            path: dest_path.to_owned(),
295            max: MAX_SYMLINK_TARGET,
296        });
297    }
298
299    let target = std::str::from_utf8(&target_bytes)
300        .map_err(|_| Error::InvalidUtf8Path(dest_path.to_string()))?;
301
302    filter::safe_link_target(dest_path.as_str(), target)?;
303
304    if fs_err::symlink_metadata(out_path).is_ok() {
305        fs_err::remove_file(out_path)?;
306    }
307
308    #[cfg(unix)]
309    {
310        std::os::unix::fs::symlink(target, out_path)?;
311    }
312    #[cfg(not(unix))]
313    {
314        use std::io::Write;
315        let mut f = fs_err::File::create(out_path)?;
316        f.write_all(target_bytes.as_slice())?;
317    }
318    Ok(target_bytes.len() as u64)
319}
320
321// ── Decompress ────────────────────────────────────────────────────────────────
322
323pub fn decompress(input: &Utf8Path, output: &Utf8Path, opts: &DecompressOpts<'_>) -> Result<()> {
324    let (groups, dir_modes, shared_metadata) = {
325        let file = fs_err::File::open(input)?;
326        let mut archive = ZipArchive::new(file)?;
327        let metadata = archive.metadata();
328        let (groups, dir_modes) = plan_destinations(&mut archive, opts)?;
329        (groups, dir_modes, metadata)
330    };
331
332    // A *file* destined for `x` alongside any entry destined for `x/…`
333    // cannot both succeed — either the parent create_dir_all or the file
334    // create must fail.  The parallel plan puts the two paths in different
335    // groups, so they race dir-vs-file creation and fail (or half-succeed)
336    // nondeterministically.  Fall back to strict archive order so the
337    // outcome — including which entry errors — matches a serial run every
338    // time.
339    if has_ancestor_conflict(&groups) {
340        let file = fs_err::File::open(input)?;
341        let mut archive = ZipArchive::new(file)?;
342        let mut jobs: Vec<(usize, Utf8PathBuf)> = groups
343            .into_iter()
344            .flat_map(|group| {
345                let dest = group.dest;
346                group.indices.into_iter().map(move |i| (i, dest.clone()))
347            })
348            .collect();
349        jobs.sort_by_key(|(i, _)| *i);
350        for (index, dest) in jobs {
351            extract_entry(
352                &mut archive,
353                index,
354                &dest,
355                output,
356                opts,
357                opts.password.as_deref(),
358            )?;
359        }
360        return restore_dir_modes(dir_modes, output, opts);
361    }
362
363    let password = opts.password.clone();
364    groups.into_par_iter().try_for_each_init(
365        || -> Option<ZipArchive<fs_err::File>> {
366            let file = fs_err::File::open(input).ok()?;
367            // SAFETY: metadata was parsed from the same file.
368            Some(unsafe { ZipArchive::unsafe_new_with_metadata(file, shared_metadata.clone()) })
369        },
370        |maybe_archive, group| -> Result<()> {
371            let archive = maybe_archive
372                .as_mut()
373                .ok_or_else(|| Error::Io(io::Error::other("failed to open zip archive")))?;
374            for index in group.indices {
375                extract_entry(
376                    archive,
377                    index,
378                    &group.dest,
379                    output,
380                    opts,
381                    password.as_deref(),
382                )?;
383            }
384            Ok(())
385        },
386    )?;
387    restore_dir_modes(dir_modes, output, opts)
388}
389
390/// Apply recorded directory modes after extraction, children first, under
391/// `-P` — the deferral the tar and 7z paths already do.  Directory entries
392/// used to return from `extract_entry` before any permission handling, so a
393/// 0700 directory in a zip came out at the umask default.
394fn restore_dir_modes(
395    dir_modes: Vec<(Utf8PathBuf, u32)>,
396    output: &Utf8Path,
397    opts: &DecompressOpts<'_>,
398) -> Result<()> {
399    if !opts.preserve_permissions {
400        return Ok(());
401    }
402    #[cfg(unix)]
403    {
404        use std::os::unix::fs::PermissionsExt;
405        let mut dirs = dir_modes;
406        dirs.sort_by(|a, b| b.0.as_str().cmp(&a.0.as_str()));
407        for (dest, mode) in dirs {
408            let path = output.join(dest);
409            // Skip anything that is no longer a real directory — chmod
410            // follows symlinks.
411            if !fs_err::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_dir()) {
412                continue;
413            }
414            fs_err::set_permissions(&path, std::fs::Permissions::from_mode(mode & 0o7777))?;
415        }
416    }
417    #[cfg(not(unix))]
418    {
419        let _ = (dir_modes, output);
420    }
421    Ok(())
422}
423
424/// One planned destination: the archive indices writing to it, and whether
425/// any of them are non-directory / directory entries (both flags feed the
426/// ancestor-conflict check).
427struct DestGroup {
428    dest: Utf8PathBuf,
429    indices: Vec<usize>,
430    has_file: bool,
431    has_dir: bool,
432}
433
434/// Detect a destination that is a strict path ancestor of another destination
435/// while receiving at least one non-directory entry.
436///
437/// Groups arrive sorted by `Utf8PathBuf`'s component-wise order, in which
438/// every descendant of a path sorts directly after it (no sibling can sit
439/// between `x` and `x/y`), so checking consecutive pairs finds an ancestor
440/// pair iff one exists.  Directory-only ancestors (`d/` before `d/f`) are the
441/// normal shape of every archive and stay on the parallel path.
442///
443/// A directory entry and a file entry colliding on one destination (`a/` and
444/// `a` — the same path after trailing-slash canonicalization) are the
445/// degenerate ancestor pair at zero distance: they share a group, so the
446/// pair never shows up in a window and needs its own check.
447fn has_ancestor_conflict(groups: &[DestGroup]) -> bool {
448    if groups.iter().any(|g| g.has_file && g.has_dir) {
449        return true;
450    }
451    groups.windows(2).any(|w| match w {
452        [ancestor, descendant] => ancestor.has_file && descendant.dest.starts_with(&ancestor.dest),
453        _ => false,
454    })
455}
456
457/// Resolve every entry's destination up front and bucket the archive indices by
458/// it, keeping each bucket in ascending index order.
459///
460/// Flattening (`--no-directory`), `--strip-components` and rename rules all
461/// routinely collapse several entries onto one destination.  Handing a whole
462/// bucket to a single worker is what keeps a destination owned by exactly one
463/// thread, so the overwrite decision and the write that follows it stay
464/// indivisible and the surviving file is the archive's last entry for that
465/// path, exactly as in a serial run.
466fn plan_destinations(
467    archive: &mut ZipArchive<fs_err::File>,
468    opts: &DecompressOpts<'_>,
469) -> Result<(Vec<DestGroup>, Vec<(Utf8PathBuf, u32)>)> {
470    let mut groups: BTreeMap<Utf8PathBuf, (Vec<usize>, bool, bool)> = BTreeMap::new();
471    // Directory modes for the post-extraction restore pass — the mode word
472    // comes from the central directory, so collecting it here is free.
473    let mut dir_modes: Vec<(Utf8PathBuf, u32)> = Vec::new();
474    for index in 0..archive.len() {
475        // Raw access: names and directory-ness come from the central directory,
476        // so planning costs no decompression and needs no password.
477        let entry = archive.by_index_raw(index)?;
478        let name = Utf8PathBuf::from(entry.name());
479        let is_dir = entry.is_dir();
480        let unix_mode = entry.unix_mode();
481        drop(entry);
482
483        if let Some(dest) = resolve_destination(&name, is_dir, opts)? {
484            if is_dir && let Some(mode) = unix_mode {
485                dir_modes.push((dest.clone(), mode));
486            }
487            let group = groups.entry(dest).or_default();
488            group.0.push(index);
489            group.1 |= !is_dir;
490            group.2 |= is_dir;
491        }
492    }
493    let groups = groups
494        .into_iter()
495        .map(|(dest, (indices, has_file, has_dir))| DestGroup {
496            dest,
497            indices,
498            has_file,
499            has_dir,
500        })
501        .collect();
502    Ok((groups, dir_modes))
503}
504
505/// Run one entry name through the filter and rewrite chain, yielding its
506/// destination relative to the output root, or `None` when the entry is
507/// filtered out.
508fn resolve_destination(
509    name: &Utf8Path,
510    is_dir: bool,
511    opts: &DecompressOpts<'_>,
512) -> Result<Option<Utf8PathBuf>> {
513    // Reject entries that attempt path traversal.
514    filter::safe_entry_path(name.as_str())?;
515
516    if !filter::should_extract(name.as_str(), &opts.includes, &opts.excludes) {
517        return Ok(None);
518    }
519
520    if opts.no_directory && is_dir {
521        return Ok(None);
522    }
523
524    let stripped = match filter::strip_components(name, opts.strip_components) {
525        Some(p) => p,
526        None => return Ok(None),
527    };
528
529    let dest_path = if opts.no_directory {
530        match stripped.file_name() {
531            Some(name) => Utf8PathBuf::from(name),
532            None => return Ok(None),
533        }
534    } else {
535        stripped
536    };
537
538    // Apply rename rules and optional prefix.
539    match filter::apply_path_rewrites(dest_path, &opts.renames, opts.prefix.as_deref())? {
540        p if p.as_str().is_empty() => Ok(None),
541        p => Ok(canonicalize_dest(&p)),
542    }
543}
544
545/// Rebuild a destination path from its `Normal` components only, dropping any
546/// `CurDir` (`.`) components in the process.
547///
548/// `plan_destinations` uses the return value both as the write path and as
549/// the `BTreeMap` grouping key, so this must be the canonical form: without
550/// it, `./f.bin` and `f.bin` compare as different keys — even though
551/// `output.join()` sends both to the same file — and the two groups can be
552/// handed to different rayon workers, which then race to write the same
553/// path.  `safe_entry_path` has already rejected `..` and absolute paths, so
554/// only `CurDir` and `Normal` components can remain here. Rebuilding through
555/// `push` (rather than keeping the original string) also drops any trailing
556/// slash a directory entry's name carried, so `d/` and `d` collapse to the
557/// identical path string rather than one of them still ending in `/` and
558/// tripping `File::create` with "Is a directory".
559fn canonicalize_dest(path: &Utf8Path) -> Option<Utf8PathBuf> {
560    let mut out = Utf8PathBuf::new();
561    for component in path.components() {
562        if let camino::Utf8Component::Normal(part) = component {
563            out.push(part);
564        }
565    }
566    if out.as_str().is_empty() {
567        None
568    } else {
569        Some(out)
570    }
571}
572
573/// Extract a single entry to the destination [`plan_destinations`] resolved for
574/// it, applying the overwrite policy.
575fn extract_entry(
576    archive: &mut ZipArchive<fs_err::File>,
577    index: usize,
578    dest_path: &Utf8Path,
579    output: &Utf8Path,
580    opts: &DecompressOpts<'_>,
581    password: Option<&str>,
582) -> Result<()> {
583    let mut entry = open_zip_entry(archive, index, password)?;
584    let out_path = output.join(dest_path);
585
586    if entry.is_dir() {
587        fs_err::create_dir_all(&out_path)?;
588        return Ok(());
589    }
590
591    if let Some(parent) = out_path.parent() {
592        fs_err::create_dir_all(parent)?;
593    }
594    let existed = fs_err::symlink_metadata(&out_path).is_ok();
595    if existed {
596        if let Some(ref suffix) = opts.backup_suffix {
597            let backup = Utf8PathBuf::from(format!("{out_path}{suffix}"));
598            fs_err::rename(&out_path, &backup)?;
599        } else if opts.keep_newer {
600            let entry_mtime = entry
601                .last_modified()
602                .map(zip_datetime_to_epoch)
603                .unwrap_or(0);
604            if filter::is_existing_newer(&out_path, entry_mtime)? {
605                return Ok(());
606            }
607        } else if opts.no_overwrite {
608            return Ok(());
609        } else if !opts.force {
610            return Err(Error::FileExists(out_path));
611        }
612    }
613
614    if entry.is_symlink() {
615        let written = extract_symlink_entry(&mut entry, &out_path, dest_path)?;
616        opts.progress.set_entry(dest_path.as_str());
617        opts.progress.inc(written);
618    } else {
619        let unix_mode = entry.unix_mode();
620        // If overwriting an existing symlink, remove it first so the new file
621        // replaces the link rather than the link's target.  Re-stat instead
622        // of reusing `existed`: the backup branch renames the original away.
623        if fs_err::symlink_metadata(&out_path)
624            .is_ok_and(|m| m.file_type().is_symlink())
625        {
626            fs_err::remove_file(&out_path)?;
627        }
628        let mut out_file = fs_err::File::create(&out_path)?;
629        let written = io::copy(&mut entry, &mut out_file)?;
630        #[cfg(unix)]
631        if opts.preserve_permissions
632            && let Some(mode) = unix_mode
633        {
634            use std::os::unix::fs::PermissionsExt;
635            fs_err::set_permissions(&out_path, std::fs::Permissions::from_mode(mode & 0o7777))?;
636        }
637        opts.progress.set_entry(dest_path.as_str());
638        opts.progress.inc(written);
639    }
640    Ok(())
641}
642
643// ── Decompress to writer ─────────────────────────────────────────────────────
644
645pub fn decompress_to_writer<W: std::io::Write>(
646    input: &Utf8Path,
647    writer: &mut W,
648    opts: &DecompressOpts<'_>,
649) -> Result<()> {
650    let file = fs_err::File::open(input)?;
651    let mut archive = ZipArchive::new(file)?;
652
653    for i in 0..archive.len() {
654        let mut entry = open_zip_entry(&mut archive, i, opts.password.as_deref())?;
655        let name = Utf8PathBuf::from(entry.name());
656
657        // Reject entries that attempt path traversal.
658        filter::safe_entry_path(name.as_str())?;
659
660        if !filter::should_extract(name.as_str(), &opts.includes, &opts.excludes) {
661            continue;
662        }
663
664        let stripped = match filter::strip_components(&name, opts.strip_components) {
665            Some(p) => p,
666            None => continue,
667        };
668
669        if entry.is_dir() {
670            continue;
671        }
672
673        // Apply rename rules and optional prefix.
674        let display_path =
675            match filter::apply_path_rewrites(stripped, &opts.renames, opts.prefix.as_deref())? {
676                p if p.as_str().is_empty() => continue,
677                p => p,
678            };
679
680        opts.progress.set_entry(display_path.as_str());
681        io::copy(&mut entry, writer)?;
682    }
683    Ok(())
684}
685
686// ── Test ──────────────────────────────────────────────────────────────────────
687
688pub fn test(
689    input: &Utf8Path,
690    password: Option<&str>,
691    progress: &dyn crate::progress::ProgressReport,
692) -> Result<()> {
693    let (len, shared_metadata) = {
694        let file = fs_err::File::open(input)?;
695        let archive = ZipArchive::new(file)?;
696        (archive.len(), archive.metadata())
697    };
698
699    let password = password.map(str::to_owned);
700    (0..len).into_par_iter().try_for_each_init(
701        || -> Option<ZipArchive<fs_err::File>> {
702            let file = fs_err::File::open(input).ok()?;
703            // SAFETY: metadata was parsed from the same file.
704            Some(unsafe { ZipArchive::unsafe_new_with_metadata(file, shared_metadata.clone()) })
705        },
706        |maybe_archive, i| -> Result<()> {
707            let archive = maybe_archive
708                .as_mut()
709                .ok_or_else(|| Error::Io(io::Error::other("failed to open zip archive")))?;
710            let mut entry = open_zip_entry(archive, i, password.as_deref())?;
711            let name = entry.name().to_owned();
712            progress.set_entry(&name);
713            let written = io::copy(&mut entry, &mut io::sink())?;
714            progress.inc(written);
715            Ok(())
716        },
717    )?;
718    Ok(())
719}
720
721// ── List ──────────────────────────────────────────────────────────────────────
722
723pub fn list(input: &Utf8Path) -> Result<Vec<Entry>> {
724    let file = fs_err::File::open(input)?;
725    let mut archive = ZipArchive::new(file)?;
726    let mut entries = Vec::with_capacity(archive.len());
727    for i in 0..archive.len() {
728        let entry = archive.by_index_raw(i)?;
729        let read_target = entry.is_symlink() && !entry.encrypted();
730        let mut listed = Entry {
731            path: Utf8PathBuf::from(entry.name()),
732            size: entry.size(),
733            mtime: entry
734                .last_modified()
735                .map(zip_datetime_to_epoch)
736                .unwrap_or(0),
737            mode: entry.unix_mode().unwrap_or(0),
738            is_dir: entry.is_dir(),
739            link_target: None,
740        };
741        drop(entry);
742        // Symlink targets are the entry content; they're tiny, and dry-run
743        // needs them for the same traversal check extraction applies.
744        if read_target {
745            use std::io::Read as _;
746            let mut target = Vec::new();
747            archive
748                .by_index(i)?
749                .take(MAX_SYMLINK_TARGET)
750                .read_to_end(&mut target)?;
751            listed.link_target = Some(String::from_utf8_lossy(&target).into_owned());
752        }
753        entries.push(listed);
754    }
755    Ok(entries)
756}
757
758// ── Info ──────────────────────────────────────────────────────────────────────
759
760pub fn info(input: &Utf8Path) -> Result<ArchiveInfo> {
761    let compressed_size = fs_err::metadata(input)?.len();
762
763    let file = fs_err::File::open(input)?;
764    let mut archive = ZipArchive::new(file)?;
765    let entry_count = archive.len();
766
767    // Fast path: decompressed_size() reads from the already-parsed central
768    // directory with zero per-entry I/O.  Falls back to by_index_raw() only
769    // when the archive uses data descriptors (uncommon).
770    let total_uncompressed = match archive.decompressed_size() {
771        Some(size) => u64::try_from(size).unwrap_or(u64::MAX),
772        None => {
773            // Saturating add — a corrupt or adversarial archive could claim
774            // per-entry sizes that sum past u64::MAX; we report u64::MAX in
775            // that case rather than panicking (debug) or wrapping (release).
776            let mut total: u64 = 0;
777            for i in 0..entry_count {
778                let entry = archive.by_index_raw(i)?;
779                total = total.saturating_add(entry.size());
780            }
781            total
782        }
783    };
784
785    Ok(ArchiveInfo {
786        format: "zip",
787        entry_count,
788        total_uncompressed,
789        compressed_size,
790    })
791}
792
793// ── Helpers ──────────────────────────────────────────────────────────────────
794
795/// Open a zip entry by index, decrypting with `password` when provided.
796///
797/// If no password is supplied but the entry IS encrypted, returns
798/// `Error::PasswordRequired` rather than a cryptic `UnsupportedArchive` error.
799fn open_zip_entry<'a>(
800    archive: &'a mut ZipArchive<fs_err::File>,
801    index: usize,
802    password: Option<&str>,
803) -> Result<zip::read::ZipFile<'a, fs_err::File>> {
804    if let Some(pwd) = password {
805        Ok(archive.by_index_decrypt(index, pwd.as_bytes())?)
806    } else {
807        // Peek at the raw entry to check if it's encrypted before attempting
808        // to open it without a password.
809        let encrypted = archive.by_index_raw(index)?.encrypted();
810        if encrypted {
811            return Err(Error::PasswordRequired);
812        }
813        Ok(archive.by_index(index)?)
814    }
815}
816
817/// Convert a zip `DateTime` to a unix epoch (seconds since 1970-01-01).
818/// Returns 0 for any invalid or pre-epoch date.
819fn zip_datetime_to_epoch(dt: zip::DateTime) -> u64 {
820    let Some(month) = time::Month::try_from(dt.month()).ok() else {
821        return 0;
822    };
823    let Some(date) = time::Date::from_calendar_date(dt.year() as i32, month, dt.day()).ok() else {
824        return 0;
825    };
826    let Some(time) = time::Time::from_hms(dt.hour(), dt.minute(), dt.second()).ok() else {
827        return 0;
828    };
829
830    let stamp = time::PrimitiveDateTime::new(date, time)
831        .assume_utc()
832        .unix_timestamp();
833    if stamp >= 0 { stamp as u64 } else { 0 }
834}