Skip to main content

stuffr_core/
containment.rs

1//! Entry-path containment: the single decision point for whether an
2//! archive entry's name (or a symlink's target) may be written under a
3//! destination directory.
4//!
5//! Pure and filesystem-free by design — see the module-level doc comments on
6//! each function for why.
7
8use std::path::{Component, Path, PathBuf};
9
10use crate::error::{Error, Result};
11
12/// Joins `entry_name` onto `dest`, refusing anything that escapes.
13///
14/// Resolved COMPONENT-WISE and without touching the filesystem, so the
15/// decision cannot depend on what happens to exist — and so it is exhaustively
16/// unit-testable with no archive and no temp directory. There is deliberately
17/// no code path that returns a sanitised path for a hostile name: the README's
18/// contract is "refused, not silently sanitised", and a caller that received a
19/// cleaned-up path would have no way to know it had been handed one.
20///
21/// `.` and `./` name the destination directory itself and are ACCEPTED,
22/// returning `dest` unchanged: `tar cf x.tar .` — the single most common way
23/// a tarball gets produced — emits `./` as its first entry, and refusing it
24/// would reject the majority of real-world tarballs at exit 7. Naming the
25/// destination cannot escape it, and escaping is the only thing this
26/// function exists to prevent. This is different from a name like `a/..`,
27/// which also nets to the destination but only after actually consuming a
28/// real component — see the `pushed_a_component` note below for why the two
29/// are told apart rather than collapsed onto one "ends up empty" check.
30pub fn safe_join(dest: &Path, entry_name: &str) -> Result<PathBuf> {
31    if entry_name.is_empty() {
32        return Err(Error::UnsafePath {
33            path: entry_name.to_string(),
34            reason: "empty entry name".to_string(),
35        });
36    }
37    // A name no path can hold, refused HERE rather than by the filesystem —
38    // the final whole-branch review's F3.
39    //
40    // `std::fs` rejects an interior NUL as `io::ErrorKind::InvalidInput`,
41    // which becomes `Error::Io` and falls through `Error::exit_code`'s
42    // `_ => 1` wildcard. Measured on a mutated `stuffr pack` zip whose entry
43    // name is `zt\0ee\0a.txt` (an ordinary zeroed run): `stuffr list` and
44    // `stuffr test` both exited 0, and `stuffr unpack -C out` answered
45    //
46    //     stuffr: i/o error: file name contained an unexpected NUL byte
47    //     exit=1
48    //
49    // — the code this project reserves for *stuffr* failing, on input the
50    // archive chose. It was the ONLY exit 1 in the review's 31,460-run
51    // corruption sweep across all five salvageable formats, and closing it
52    // makes that result a clean zero.
53    //
54    // # Why here, and why `UnsafePath`
55    //
56    // `Error::exit_code`'s own doc says a wrong code landing in that
57    // wildcard is as likely to mean the wrong VARIANT was constructed
58    // upstream as it is to mean the match is wrong, and that is the case
59    // here: `Error::Io` is not what an unrepresentable archive-supplied name
60    // is. The alternative — matching `io::ErrorKind` at the extraction call
61    // site — is the fix-the-site shape Task 3c shipped twice and had to undo
62    // twice.
63    //
64    // This function is the single decision point for whether an entry name
65    // may become a path under `dest`, it is pure, and every refusal it
66    // already makes is `UnsafePath`: absolute, traversal, net-zero
67    // traversal, and — the exact precedent — an EMPTY name, which is no more
68    // an escape attempt than this is. Splitting one function's verdict
69    // across two exit codes would leave a caller unable to predict either.
70    // Exit 7 at the CLI reads "an entry's name was refused and nothing was
71    // written under it", which is precisely what happens. Exit 5 was
72    // considered and rejected: it claims the ARCHIVE is corrupt, a stronger
73    // claim than stuffr can make from a name alone, and `list`/`test` verify
74    // the same file's bytes without complaint.
75    if entry_name.contains('\0') {
76        return Err(Error::UnsafePath {
77            path: entry_name.to_string(),
78            reason: "entry name contains a NUL byte".to_string(),
79        });
80    }
81
82    let mut out = PathBuf::new();
83    // Tracks whether any real (`Normal`) component was ever pushed, as
84    // distinct from `out` being empty. `.` and `./` leave both false and
85    // empty — never having accumulated anything to pop, they are the
86    // destination itself. `a/..` leaves `out` empty too, but only after
87    // pushing `a` and then popping it away again; collapsing these two onto
88    // a single "is `out` empty at the end" check would silently start
89    // accepting that second shape, which is real traversal that happens to
90    // net to zero rather than a name for the destination.
91    let mut pushed_a_component = false;
92    walk_within(&mut out, &mut pushed_a_component, Path::new(entry_name)).map_err(|reason| {
93        Error::UnsafePath {
94            path: entry_name.to_string(),
95            reason: reason.to_string(),
96        }
97    })?;
98
99    if out.as_os_str().is_empty() {
100        return if pushed_a_component {
101            // `a/..`, `a/b/../..`: a real component was consumed and then
102            // popped away again. Never produced by a real archiver (which
103            // emits bare `.`/`./` for the root, not this), so refusing it
104            // costs nothing in compatibility and keeps the traversal check
105            // from being second-guessed by a coincidental net-zero.
106            //
107            // This rule is about ENTRY NAMES specifically, which is why
108            // `check_symlink_target` below does not go through this
109            // function any more — see its own doc.
110            Err(Error::UnsafePath {
111                path: entry_name.to_string(),
112                reason: "path traversal nets back to the destination".to_string(),
113            })
114        } else {
115            // `.` or `./`: nothing was ever pushed, so nothing was popped
116            // away either — this names `dest` itself, not an escape from it.
117            Ok(dest.to_path_buf())
118        };
119    }
120    Ok(dest.join(out))
121}
122
123/// Walks `path`'s components onto `out`, popping for each `..`.
124///
125/// The single component-classification loop both public functions share, so
126/// there is exactly ONE place that decides what `..`, `.`, a root and a
127/// normal component mean. Returns the refusal `reason` rather than a full
128/// `Error`, so each caller builds its own: `safe_join` names the entry with
129/// this reason, and `classify_symlink_target` replaces it with
130/// `CLIMBS_OUT_OF_DEST` and names the ENTRY too (0.10.1) — never the composed
131/// string, which is not something the user can find in their archive.
132///
133/// Operates on `Path` components rather than a re-joined string, so nothing
134/// round-trips through `to_string_lossy` on the way. A lossy conversion was
135/// never a bypass here (`.`, `..` and `/` are ASCII and survive it) but it
136/// could mangle a non-UTF-8 target into a DIFFERENT contained name, and
137/// there is no reason to keep it now that the composition is component-wise.
138fn walk_within(
139    out: &mut PathBuf,
140    pushed_a_component: &mut bool,
141    path: &Path,
142) -> std::result::Result<(), &'static str> {
143    for comp in path.components() {
144        match comp {
145            // An absolute path or a Windows drive prefix ignores `dest`
146            // entirely — the classic "tar bomb writes to /etc" shape.
147            Component::RootDir | Component::Prefix(_) => return Err("absolute path"),
148            // `./` is noise, not an attack.
149            Component::CurDir => {}
150            // Pop only within what we have accumulated. `a/../b` is fine;
151            // `../b` and `a/../../b` are not. Comparing against the
152            // accumulated depth rather than canonicalising is what keeps this
153            // filesystem-independent — and immune to a symlink that appears
154            // between the check and the write.
155            Component::ParentDir => {
156                if !out.pop() {
157                    return Err("path traversal above the destination");
158                }
159            }
160            Component::Normal(part) => {
161                out.push(part);
162                *pushed_a_component = true;
163            }
164        }
165    }
166    Ok(())
167}
168
169/// [`classify_symlink_target`]'s skip reason for an empty target.
170pub(crate) const EMPTY_SYMLINK_TARGET: &str = "symlink target is empty";
171
172/// [`classify_symlink_target`]'s skip reason for a target holding `..` after
173/// a name.
174pub(crate) const CLIMBS_AFTER_A_NAME: &str =
175    "symlink target uses `..` after a name, which can climb out through another link";
176
177/// [`classify_symlink_target`]'s refusal for a leading `..` run that climbs
178/// past the destination.
179/// Quotes the target, escaped the way entry names are in messages.
180pub(crate) fn climbs_out_of_dest(target: &str) -> String {
181    format!(
182        "symlink target `{}` climbs out of the destination",
183        crate::display::fmt_name(target)
184    )
185}
186
187/// What extraction may do with one symlink entry, decided by
188/// [`classify_symlink_target`]. A genuine escape is not a verdict: it is
189/// `Err(Error::UnsafePath)`, exit 7.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191#[non_exhaustive]
192pub enum SymlinkVerdict {
193    /// Create the link: its target resolves inside the destination.
194    Allowed,
195    /// Do not create the link; name the entry in an
196    /// [`crate::Fidelity::EntrySkipped`] with this reason and carry on. The
197    /// target's SHAPE is one this extraction cannot prove contained (`..`
198    /// after a name) or names nothing (empty). Nothing is created, so
199    /// nothing can escape.
200    Skip(&'static str),
201}
202
203/// Refuses a symlink whose target would resolve outside `dest`.
204///
205/// The original, all-or-nothing form of [`classify_symlink_target`], kept
206/// for its callers: a [`SymlinkVerdict::Skip`] is reported here as
207/// `Error::UnsafePath` too, so anything this accepts is a link
208/// `classify_symlink_target` would create. The refusal names the TARGET,
209/// as it always has; extraction calls `classify_symlink_target`, which names
210/// the entry.
211pub fn check_symlink_target(dest: &Path, link_path: &Path, target: &str) -> Result<()> {
212    match classify_symlink_target(dest, link_path, target, target)? {
213        SymlinkVerdict::Allowed => Ok(()),
214        SymlinkVerdict::Skip(reason) => Err(Error::UnsafePath {
215            path: target.to_string(),
216            reason: reason.to_string(),
217        }),
218    }
219}
220
221/// Decides one symlink entry named `entry`, to be created at `link_path`
222/// (what `safe_join` produced for it) pointing at `target`: create it,
223/// skip it, or refuse the extraction. **The one owner of that rule** —
224/// extraction, the hard-link-to-a-symlink path and the fuzz oracle's
225/// lexical rule all ask it.
226///
227/// - `Err(Error::UnsafePath)`, exit 7, naming `entry`: a genuine escape. An
228///   absolute target, a `..` run that climbs deeper than the link's own
229///   depth below `dest` (lexically — checked before the shape, so a target
230///   that escapes is never merely skipped), or a NUL byte.
231/// - `Ok(Skip(reason))`: a SHAPE this extraction will not create — `..`
232///   after a name (`CLIMBS_AFTER_A_NAME`) or an empty target
233///   (`EMPTY_SYMLINK_TARGET`). Skipped, not refused (user ruling,
234///   2026-10-07): GNU tar's `--transform 's,^,pkg-1.0/,'` rewrites symlink
235///   targets as well as names, so `bin/foo -> ../lib/foo` arrives as
236///   `pkg-1.0/../lib/foo`, and aborting the whole unpack over it left a
237///   half-written tree where 0.10.0 and GNU tar extract everything.
238/// - `Ok(Allowed)`: anything else.
239///
240/// The link's own PATH can be perfectly contained while its target is not,
241/// and a later entry written through that link lands wherever it points.
242/// The target is resolved relative to the link's parent, which is how the
243/// OS will resolve it.
244///
245/// # Why this does not call `safe_join`
246///
247/// It used to: it composed `rel.join(target)` into a string and handed that
248/// to `safe_join`, which meant a symlink target inherited a refusal written
249/// for entry NAMES. `safe_join` refuses a name that nets back to `dest`
250/// after consuming a real component (`a/..`), on the reasoning that no real
251/// archiver emits one. That reasoning does not carry over: `sub/top -> ..`
252/// is an ordinary symlink pointing at the extraction root, which bsdtar and
253/// GNU tar both extract without comment, and `dest` is trivially inside
254/// `dest`. stuffr aborted the whole extraction at exit 7 — naming a path
255/// (`sub/..`) that is not an entry in the archive at all, so the user could
256/// not find it.
257///
258/// The component walk is shared (`walk_within`); only the net-to-`dest`
259/// verdict differs, which is the whole point. Every genuine escape is still
260/// refused by the same walk: an absolute target, and any `..` run deeper
261/// than the link's own depth below `dest`.
262///
263/// # `..` only as a leading run (0.10.1)
264///
265/// A target may hold `..` only as a LEADING prefix — `..`, `../..`,
266/// `../../a/b` — never after a name: `x/s2/..`, `a/../b` and `./a/..` are
267/// skipped (`CLIMBS_AFTER_A_NAME`) whatever they net to. `.` components
268/// are noise, exactly as `walk_within` treats them, so a leading `./` is
269/// harmless and `./..` is still a leading run. An EMPTY target is skipped
270/// too (`EMPTY_SYMLINK_TARGET`): it names nothing, and Linux's `symlink(2)`
271/// rejects it with ENOENT, which would otherwise surface as exit 1.
272///
273/// Why: lexical normalisation reads `x/s2/..` as `x`, but the OS resolves
274/// `x/s2` FIRST, and if `x/s2` is itself a symlink the `..` climbs from
275/// wherever IT points. A tar holding `x/`, `x/s2 -> ..` (contained: it names
276/// `dest`) and `s1 -> x/s2/..` unpacked at exit 0 with `dest/s1` resolving
277/// to `dest/..`. Entry order is no defence — `s1` can precede `s2`, so no
278/// creation-time existence check sees the link it climbs through. The rule
279/// is therefore about the target's SHAPE, and needs no filesystem.
280///
281/// # Why that is sound — by induction over the links a run creates
282///
283/// Only links that are CREATED have to obey the shape: a skipped link
284/// creates nothing, so it adds nothing to resolve through. Every link that
285/// is created sits at a path with no symlinked ancestor (the extraction
286/// refuses one before creating anything — `stuffr`'s
287/// `refuse_symlinked_ancestors` — and never removes a directory it would
288/// have to replace with a link), so the link's own directory is a REAL
289/// directory inside `dest`. Resolving its target from there:
290///
291/// 1. The leading `..` run climbs through real directories only, so its
292///    lexical depth check above IS its physical answer: it ends at a real
293///    directory inside `dest`, or is refused.
294/// 2. Every component after that run is a name (or `.`). A name either is a
295///    real directory entry — descending, so still inside `dest` — or is
296///    another link this run created, which by the induction hypothesis
297///    resolves inside `dest`, and the walk continues from there, again only
298///    by names.
299/// 3. No `..` ever follows a name, so the walk never climbs out of whatever
300///    a link handed it. Hence every created link resolves inside `dest`.
301///
302/// What the argument does not cover, by design: a symlink that was in
303/// `dest` before the run started, which a target could name through a plain
304/// component — that is the destination's owner's link, not the archive's —
305/// and a concurrent local writer, the check-then-use window
306/// `refuse_symlinked_ancestors` documents. The `container` fuzz target's
307/// oracle checks the result PHYSICALLY (`check_extraction_contained`), so a
308/// hole in this argument would be a finding there.
309pub fn classify_symlink_target(
310    dest: &Path,
311    link_path: &Path,
312    entry: &str,
313    target: &str,
314) -> Result<SymlinkVerdict> {
315    let refuse = |reason: String| Error::UnsafePath {
316        path: entry.to_string(),
317        reason,
318    };
319    let target_path = Path::new(target);
320    // The sibling door to `safe_join`'s own NUL refusal, closed in the same
321    // change and for the same reason: `std::os::unix::fs::symlink` rejects
322    // an interior NUL as `InvalidInput`, so a symlink TARGET carrying one
323    // reached the identical `Error::Io`/exit-1 path an entry NAME did.
324    // Closing only the half the review measured would leave the class open
325    // through a door two lines away.
326    if target.contains('\0') {
327        return Err(refuse(format!(
328            "symlink target `{}` contains a NUL byte",
329            crate::display::fmt_name(target)
330        )));
331    }
332    // An empty target names nothing and has no components, so every rule
333    // below would accept it — and `symlink(2)` on Linux answers ENOENT,
334    // which reached the CLI as `Error::Io`, exit 1, on a target the archive
335    // chose (macOS creates `l -> ""` instead). Skipped: nothing is created.
336    if target.is_empty() {
337        return Ok(SymlinkVerdict::Skip(EMPTY_SYMLINK_TARGET));
338    }
339    // Checked before the walk purely so the reason names symlinks — the
340    // walk's own `RootDir` arm would refuse it anyway.
341    if target_path.is_absolute() {
342        return Err(refuse(format!(
343            "symlink target `{}` is absolute",
344            crate::display::fmt_name(target)
345        )));
346    }
347
348    let parent = link_path.parent().unwrap_or(dest);
349    let rel = parent.strip_prefix(dest).unwrap_or(Path::new(""));
350
351    let mut out = PathBuf::new();
352    let mut pushed = false;
353    // `rel` came out of `safe_join`, so it holds only `Normal` components
354    // and cannot fail — walked rather than asserted so the depth it
355    // contributes is computed by the same code that consumes it.
356    walk_within(&mut out, &mut pushed, rel).map_err(|_| refuse(climbs_out_of_dest(target)))?;
357
358    // The depth walk BEFORE the shape rule: a target whose lexical walk
359    // leaves `dest` is a genuine escape (exit 7) whatever its shape, and
360    // must not be downgraded to a skip by a `..` after a name — `../..`,
361    // and `a/../../..` alike.
362    walk_within(&mut out, &mut pushed, target_path)
363        .map_err(|_| refuse(climbs_out_of_dest(target)))?;
364
365    // The shape rule: a `..` after a name is skipped even when it nets
366    // inside `dest`, because only the OS knows where the name before it
367    // leads.
368    let mut named = false;
369    for comp in target_path.components() {
370        match comp {
371            Component::Normal(_) => named = true,
372            Component::ParentDir if named => {
373                return Ok(SymlinkVerdict::Skip(CLIMBS_AFTER_A_NAME));
374            }
375            _ => {}
376        }
377    }
378    // No net-to-`dest` check here, deliberately: a target resolving to
379    // `dest` itself is contained.
380    Ok(SymlinkVerdict::Allowed)
381}
382
383/// Why a filesystem refused a NAME: the one definition of "the filesystem
384/// refuses this name", shared by extraction (which skips the entry) and the
385/// fuzz containment oracle (which cannot resolve such a link physically).
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387#[non_exhaustive]
388pub enum NameRefusal {
389    /// A component, or the whole path, is past the filesystem's limit
390    /// (`ENAMETOOLONG`).
391    TooLong,
392    /// The name is not valid on this filesystem (`EILSEQ`: APFS refuses a
393    /// name that is not valid Unicode in its required form).
394    InvalidOnThisFilesystem,
395}
396
397/// Whether `e` is the filesystem refusing a NAME, and why.
398///
399/// `ENAMETOOLONG` is recognised as `ErrorKind::InvalidFilename` (what `std`
400/// maps it to, stable since 1.87), with the raw errno as the fallback for a
401/// platform that reports it otherwise (63 on macOS and the BSDs, 36 on
402/// Linux). `EILSEQ` is matched by its raw number, since `std` maps it to no
403/// stable `ErrorKind`: 92 on macOS and iOS, 84 on Linux. An unknown platform
404/// matches no `EILSEQ`, so the error is `None` and the caller fails closed.
405#[must_use]
406pub fn name_refused_by_filesystem(e: &std::io::Error) -> Option<NameRefusal> {
407    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
408    const ENAMETOOLONG: i32 = 63;
409    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "freebsd")))]
410    const ENAMETOOLONG: i32 = 36;
411    #[cfg(any(target_os = "macos", target_os = "ios"))]
412    const EILSEQ: Option<i32> = Some(92);
413    #[cfg(target_os = "linux")]
414    const EILSEQ: Option<i32> = Some(84);
415    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
416    const EILSEQ: Option<i32> = None;
417    if e.kind() == std::io::ErrorKind::InvalidFilename || e.raw_os_error() == Some(ENAMETOOLONG) {
418        Some(NameRefusal::TooLong)
419    } else if EILSEQ.is_some() && e.raw_os_error() == EILSEQ {
420        Some(NameRefusal::InvalidOnThisFilesystem)
421    } else {
422        None
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    #[test]
429    fn the_filesystem_refusing_a_name_is_classified() {
430        use super::{NameRefusal, name_refused_by_filesystem};
431        use std::io::{Error as IoError, ErrorKind};
432        #[cfg(target_os = "macos")]
433        const EILSEQ: i32 = 92;
434        #[cfg(target_os = "linux")]
435        const EILSEQ: i32 = 84;
436        #[cfg(any(target_os = "macos", target_os = "linux"))]
437        assert_eq!(
438            name_refused_by_filesystem(&IoError::from_raw_os_error(EILSEQ)),
439            Some(NameRefusal::InvalidOnThisFilesystem)
440        );
441        assert_eq!(
442            name_refused_by_filesystem(&IoError::from(ErrorKind::InvalidFilename)),
443            Some(NameRefusal::TooLong)
444        );
445        assert_eq!(
446            name_refused_by_filesystem(&IoError::from(ErrorKind::PermissionDenied)),
447            None
448        );
449    }
450
451    use std::path::Path;
452
453    use crate::error::Error;
454
455    #[test]
456    fn absolute_traversal_and_escaping_symlinks_are_all_refused() {
457        let dest = Path::new("/tmp/out");
458        for (name, why) in [
459            ("../../etc/passwd", "traversal"),
460            ("a/../../b", "traversal"),
461            ("/etc/passwd", "absolute"),
462            ("", "empty"),
463        ] {
464            let err = super::safe_join(dest, name).expect_err("{name} must be refused");
465            match err {
466                Error::UnsafePath { path, .. } => assert_eq!(path, name),
467                other => panic!("expected UnsafePath for {name} ({why}), got {other:?}"),
468            }
469        }
470    }
471
472    #[test]
473    fn ordinary_nested_names_are_accepted_unchanged() {
474        let dest = Path::new("/tmp/out");
475        assert_eq!(
476            super::safe_join(dest, "a/b/c.txt").unwrap(),
477            dest.join("a/b/c.txt")
478        );
479        // A leading `./` is noise, not an attack.
480        assert_eq!(
481            super::safe_join(dest, "./a.txt").unwrap(),
482            dest.join("a.txt")
483        );
484    }
485
486    #[test]
487    fn refusal_never_rewrites_the_name_it_refuses() {
488        // The README's non-negotiable, asserted directly: there is no code path
489        // that returns a SANITISED PathBuf for a hostile entry. If sanitising were
490        // ever added, this test is what fails.
491        let dest = Path::new("/tmp/out");
492        assert!(super::safe_join(dest, "../x").is_err());
493        assert!(super::safe_join(dest, "/x").is_err());
494    }
495
496    #[test]
497    fn a_symlink_whose_target_escapes_the_destination_is_refused() {
498        let dest = Path::new("/tmp/out");
499        assert!(super::check_symlink_target(dest, &dest.join("link"), "../../etc/passwd").is_err());
500        assert!(super::check_symlink_target(dest, &dest.join("link"), "/etc/passwd").is_err());
501        assert!(super::check_symlink_target(dest, &dest.join("a/link"), "b.txt").is_ok());
502    }
503
504    #[test]
505    fn dot_only_and_dot_prefixed_names_are_ordinary_filenames_not_traversal() {
506        // Component classification is exact-string equality against "." and
507        // "..", not "contains dots": a name that is only three (or more)
508        // dots, or merely starts with `..`, is a valid, if unusual, filename
509        // on every filesystem this runs on — not a traversal variant. A
510        // naive `contains("..")` check would reject these as false
511        // positives; a naive strip-`..`-substrings sanitiser would corrupt
512        // them. Neither applies here — all three are accepted unchanged.
513        let dest = Path::new("/tmp/out");
514        assert_eq!(super::safe_join(dest, "...").unwrap(), dest.join("..."));
515        assert_eq!(
516            super::safe_join(dest, "a/....b").unwrap(),
517            dest.join("a/....b")
518        );
519        assert_eq!(super::safe_join(dest, "..foo").unwrap(), dest.join("..foo"));
520    }
521
522    #[test]
523    fn backslash_heavy_names_stay_a_single_contained_component() {
524        // `stuffr` builds for macOS/Linux (see CLAUDE.md); `\` is an ordinary
525        // filename byte there, not a separator, so a Windows-shaped entry
526        // name from a foreign archive cannot smuggle a traversal past this
527        // function on this platform — it becomes one odd-looking but fully
528        // contained filename.
529        let dest = Path::new("/tmp/out");
530        assert_eq!(
531            super::safe_join(dest, "..\\..\\etc\\passwd").unwrap(),
532            dest.join("..\\..\\etc\\passwd")
533        );
534    }
535
536    /// A name carrying a NUL is refused outright, and a symlink target
537    /// carrying one is too.
538    ///
539    /// **This test asserted the opposite for its first shape until the final
540    /// whole-branch review's F3**: `safe.txt\0..` used to be ACCEPTED and
541    /// joined verbatim, on the reasoning that a NUL-poisoned segment is
542    /// classified `Normal` rather than `ParentDir` (classification is
543    /// exact-string equality), so the injection buys an attacker no
544    /// traversal. That reasoning was and is correct — it is simply not the
545    /// whole question. The accepted path then reached `std::fs`, which
546    /// rejects an interior NUL as `InvalidInput`, and `stuffr unpack` ended
547    /// at **exit 1** — the code reserved for stuffr itself failing — on a
548    /// name the ARCHIVE chose. That was the only exit 1 in the review's
549    /// 31,460-run corruption sweep.
550    ///
551    /// A refusal is strictly stronger than the property this test used to
552    /// pin, so nothing is lost by the change: `walk_within` is never reached
553    /// with a NUL-bearing string from either public entry point any more,
554    /// because both refuse one first.
555    #[test]
556    fn a_nul_bearing_name_or_symlink_target_is_refused_before_the_filesystem_sees_it() {
557        let dest = Path::new("/tmp/out");
558        for name in ["safe.txt\0..", "a\0/../../etc/passwd", "zt\0ee\0a.txt"] {
559            match super::safe_join(dest, name) {
560                Err(Error::UnsafePath { path, reason }) => {
561                    assert_eq!(path, name);
562                    assert!(
563                        // The second shape holds a genuine `..` component as
564                        // well, and either refusal is correct for it — what
565                        // must never happen is an `Ok`.
566                        reason == "entry name contains a NUL byte"
567                            || reason == "path traversal above the destination",
568                        "unexpected reason for {name:?}: {reason}"
569                    );
570                }
571                other => panic!("expected UnsafePath for {name:?}, got {other:?}"),
572            }
573        }
574        // The sibling door: a symlink TARGET reaches `symlink(2)`, which
575        // rejects an interior NUL the same way `File::create` does.
576        match super::check_symlink_target(dest, &dest.join("sub/link"), "tar\0get") {
577            Err(Error::UnsafePath { path, reason }) => {
578                assert_eq!(path, "tar\0get");
579                assert_eq!(reason, "symlink target `tar\\0get` contains a NUL byte");
580                assert!(!reason.contains('\0'), "raw NUL leaked");
581            }
582            other => panic!("expected UnsafePath, got {other:?}"),
583        }
584    }
585
586    /// The Phase 2 final review's I1. `sub/top -> ..` is a symlink pointing
587    /// at the extraction root: contained, since `dest` is inside `dest`, and
588    /// both bsdtar and GNU tar extract it without comment. It was refused
589    /// (exit 7, aborting the whole extraction) because
590    /// `check_symlink_target` composed `sub/..` and handed it to
591    /// `safe_join`, inheriting a rule written for entry NAMES — and the path
592    /// the refusal named, `sub/..`, is not an entry in the archive, so there
593    /// was nothing for the user to go and look at.
594    #[test]
595    fn a_symlink_target_that_resolves_to_the_destination_itself_is_contained() {
596        let dest = Path::new("/tmp/out");
597        // One level deep, netting exactly to `dest`.
598        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
599        // Two levels deep, netting exactly to `dest`.
600        assert!(super::check_symlink_target(dest, &dest.join("a/b/top"), "../..").is_ok());
601        // (`../sub/..`, down and back up again, was accepted here until
602        // 0.10.1: a `..` after a name is now skipped whatever it nets to —
603        // see `a_symlink_target_climbing_after_a_name_is_skipped`.)
604        // A link directly under `dest` pointing at `dest`.
605        assert!(super::check_symlink_target(dest, &dest.join("here"), ".").is_ok());
606        // And the sibling shapes that already worked must keep working.
607        assert!(super::check_symlink_target(dest, &dest.join("sub/self"), "../sub").is_ok());
608    }
609
610    /// The regression the fix above could plausibly introduce, pinned
611    /// separately: accepting a net-to-`dest` TARGET must not widen into
612    /// accepting one that goes a single step further.
613    #[test]
614    fn a_symlink_target_one_step_past_the_destination_is_still_refused() {
615        let dest = Path::new("/tmp/out");
616        for (link, target) in [
617            (dest.join("sub/top"), "../.."),
618            (dest.join("top"), ".."),
619            (dest.join("a/b/top"), "../../.."),
620            (dest.join("sub/top"), "../../etc/passwd"),
621            (dest.join("sub/top"), "../sub/../.."),
622            (dest.join("sub/top"), "/etc/passwd"),
623        ] {
624            let err = super::check_symlink_target(dest, &link, target)
625                .expect_err("{target} from {link:?} must be refused");
626            match err {
627                // The refusal names the TARGET the archive actually
628                // contains, never the composed path — which the user has no
629                // way to find. That was half of I1.
630                Error::UnsafePath { path, .. } => assert_eq!(
631                    path, target,
632                    "the refusal must name the archive's own target string"
633                ),
634                other => panic!("expected UnsafePath for {target}, got {other:?}"),
635            }
636        }
637    }
638
639    /// 0.10.1: a `..` after a name is SKIPPED, wherever the walk would land
640    /// (user ruling 2026-10-07 — it was exit 7 until the final fix wave).
641    ///
642    /// Lexical normalisation reads `x/s2/..` as `x`, but the OS resolves
643    /// `x/s2` FIRST — and when `x/s2` is itself a symlink (`x/s2 -> ..`, an
644    /// ordinary contained link), the `..` climbs from wherever it points. A
645    /// tar holding `x/`, `x/s2 -> ..` and `s1 -> x/s2/..` unpacked at exit 0
646    /// and left `dest/s1` resolving to `dest/..`. Every shape here nets
647    /// INSIDE `dest` lexically, which is exactly why the depth walk alone
648    /// accepted them. The last is GNU tar's `--transform 's,^,pkg-1.0/,'`
649    /// rewrite of `bin/foo -> ../lib/foo`.
650    #[test]
651    fn a_symlink_target_climbing_after_a_name_is_skipped() {
652        let dest = Path::new("/tmp/out");
653        for (link, target) in [
654            (dest.join("s1"), "x/s2/.."),
655            (dest.join("s1"), "a/../b"),
656            (dest.join("s1"), "./a/.."),
657            (dest.join("sub/top"), "../sub/.."),
658            (dest.join("a/b/l"), "../../x/y/../z"),
659            (dest.join("s1"), "a/./.."),
660            (dest.join("pkg-1.0/bin/foo"), "pkg-1.0/../lib/foo"),
661        ] {
662            assert_eq!(
663                super::classify_symlink_target(dest, &link, "e", target).unwrap(),
664                super::SymlinkVerdict::Skip(super::CLIMBS_AFTER_A_NAME),
665                "{target} from {link:?}"
666            );
667            // The all-or-nothing wrapper still refuses it, naming the target.
668            match super::check_symlink_target(dest, &link, target) {
669                Err(Error::UnsafePath { path, reason }) => {
670                    assert_eq!(path, target);
671                    assert_eq!(reason, super::CLIMBS_AFTER_A_NAME, "{target}");
672                }
673                other => panic!("{target} from {link:?} must be refused, got {other:?}"),
674            }
675        }
676    }
677
678    /// The skip never swallows a genuine escape: a target that ALSO climbs
679    /// out of `dest` lexically is exit 7 whatever its shape, and every
680    /// refusal names the ENTRY (F2 of the 0.10.1 final review), never the
681    /// target text, which is not something the user can find with `list`.
682    #[test]
683    fn an_escape_is_refused_naming_the_entry_even_when_its_shape_would_skip() {
684        let dest = Path::new("/tmp/out");
685        for (link, target, reason) in [
686            (
687                dest.join("l"),
688                "a/../../..",
689                super::climbs_out_of_dest("a/../../.."),
690            ),
691            (
692                dest.join("sub/l"),
693                "../sub/../..",
694                super::climbs_out_of_dest("../sub/../.."),
695            ),
696            (
697                dest.join("sub/l"),
698                "../..",
699                super::climbs_out_of_dest("../.."),
700            ),
701            (
702                dest.join("sub/l"),
703                "../../x/../y",
704                super::climbs_out_of_dest("../../x/../y"),
705            ),
706            (
707                dest.join("l"),
708                "/etc/passwd",
709                "symlink target `/etc/passwd` is absolute".to_string(),
710            ),
711            (
712                dest.join("l"),
713                "a\0/..",
714                "symlink target `a\\0/..` contains a NUL byte".to_string(),
715            ),
716        ] {
717            match super::classify_symlink_target(dest, &link, "the/entry", target) {
718                Err(Error::UnsafePath { path, reason: r }) => {
719                    assert_eq!(path, "the/entry", "{target:?}");
720                    assert_eq!(r, reason, "{target:?}");
721                }
722                other => panic!("{target:?} from {link:?} must be refused, got {other:?}"),
723            }
724        }
725    }
726
727    /// The reason quotes the target, escaped the way entry names are: an
728    /// archive-supplied control character never reaches a terminal raw.
729    #[test]
730    fn a_quoted_target_is_escaped() {
731        let dest = Path::new("/tmp/out");
732        let target = "../\x1b`x/../..";
733        match super::classify_symlink_target(dest, &dest.join("l"), "e", target) {
734            Err(Error::UnsafePath { reason, .. }) => {
735                assert_eq!(
736                    reason,
737                    "symlink target `../\\x1b`x/../..` climbs out of the destination"
738                );
739                assert!(!reason.contains('\x1b'));
740            }
741            other => panic!("expected UnsafePath, got {other:?}"),
742        }
743    }
744
745    /// 0.10.1 fix round 1, then the final fix wave: an EMPTY target names
746    /// nothing and has no components, so the walk accepted it — and
747    /// `symlink(2)` on Linux answers ENOENT, which reached the CLI as
748    /// `Error::Io`, exit 1. (macOS creates `l -> ""` at exit 0.) Skipped.
749    #[test]
750    fn an_empty_symlink_target_is_skipped() {
751        let dest = Path::new("/tmp/out");
752        assert_eq!(
753            super::classify_symlink_target(dest, &dest.join("sub/l"), "sub/l", "").unwrap(),
754            super::SymlinkVerdict::Skip(super::EMPTY_SYMLINK_TARGET)
755        );
756        match super::check_symlink_target(dest, &dest.join("sub/l"), "") {
757            Err(Error::UnsafePath { path, reason }) => {
758                assert_eq!(path, "");
759                assert_eq!(reason, super::EMPTY_SYMLINK_TARGET);
760            }
761            other => panic!("the wrapper must still refuse it, got {other:?}"),
762        }
763    }
764
765    /// The over-strictness guard for the rule above: a LEADING `..` run
766    /// within the link's depth, plain names, and `.` anywhere stay accepted.
767    #[test]
768    fn a_leading_parent_run_and_plain_names_are_still_accepted() {
769        let dest = Path::new("/tmp/out");
770        for (link, target) in [
771            (dest.join("sub/top"), ".."),
772            (dest.join("sub/l"), "../a"),
773            (dest.join("a/b/l"), "../../a/b"),
774            (dest.join("a/b/l"), "./../.."),
775            (dest.join("l"), "a/b"),
776            (dest.join("l"), "./a"),
777            (dest.join("l"), "a/./b"),
778            (dest.join("l"), "a/b/."),
779            (dest.join("l"), "."),
780            (dest.join("l"), "..foo/...b"),
781        ] {
782            assert!(
783                super::check_symlink_target(dest, &link, target).is_ok(),
784                "{target} from {link:?} must be accepted"
785            );
786            assert_eq!(
787                super::classify_symlink_target(dest, &link, "e", target).unwrap(),
788                super::SymlinkVerdict::Allowed,
789                "{target} from {link:?}"
790            );
791        }
792    }
793
794    /// `safe_join`'s own net-to-destination rule is untouched by I1's fix:
795    /// an ENTRY NAME that pops back to the destination is still refused.
796    /// The two functions now disagree on purpose, and that is the fix.
797    #[test]
798    fn an_entry_name_that_nets_to_the_destination_is_still_refused_after_the_symlink_fix() {
799        let dest = Path::new("/tmp/out");
800        assert!(super::safe_join(dest, "sub/..").is_err());
801        assert!(super::safe_join(dest, "a/b/../..").is_err());
802        // While the same string as a symlink TARGET is fine.
803        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
804    }
805
806    #[test]
807    fn a_chain_of_parent_dirs_through_a_nested_symlink_is_refused() {
808        // The brief's own symlink test only exercises one level of nesting.
809        // A link two directories deep needs two `..` just to reach `dest`,
810        // so a target with three escapes past it — the composition Task 9
811        // actually relies on: `check_symlink_target` re-derives the correct
812        // depth from the link's OWN path rather than assuming a fixed one.
813        let dest = Path::new("/tmp/out");
814        assert!(super::check_symlink_target(dest, &dest.join("a/b/link"), "../../b.txt").is_ok());
815        assert!(
816            super::check_symlink_target(dest, &dest.join("a/b/link"), "../../../etc/passwd")
817                .is_err()
818        );
819    }
820
821    #[test]
822    fn dot_and_dot_slash_name_the_destination_itself() {
823        // `tar cf x.tar .` emits `./` as its first entry — the single most
824        // common way a tarball gets produced. Refusing it would refuse the
825        // majority of real-world archives at exit 7, a security refusal, on
826        // completely benign input. `.` and `./` cannot escape `dest`; they
827        // name it.
828        let dest = Path::new("/tmp/out");
829        assert_eq!(super::safe_join(dest, ".").unwrap(), dest);
830        assert_eq!(super::safe_join(dest, "./").unwrap(), dest);
831    }
832
833    #[test]
834    fn a_trailing_dot_resolves_within_its_parent_not_to_the_destination() {
835        let dest = Path::new("/tmp/out");
836        assert_eq!(super::safe_join(dest, "sub/.").unwrap(), dest.join("sub"));
837    }
838
839    #[test]
840    fn empty_name_is_still_refused_even_though_dot_is_now_accepted() {
841        // An empty string is malformed, not merely redundant with `.` — it
842        // stays refused with its own reason.
843        let dest = Path::new("/tmp/out");
844        assert!(super::safe_join(dest, "").is_err());
845    }
846
847    #[test]
848    fn traversal_that_nets_to_empty_is_still_refused_not_confused_with_naming_the_destination() {
849        // The regression this change could plausibly introduce: accepting
850        // `.` must not widen into accepting anything that merely RESOLVES to
851        // empty. `..`, `./..` and `a/../..` all attempt to pop past what was
852        // ever pushed — refused exactly as before, by the same
853        // pop-fails-immediately path this change did not touch.
854        let dest = Path::new("/tmp/out");
855        assert!(super::safe_join(dest, "..").is_err());
856        assert!(super::safe_join(dest, "./..").is_err());
857        assert!(super::safe_join(dest, "a/../..").is_err());
858        // `a/..` is the subtler case: it never pops past zero, so the old
859        // "pop fails" guard does not catch it — it is refused only because
860        // a real component was pushed and then popped away again, which is
861        // exactly the distinction `pushed_a_component` exists to draw.
862        assert!(super::safe_join(dest, "a/..").is_err());
863    }
864}