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
13pub(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
42fn zip_datetime_from_meta(meta: &std::fs::Metadata) -> Option<zip::DateTime> {
52 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 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
81pub(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 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 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
174fn 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 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
215pub(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 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
252pub(crate) const MAX_SYMLINK_TARGET: u64 = 8 * 1024;
258
259fn extract_symlink_entry(
277 entry: &mut zip::read::ZipFile<'_, fs_err::File>,
278 out_path: &Utf8Path,
279 dest_path: &Utf8Path,
280) -> Result<u64> {
281 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
321pub 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 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 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
390fn 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 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
424struct DestGroup {
428 dest: Utf8PathBuf,
429 indices: Vec<usize>,
430 has_file: bool,
431 has_dir: bool,
432}
433
434fn 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
457fn 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 let mut dir_modes: Vec<(Utf8PathBuf, u32)> = Vec::new();
474 for index in 0..archive.len() {
475 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
505fn resolve_destination(
509 name: &Utf8Path,
510 is_dir: bool,
511 opts: &DecompressOpts<'_>,
512) -> Result<Option<Utf8PathBuf>> {
513 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 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
545fn 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
573fn 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 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
643pub 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 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 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
686pub 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 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
721pub 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 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
758pub 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 let total_uncompressed = match archive.decompressed_size() {
771 Some(size) => u64::try_from(size).unwrap_or(u64::MAX),
772 None => {
773 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
793fn 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 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
817fn 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}