1use super::format::path_requires_directory;
2use super::{Archive, Entry, EntryType, Result, error, invalid};
3use std::fs::{self, OpenOptions};
4use std::io::{ErrorKind, Read, Write};
5#[cfg(unix)]
6use std::os::unix::fs::MetadataExt;
7use std::path::{Component, Path, PathBuf};
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub struct Progress {
12 pub bytes_read: u64,
14}
15
16#[derive(Clone, Debug)]
18pub struct EntryInfo {
19 pub path: PathBuf,
21 pub entry_type: EntryType,
23 pub size: u64,
25 pub sparse: bool,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum SkipReason {
33 Stripped,
35 ReservedName,
37 UnsupportedType,
39 Exists,
41 SymlinkUnavailable,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct SkippedEntry {
48 pub path: PathBuf,
50 pub reason: SkipReason,
52}
53
54#[derive(Clone, Debug, Default, Eq, PartialEq)]
56pub struct UnpackSummary {
57 pub files: u64,
59 pub dirs: u64,
61 pub symlinks: u64,
63 pub hardlinks: u64,
65 pub sparse_files: u64,
67 pub skipped: Vec<SkippedEntry>,
69}
70
71#[non_exhaustive]
73pub struct UnpackOptions {
74 pub strip_components: usize,
76 pub preserve_mtime: bool,
78 pub preserve_permissions: bool,
80 pub overwrite: bool,
82 pub on_progress: Option<ProgressCallback>,
84 pub on_entry: Option<EntryCallback>,
86}
87
88pub type ProgressCallback = Box<dyn FnMut(Progress)>;
90pub type EntryCallback = Box<dyn FnMut(&EntryInfo)>;
92
93impl Default for UnpackOptions {
94 fn default() -> Self {
95 Self {
96 strip_components: 0,
97 preserve_mtime: true,
98 preserve_permissions: true,
99 overwrite: true,
100 on_progress: None,
101 on_entry: None,
102 }
103 }
104}
105
106#[must_use = "call finish() to apply deferred directory metadata"]
112pub struct EntryUnpacker<'a> {
113 root: PathBuf,
114 root_identity: RootIdentity,
115 opts: &'a mut UnpackOptions,
116 deferred_dirs: Vec<DeferredDirectory>,
117}
118
119pub(super) struct DeferredDirectory {
120 path: PathBuf,
121 mode: u32,
122 mtime: i64,
123 #[cfg(unix)]
124 identity: (u64, u64),
125}
126
127impl DeferredDirectory {
128 fn new(path: PathBuf, mode: u32, mtime: i64) -> Result<Self> {
129 let path = fs::canonicalize(path)?;
130 let metadata = fs::symlink_metadata(&path)?;
131 if !metadata.is_dir() {
132 return Err(invalid("archive directory is not a directory"));
133 }
134 Ok(Self {
135 path,
136 mode,
137 mtime,
138 #[cfg(unix)]
139 identity: (metadata.dev(), metadata.ino()),
140 })
141 }
142}
143
144pub(super) struct RootIdentity {
145 #[cfg(unix)]
146 identity: (u64, u64),
147}
148
149impl RootIdentity {
150 fn capture(root: &Path) -> Result<Self> {
151 let metadata = fs::symlink_metadata(root)?;
152 if !metadata.is_dir() {
153 return Err(invalid("extraction root is not a directory"));
154 }
155 Ok(Self {
156 #[cfg(unix)]
157 identity: (metadata.dev(), metadata.ino()),
158 })
159 }
160
161 fn check(&self, root: &Path) -> Result<()> {
162 #[cfg(not(unix))]
163 let _ = self;
164 let metadata = fs::symlink_metadata(root)?;
165 #[cfg(unix)]
166 let identity_matches = (metadata.dev(), metadata.ino()) == self.identity;
167 #[cfg(not(unix))]
168 let identity_matches = true;
169 if !metadata.is_dir() || !identity_matches || fs::canonicalize(root)?.as_path() != root {
170 return Err(invalid("extraction root changed during unpack"));
171 }
172 Ok(())
173 }
174}
175
176impl<'a> EntryUnpacker<'a> {
177 pub fn new<P: AsRef<Path>>(dest: P, opts: &'a mut UnpackOptions) -> Result<Self> {
184 let dest = dest.as_ref();
185 fs::create_dir_all(dest)?;
186 if fs::symlink_metadata(dest)?.file_type().is_symlink() {
187 return Err(invalid("destination may not be a symlink"));
188 }
189 let root = fs::canonicalize(dest)?;
190 let root_identity = RootIdentity::capture(&root)?;
191 Ok(Self {
192 root,
193 root_identity,
194 opts,
195 deferred_dirs: Vec::new(),
196 })
197 }
198
199 pub fn unpack<R: Read>(&mut self, entry: &mut Entry<R>) -> Result<UnpackSummary> {
211 unpack_entry(
212 entry,
213 &self.root,
214 &self.root_identity,
215 self.opts,
216 &mut self.deferred_dirs,
217 )
218 }
219
220 pub fn finish(self) -> Result<()> {
227 self.root_identity.check(&self.root)?;
228 apply_deferred_directory_metadata(
229 self.deferred_dirs,
230 self.opts.preserve_permissions,
231 self.opts.preserve_mtime,
232 )
233 }
234}
235
236pub(super) struct ProgressReporter<'a> {
237 callback: &'a mut Option<ProgressCallback>,
238 last: u64,
239 root_guard: Option<(&'a Path, &'a RootIdentity)>,
240}
241
242impl ProgressReporter<'_> {
243 pub(super) fn update(&mut self, current: u64) -> Result<()> {
244 if current.saturating_sub(self.last) >= 64 * 1024 {
245 self.fire(current)?;
246 }
247 Ok(())
248 }
249 fn boundary(&mut self, current: u64) -> Result<()> {
250 self.fire(current)
251 }
252 fn fire(&mut self, current: u64) -> Result<()> {
253 if let Some(callback) = self.callback.as_mut() {
254 callback(Progress {
255 bytes_read: current,
256 });
257 if let Some((root, identity)) = self.root_guard {
258 identity.check(root)?;
259 }
260 }
261 self.last = current;
262 Ok(())
263 }
264}
265
266#[allow(clippy::too_many_lines)]
267pub(super) fn unpack_archive<R: Read>(
268 archive: &mut Archive<R>,
269 dest: &Path,
270 opts: &mut UnpackOptions,
271) -> Result<UnpackSummary> {
272 let mut entries = archive.entries()?;
273 fs::create_dir_all(dest)?;
274 if fs::symlink_metadata(dest)?.file_type().is_symlink() {
275 return Err(invalid("destination may not be a symlink"));
276 }
277 let root = fs::canonicalize(dest)?;
278 let root_identity = RootIdentity::capture(&root)?;
279 let mut summary = UnpackSummary::default();
280 let mut deferred_dirs = Vec::new();
281 let preserve_permissions = opts.preserve_permissions;
282 let preserve_mtime = opts.preserve_mtime;
283 let mut progress = ProgressReporter {
284 callback: &mut opts.on_progress,
285 last: 0,
286 root_guard: Some((&root, &root_identity)),
287 };
288 for item in &mut entries {
289 let mut entry = item?;
290 validate_directory_suffixes(&entry)?;
291 root_identity.check(&root)?;
292 let original = entry.path()?.into_owned();
293 if let Some(callback) = opts.on_entry.as_mut() {
294 callback(&EntryInfo {
295 path: original.clone(),
296 entry_type: entry.kind,
297 size: entry.logical_size,
298 sparse: entry.sparse.is_some(),
299 });
300 root_identity.check(&root)?;
301 }
302 progress.boundary(entry.bytes_read())?;
303 let Some(relative) = secure_relative_path(&original, opts.strip_components)? else {
304 summary.skipped.push(SkippedEntry {
305 path: original,
306 reason: SkipReason::Stripped,
307 });
308 continue;
309 };
310 if is_reserved_path(&relative) {
311 summary.skipped.push(SkippedEntry {
312 path: original,
313 reason: SkipReason::ReservedName,
314 });
315 continue;
316 }
317 if matches!(
318 entry.kind,
319 EntryType::CharDevice | EntryType::BlockDevice | EntryType::Fifo
320 ) {
321 summary.skipped.push(SkippedEntry {
322 path: original,
323 reason: SkipReason::UnsupportedType,
324 });
325 continue;
326 }
327 let output = root.join(&relative);
328 if matches!(entry.kind, EntryType::Hardlink | EntryType::Symlink)
329 && skip_existing_output(&root, &relative, opts.overwrite)?
330 {
331 summary.skipped.push(SkippedEntry {
332 path: original,
333 reason: SkipReason::Exists,
334 });
335 continue;
336 }
337 let hardlink_source = if entry.kind == EntryType::Hardlink {
338 Some(preflight_hardlink_source(
339 &entry,
340 &root,
341 &output,
342 opts.strip_components,
343 )?)
344 } else {
345 None
346 };
347 validate_mtime_before_output(
348 entry.kind,
349 entry.header.mtime,
350 opts.preserve_mtime,
351 &output,
352 opts.overwrite,
353 )?;
354 if entry.kind == EntryType::File
355 && entry.sparse.is_some()
356 && entry.logical_size > i64::MAX as u64
357 && (opts.overwrite || fs::symlink_metadata(&output).is_err())
358 {
359 return Err(invalid(
360 "sparse output length exceeds the filesystem offset range",
361 ));
362 }
363 ensure_safe_parents(&root, &relative)?;
364 match entry.kind {
365 EntryType::Directory => {
366 if output.exists() && fs::symlink_metadata(&output)?.file_type().is_symlink() {
367 return Err(invalid("archive directory collides with symlink"));
368 }
369 fs::create_dir_all(&output)?;
370 deferred_dirs.push(DeferredDirectory::new(
371 output,
372 entry.header.mode,
373 entry.header.mtime,
374 )?);
375 summary.dirs += 1;
376 }
377 EntryType::File => {
378 if !prepare_output(&output, opts.overwrite)? {
379 summary.skipped.push(SkippedEntry {
380 path: original,
381 reason: SkipReason::Exists,
382 });
383 continue;
384 }
385 let mut file = OpenOptions::new()
386 .write(true)
387 .create_new(true)
388 .open(&output)?;
389 if entry.sparse.is_some() {
390 entry.copy_sparse_to(&mut file, &mut progress)?;
391 summary.sparse_files += 1;
392 } else {
393 let mut buf = vec![0_u8; 64 * 1024];
394 loop {
395 let n = entry.read(&mut buf)?;
396 if n == 0 {
397 break;
398 }
399 file.write_all(&buf[..n])?;
400 progress.update(entry.bytes_read())?;
401 }
402 }
403 root_identity.check(&root)?;
404 apply_file_metadata(
405 &file,
406 entry.header.mode,
407 entry.header.mtime,
408 preserve_permissions,
409 preserve_mtime,
410 )?;
411 summary.files += 1;
412 }
413 EntryType::Symlink => {
414 let target = entry
415 .header
416 .link_name()
417 .ok_or_else(|| invalid("symlink lacks target"))?;
418 match replace_output_with_link(&output, opts.overwrite, |path| {
419 create_symlink(&target, path)
420 }) {
421 Ok(true) => summary.symlinks += 1,
422 Ok(false) => {
423 summary.skipped.push(SkippedEntry {
424 path: original,
425 reason: SkipReason::Exists,
426 });
427 continue;
428 }
429 Err(err)
430 if cfg!(windows)
431 && matches!(
432 err.kind(),
433 ErrorKind::PermissionDenied | ErrorKind::Unsupported
434 ) =>
435 {
436 summary.skipped.push(SkippedEntry {
437 path: original,
438 reason: SkipReason::SymlinkUnavailable,
439 });
440 }
441 Err(err) => return Err(err),
442 }
443 }
444 EntryType::Hardlink => {
445 let source = hardlink_source
446 .as_ref()
447 .ok_or_else(|| invalid("hardlink source was not prepared"))?;
448 if !replace_output_with_link(&output, opts.overwrite, |path| {
449 fs::hard_link(source, path)
450 })? {
451 summary.skipped.push(SkippedEntry {
452 path: original,
453 reason: SkipReason::Exists,
454 });
455 continue;
456 }
457 summary.hardlinks += 1;
458 }
459 _ => summary.skipped.push(SkippedEntry {
460 path: original,
461 reason: SkipReason::UnsupportedType,
462 }),
463 }
464 progress.boundary(entry.bytes_read())?;
465 }
466 root_identity.check(&root)?;
467 apply_deferred_directory_metadata(deferred_dirs, preserve_permissions, preserve_mtime)?;
468 progress.boundary(archive.state.borrow().raw_bytes)?;
469 Ok(summary)
470}
471
472#[allow(clippy::too_many_lines)]
473pub(super) fn unpack_entry<R: Read>(
474 entry: &mut Entry<R>,
475 root: &Path,
476 root_identity: &RootIdentity,
477 opts: &mut UnpackOptions,
478 deferred_dirs: &mut Vec<DeferredDirectory>,
479) -> Result<UnpackSummary> {
480 if entry.generation != entry.state.borrow().generation {
481 return Err(error(
482 ErrorKind::InvalidInput,
483 "entry is stale because iteration advanced",
484 ));
485 }
486 if entry.logical_pos != 0 || entry.extraction_started {
487 return Err(error(
488 ErrorKind::InvalidInput,
489 "entry was already read or extraction was started",
490 ));
491 }
492 validate_directory_suffixes(entry)?;
493 root_identity.check(root)?;
494 let original = entry.path()?.into_owned();
495 if let Some(callback) = opts.on_entry.as_mut() {
496 callback(&EntryInfo {
497 path: original.clone(),
498 entry_type: entry.kind,
499 size: entry.logical_size,
500 sparse: entry.sparse.is_some(),
501 });
502 root_identity.check(root)?;
503 }
504 let mut summary = UnpackSummary::default();
505 let mut progress = ProgressReporter {
506 callback: &mut opts.on_progress,
507 last: 0,
508 root_guard: Some((root, root_identity)),
509 };
510 progress.boundary(entry.bytes_read())?;
511 let Some(relative) = secure_relative_path(&original, opts.strip_components)? else {
512 summary.skipped.push(SkippedEntry {
513 path: original,
514 reason: SkipReason::Stripped,
515 });
516 return Ok(summary);
517 };
518 if is_reserved_path(&relative) {
519 summary.skipped.push(SkippedEntry {
520 path: original,
521 reason: SkipReason::ReservedName,
522 });
523 return Ok(summary);
524 }
525 if matches!(
526 entry.kind,
527 EntryType::CharDevice | EntryType::BlockDevice | EntryType::Fifo
528 ) {
529 summary.skipped.push(SkippedEntry {
530 path: original,
531 reason: SkipReason::UnsupportedType,
532 });
533 return Ok(summary);
534 }
535 let output = root.join(&relative);
536 if matches!(entry.kind, EntryType::Hardlink | EntryType::Symlink)
537 && skip_existing_output(root, &relative, opts.overwrite)?
538 {
539 summary.skipped.push(SkippedEntry {
540 path: original,
541 reason: SkipReason::Exists,
542 });
543 return Ok(summary);
544 }
545 let hardlink_source = if entry.kind == EntryType::Hardlink {
546 Some(preflight_hardlink_source(
547 entry,
548 root,
549 &output,
550 opts.strip_components,
551 )?)
552 } else {
553 None
554 };
555 validate_mtime_before_output(
556 entry.kind,
557 entry.header.mtime,
558 opts.preserve_mtime,
559 &output,
560 opts.overwrite,
561 )?;
562 if entry.kind == EntryType::File
563 && entry.sparse.is_some()
564 && entry.logical_size > i64::MAX as u64
565 && (opts.overwrite || fs::symlink_metadata(&output).is_err())
566 {
567 return Err(invalid(
568 "sparse output length exceeds the filesystem offset range",
569 ));
570 }
571 ensure_safe_parents(root, &relative)?;
572 match entry.kind {
573 EntryType::Directory => {
574 if output.exists() && fs::symlink_metadata(&output)?.file_type().is_symlink() {
575 return Err(invalid("archive directory collides with symlink"));
576 }
577 entry.extraction_started = true;
578 fs::create_dir_all(&output)?;
579 deferred_dirs.push(DeferredDirectory::new(
580 output,
581 entry.header.mode,
582 entry.header.mtime,
583 )?);
584 summary.dirs = 1;
585 }
586 EntryType::File => {
587 if !prepare_output(&output, opts.overwrite)? {
588 summary.skipped.push(SkippedEntry {
589 path: original,
590 reason: SkipReason::Exists,
591 });
592 return Ok(summary);
593 }
594 entry.extraction_started = true;
597 let mut file = OpenOptions::new()
598 .write(true)
599 .create_new(true)
600 .open(&output)?;
601 if entry.sparse.is_some() {
602 entry.copy_sparse_to(&mut file, &mut progress)?;
603 summary.sparse_files = 1;
604 } else {
605 let mut buf = vec![0_u8; 64 * 1024];
606 loop {
607 let n = entry.read(&mut buf)?;
608 if n == 0 {
609 break;
610 }
611 file.write_all(&buf[..n])?;
612 progress.update(entry.bytes_read())?;
613 }
614 }
615 root_identity.check(root)?;
616 apply_file_metadata(
617 &file,
618 entry.header.mode,
619 entry.header.mtime,
620 opts.preserve_permissions,
621 opts.preserve_mtime,
622 )?;
623 summary.files = 1;
624 }
625 EntryType::Symlink => {
626 let target = entry
627 .header
628 .link_name()
629 .ok_or_else(|| invalid("symlink lacks target"))?;
630 match replace_output_with_link(&output, opts.overwrite, |path| {
631 create_symlink(&target, path)
632 }) {
633 Ok(true) => {
634 entry.extraction_started = true;
635 summary.symlinks = 1;
636 }
637 Ok(false) => {
638 summary.skipped.push(SkippedEntry {
639 path: original,
640 reason: SkipReason::Exists,
641 });
642 return Ok(summary);
643 }
644 Err(err)
645 if cfg!(windows)
646 && matches!(
647 err.kind(),
648 ErrorKind::PermissionDenied | ErrorKind::Unsupported
649 ) =>
650 {
651 summary.skipped.push(SkippedEntry {
652 path: original,
653 reason: SkipReason::SymlinkUnavailable,
654 });
655 }
656 Err(err) => return Err(err),
657 }
658 }
659 EntryType::Hardlink => {
660 let source = hardlink_source
661 .as_ref()
662 .ok_or_else(|| invalid("hardlink source was not prepared"))?;
663 if !replace_output_with_link(&output, opts.overwrite, |path| {
664 fs::hard_link(source, path)
665 })? {
666 summary.skipped.push(SkippedEntry {
667 path: original,
668 reason: SkipReason::Exists,
669 });
670 return Ok(summary);
671 }
672 entry.extraction_started = true;
673 summary.hardlinks = 1;
674 }
675 _ => summary.skipped.push(SkippedEntry {
676 path: original,
677 reason: SkipReason::UnsupportedType,
678 }),
679 }
680 progress.boundary(entry.bytes_read())?;
681 Ok(summary)
682}
683
684fn validate_directory_suffixes<R: Read>(entry: &Entry<R>) -> Result<()> {
685 if entry.kind != EntryType::Directory && path_requires_directory(&entry.header.path) {
686 return Err(invalid(
687 "only a directory may have a directory-required path suffix",
688 ));
689 }
690 if entry.kind == EntryType::Hardlink
691 && entry
692 .header
693 .link_name
694 .as_deref()
695 .is_some_and(path_requires_directory)
696 {
697 return Err(invalid("hardlink target requires a directory"));
698 }
699 Ok(())
700}
701
702fn secure_relative_path(path: &Path, strip: usize) -> Result<Option<PathBuf>> {
703 if path.is_absolute() {
704 return Err(invalid("absolute archive path rejected"));
705 }
706 let mut clean = Vec::new();
707 for component in path.components() {
708 match component {
709 Component::Normal(value) => clean.push(value.to_owned()),
710 Component::CurDir => {}
711 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
712 return Err(invalid("archive path traversal rejected"));
713 }
714 }
715 }
716 if strip >= clean.len() {
717 return Ok(None);
718 }
719 Ok(Some(clean.into_iter().skip(strip).collect()))
720}
721
722fn ensure_safe_parents(root: &Path, relative: &Path) -> Result<()> {
723 let mut current = root.to_path_buf();
724 if let Some(parent) = relative.parent() {
725 for component in parent.components() {
726 let Component::Normal(part) = component else {
727 return Err(invalid("invalid output path component"));
728 };
729 current.push(part);
730 match fs::symlink_metadata(¤t) {
731 Ok(meta) if meta.file_type().is_symlink() => {
732 return Err(invalid("archive attempted to write through a symlink"));
733 }
734 Ok(meta) if !meta.is_dir() => {
735 return Err(invalid("archive parent is not a directory"));
736 }
737 Ok(_) => {}
738 Err(err) if err.kind() == ErrorKind::NotFound => fs::create_dir(¤t)?,
739 Err(err) => return Err(err),
740 }
741 }
742 }
743 Ok(())
744}
745
746fn preflight_hardlink_source<R: Read>(
747 entry: &Entry<R>,
748 root: &Path,
749 output: &Path,
750 strip_components: usize,
751) -> Result<PathBuf> {
752 let target = entry
753 .header
754 .link_name()
755 .ok_or_else(|| invalid("hardlink lacks target"))?;
756 let relative = secure_relative_path(&target, strip_components)?
757 .ok_or_else(|| invalid("hardlink target was stripped away"))?;
758 let mut source = root.to_path_buf();
759 if let Some(parent) = relative.parent() {
760 for component in parent.components() {
762 source.push(component);
763 let metadata = fs::symlink_metadata(&source)?;
764 if metadata.file_type().is_symlink() || !metadata.is_dir() {
765 return Err(invalid("hardlink target parent is not a safe directory"));
766 }
767 }
768 }
769 source = root.join(relative);
770 let metadata = fs::symlink_metadata(&source)?;
771 if !metadata.is_file() {
772 return Err(invalid("hardlink target must be an existing regular file"));
773 }
774 match fs::symlink_metadata(output) {
775 Ok(metadata) if !metadata.file_type().is_symlink() => {
776 if fs::canonicalize(output)? == fs::canonicalize(&source)? {
779 return Err(invalid("hardlink target resolves to its output path"));
780 }
781 }
782 Ok(_) => {}
783 Err(err) if err.kind() == ErrorKind::NotFound => {}
784 Err(err) => return Err(err),
785 }
786 Ok(source)
787}
788
789struct TemporaryLink(PathBuf);
790
791impl Drop for TemporaryLink {
792 fn drop(&mut self) {
793 let _ = fs::remove_file(&self.0);
796 }
797}
798
799fn replace_output_with_link(
800 path: &Path,
801 overwrite: bool,
802 create: impl Fn(&Path) -> Result<()>,
803) -> Result<bool> {
804 static NEXT_LINK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
805
806 match fs::symlink_metadata(path) {
807 Ok(_) if !overwrite => return Ok(false),
808 Ok(metadata) if metadata.is_dir() => {
809 return Err(invalid("archive file collides with existing directory"));
810 }
811 Ok(_) => {}
812 Err(err) if err.kind() == ErrorKind::NotFound => {
813 create(path)?;
814 return Ok(true);
815 }
816 Err(err) => return Err(err),
817 }
818 let parent = path
819 .parent()
820 .ok_or_else(|| invalid("link output has no parent directory"))?;
821 for _ in 0..128 {
822 let id = NEXT_LINK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
823 let temporary = parent.join(format!(".jdx-tar-{}-{id}.link", std::process::id()));
824 match create(&temporary) {
825 Ok(()) => {
826 let temporary = TemporaryLink(temporary);
827 fs::rename(&temporary.0, path)?;
830 return Ok(true);
831 }
832 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
833 Err(err) => return Err(err),
834 }
835 }
836 Err(std::io::Error::new(
837 ErrorKind::AlreadyExists,
838 "unable to reserve a temporary link name",
839 ))
840}
841
842fn skip_existing_output(root: &Path, relative: &Path, overwrite: bool) -> Result<bool> {
843 if overwrite {
844 return Ok(false);
845 }
846 match fs::symlink_metadata(root.join(relative)) {
847 Ok(_) => {
848 ensure_safe_parents(root, relative)?;
851 Ok(true)
852 }
853 Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
854 Err(err) => Err(err),
855 }
856}
857
858fn prepare_output(path: &Path, overwrite: bool) -> Result<bool> {
859 match fs::symlink_metadata(path) {
860 Ok(_meta) if !overwrite => Ok(false),
861 Ok(meta) if meta.is_dir() => Err(invalid("archive file collides with existing directory")),
862 Ok(_) => {
863 fs::remove_file(path)?;
864 Ok(true)
865 }
866 Err(err) if err.kind() == ErrorKind::NotFound => Ok(true),
867 Err(err) => Err(err),
868 }
869}
870
871fn apply_deferred_directory_metadata(
872 directories: Vec<DeferredDirectory>,
873 preserve_permissions: bool,
874 preserve_mtime: bool,
875) -> Result<()> {
876 let mut latest = std::collections::BTreeMap::new();
879 for directory in directories {
880 latest.insert(directory.path.clone(), directory);
881 }
882 let mut directories: Vec<_> = latest.into_iter().collect();
883 directories.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count()));
884 for (_, directory) in directories {
885 apply_directory_metadata(&directory, preserve_permissions, preserve_mtime)?;
886 }
887 Ok(())
888}
889
890fn apply_file_metadata(
891 file: &fs::File,
892 mode: u32,
893 mtime: i64,
894 preserve_permissions: bool,
895 preserve_mtime: bool,
896) -> Result<()> {
897 #[cfg(not(unix))]
898 let _ = (mode, preserve_permissions);
899 #[cfg(unix)]
900 if preserve_permissions {
901 use std::os::unix::fs::PermissionsExt;
902 file.set_permissions(fs::Permissions::from_mode(mode & 0o7777))?;
903 }
904 if preserve_mtime {
905 let time = filetime::FileTime::from_unix_time(mtime, 0);
906 filetime::set_file_handle_times(file, None, Some(time))?;
907 }
908 Ok(())
909}
910
911fn apply_directory_metadata(
912 directory: &DeferredDirectory,
913 preserve_permissions: bool,
914 preserve_mtime: bool,
915) -> Result<()> {
916 #[cfg(not(unix))]
917 let _ = directory.mode;
918 if !preserve_permissions && !preserve_mtime {
919 return Ok(());
920 }
921 #[cfg(unix)]
922 let metadata = {
923 let metadata = fs::symlink_metadata(&directory.path)?;
924 if !metadata.is_dir() || (metadata.dev(), metadata.ino()) != directory.identity {
925 return Err(invalid(
926 "archive directory changed before metadata restoration",
927 ));
928 }
929 metadata
930 };
931 #[cfg(unix)]
932 if preserve_permissions {
933 use std::os::unix::fs::PermissionsExt;
934 fs::set_permissions(
935 &directory.path,
936 fs::Permissions::from_mode(directory.mode & 0o7777),
937 )?;
938 }
939 if preserve_mtime {
940 let time = filetime::FileTime::from_unix_time(directory.mtime, 0);
941 #[cfg(unix)]
942 {
943 filetime::set_symlink_file_times(
947 &directory.path,
948 filetime::FileTime::from_last_access_time(&metadata),
949 time,
950 )?;
951 }
952 #[cfg(not(unix))]
953 filetime::set_file_mtime(&directory.path, time)?;
954 }
955 Ok(())
956}
957
958#[cfg(unix)]
959fn create_symlink(target: &Path, output: &Path) -> Result<()> {
960 std::os::unix::fs::symlink(target, output)
961}
962#[cfg(windows)]
963fn create_symlink(target: &Path, output: &Path) -> Result<()> {
964 std::os::windows::fs::symlink_file(target, output)
965}
966
967#[cfg(windows)]
968fn is_reserved_path(path: &Path) -> bool {
969 path.components().any(|component| {
970 let Component::Normal(value) = component else {
971 return false;
972 };
973 let value = value.to_string_lossy();
974 let stem = value
975 .trim_end_matches([' ', '.'])
976 .split('.')
977 .next()
978 .unwrap_or("")
979 .to_ascii_uppercase();
980 matches!(
981 stem.as_str(),
982 "CON"
983 | "PRN"
984 | "AUX"
985 | "NUL"
986 | "COM1"
987 | "COM2"
988 | "COM3"
989 | "COM4"
990 | "COM5"
991 | "COM6"
992 | "COM7"
993 | "COM8"
994 | "COM9"
995 | "LPT1"
996 | "LPT2"
997 | "LPT3"
998 | "LPT4"
999 | "LPT5"
1000 | "LPT6"
1001 | "LPT7"
1002 | "LPT8"
1003 | "LPT9"
1004 )
1005 })
1006}
1007#[cfg(not(windows))]
1008fn is_reserved_path(_path: &Path) -> bool {
1009 false
1010}
1011
1012fn validate_mtime_before_output(
1013 kind: EntryType,
1014 mtime: i64,
1015 preserve_mtime: bool,
1016 output: &Path,
1017 overwrite: bool,
1018) -> Result<()> {
1019 let skips_existing_file =
1020 kind == EntryType::File && !overwrite && fs::symlink_metadata(output).is_ok();
1021 if preserve_mtime
1022 && matches!(kind, EntryType::File | EntryType::Directory)
1023 && mtime == i64::MIN
1024 && !skips_existing_file
1025 {
1026 return Err(invalid("tar mtime is too small to restore safely"));
1027 }
1028 Ok(())
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034 use std::cell::RefCell;
1035 use std::rc::Rc;
1036
1037 #[test]
1038 fn normalizes_and_strips_safe_paths() {
1039 assert_eq!(
1040 secure_relative_path(Path::new("./one/two/file"), 2).unwrap(),
1041 Some(PathBuf::from("file"))
1042 );
1043 assert_eq!(secure_relative_path(Path::new("one"), 1).unwrap(), None);
1044 assert!(secure_relative_path(Path::new("../file"), 0).is_err());
1045 assert!(secure_relative_path(Path::new("/file"), 0).is_err());
1046 }
1047
1048 #[test]
1049 fn progress_reports_thresholds_and_boundaries() {
1050 let seen = Rc::new(RefCell::new(Vec::new()));
1051 let mut callback: Option<ProgressCallback> = Some(Box::new({
1052 let seen = Rc::clone(&seen);
1053 move |progress| seen.borrow_mut().push(progress.bytes_read)
1054 }));
1055 let mut reporter = ProgressReporter {
1056 callback: &mut callback,
1057 last: 0,
1058 root_guard: None,
1059 };
1060 reporter.update(64 * 1024 - 1).unwrap();
1061 assert!(seen.borrow().is_empty());
1062 reporter.update(64 * 1024).unwrap();
1063 reporter.boundary(70 * 1024).unwrap();
1064 assert_eq!(*seen.borrow(), [64 * 1024, 70 * 1024]);
1065 }
1066
1067 #[test]
1068 fn prepare_output_respects_overwrite() {
1069 let temp = tempfile::tempdir().unwrap();
1070 let path = temp.path().join("file");
1071 fs::write(&path, b"old").unwrap();
1072 assert!(!prepare_output(&path, false).unwrap());
1073 assert_eq!(fs::read(&path).unwrap(), b"old");
1074 assert!(prepare_output(&path, true).unwrap());
1075 assert!(!path.exists());
1076 }
1077
1078 #[cfg(unix)]
1079 #[test]
1080 fn rejects_symlinked_parent() {
1081 let temp = tempfile::tempdir().unwrap();
1082 let outside = tempfile::tempdir().unwrap();
1083 std::os::unix::fs::symlink(outside.path(), temp.path().join("link")).unwrap();
1084 assert!(ensure_safe_parents(temp.path(), Path::new("link/file")).is_err());
1085 }
1086
1087 #[test]
1088 fn reserved_names_follow_platform_rules() {
1089 assert_eq!(is_reserved_path(Path::new("CON.txt")), cfg!(windows));
1090 assert!(!is_reserved_path(Path::new("ordinary.txt")));
1091 }
1092}