Skip to main content

jdx_tar/
unpack.rs

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/// Progress notification containing raw input bytes consumed.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub struct Progress {
12    /// Cumulative bytes pulled from the underlying reader.
13    pub bytes_read: u64,
14}
15
16/// Metadata sent immediately before extracting an entry.
17#[derive(Clone, Debug)]
18pub struct EntryInfo {
19    /// Final resolved archive path before component stripping.
20    pub path: PathBuf,
21    /// Entry kind.
22    pub entry_type: EntryType,
23    /// Logical entry size.
24    pub size: u64,
25    /// Whether the entry is sparse.
26    pub sparse: bool,
27}
28
29/// Why an entry was skipped during extraction.
30#[derive(Clone, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum SkipReason {
33    /// Component stripping removed the entire path.
34    Stripped,
35    /// The path is reserved by the target platform.
36    ReservedName,
37    /// The entry type is not extracted by this crate.
38    UnsupportedType,
39    /// An existing path was retained because overwrite is disabled.
40    Exists,
41    /// Symlink creation is unavailable or was denied.
42    SymlinkUnavailable,
43}
44
45/// One skipped archive entry.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct SkippedEntry {
48    /// Original resolved entry path.
49    pub path: PathBuf,
50    /// Skip reason.
51    pub reason: SkipReason,
52}
53
54/// Aggregate result of an extraction.
55#[derive(Clone, Debug, Default, Eq, PartialEq)]
56pub struct UnpackSummary {
57    /// Regular files extracted.
58    pub files: u64,
59    /// Directories extracted or confirmed.
60    pub dirs: u64,
61    /// Symbolic links created.
62    pub symlinks: u64,
63    /// Hard links created.
64    pub hardlinks: u64,
65    /// Sparse regular files extracted.
66    pub sparse_files: u64,
67    /// Entries skipped, with reasons.
68    pub skipped: Vec<SkippedEntry>,
69}
70
71/// Secure extraction behavior and callbacks.
72#[non_exhaustive]
73pub struct UnpackOptions {
74    /// Leading path components removed after all name overrides are resolved.
75    pub strip_components: usize,
76    /// Restore entry modification times.
77    pub preserve_mtime: bool,
78    /// Restore Unix permission bits.
79    pub preserve_permissions: bool,
80    /// Replace existing non-directory entries.
81    pub overwrite: bool,
82    /// Raw-input progress callback.
83    pub on_progress: Option<ProgressCallback>,
84    /// Callback fired before each logical entry is handled.
85    pub on_entry: Option<EntryCallback>,
86}
87
88/// Boxed raw-input progress callback.
89pub type ProgressCallback = Box<dyn FnMut(Progress)>;
90/// Boxed logical-entry callback.
91pub 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/// Stateful secure extractor for callers that inspect or skip individual
107/// entries before unpacking them.
108///
109/// Call [`Self::finish`] after the final entry so directory permissions and
110/// modification times are applied after their children have been written.
111#[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    /// Creates a per-entry extractor rooted at `dest`.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error when the destination cannot be created or resolved,
182    /// or when it is itself a symbolic link.
183    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    /// Securely extracts one entry beneath the configured destination.
200    ///
201    /// Entries may be inspected and omitted by the caller before invoking
202    /// this method. Absolute paths, traversal, and writes through symlinked
203    /// parents are rejected.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error for an unsafe path, malformed link target, truncated
208    /// data, callback-independent I/O failure, or filesystem extraction
209    /// failure, or if the entry is stale, already read, or previously extracted.
210    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    /// Applies deferred directory metadata and completes extraction.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error when directory permissions or modification times
225    /// cannot be restored.
226    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            // Sparse copying and zero-sized entries do not advance logical_pos.
595            // Once output preparation succeeds, even a failed copy is consumed.
596            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(&current) {
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(&current)?,
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        // A missing source is an error, never a reason to create its parents.
761        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            // Distinct hard links to one inode are safe; the same pathname is
777            // not, including filesystem case and Unicode aliases.
778            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        // A successful rename normally removes this name. It can also be a
794        // no-op when both names already refer to the same hard-linked inode.
795        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                // The destination is untouched until the OS has accepted the
828                // replacement link; a sibling rename commits the change.
829                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            // An existing leaf does not authorize traversal through a symlinked
849            // parent. Its parents already exist, so this check creates none.
850            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    // Canonical paths coalesce case and Unicode aliases on filesystems that
877    // resolve those spellings to the same directory. The last member wins.
878    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            // Restore time without reopening a directory whose archived mode
944            // can remove all access. The captured identity is checked after
945            // callbacks; concurrent tree mutation remains outside the contract.
946            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}