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",
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",
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,
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",
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.
179pub(crate) const CLIMBS_OUT_OF_DEST: &str = "symlink target climbs out of the destination";
180
181/// What extraction may do with one symlink entry, decided by
182/// [`classify_symlink_target`]. A genuine escape is not a verdict: it is
183/// `Err(Error::UnsafePath)`, exit 7.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185#[non_exhaustive]
186pub enum SymlinkVerdict {
187    /// Create the link: its target resolves inside the destination.
188    Allowed,
189    /// Do not create the link; name the entry in an
190    /// [`crate::Fidelity::EntrySkipped`] with this reason and carry on. The
191    /// target's SHAPE is one this extraction cannot prove contained (`..`
192    /// after a name) or names nothing (empty). Nothing is created, so
193    /// nothing can escape.
194    Skip(&'static str),
195}
196
197/// Refuses a symlink whose target would resolve outside `dest`.
198///
199/// The original, all-or-nothing form of [`classify_symlink_target`], kept
200/// for its callers: a [`SymlinkVerdict::Skip`] is reported here as
201/// `Error::UnsafePath` too, so anything this accepts is a link
202/// `classify_symlink_target` would create. The refusal names the TARGET,
203/// as it always has; extraction calls `classify_symlink_target`, which names
204/// the entry.
205pub fn check_symlink_target(dest: &Path, link_path: &Path, target: &str) -> Result<()> {
206    match classify_symlink_target(dest, link_path, target, target)? {
207        SymlinkVerdict::Allowed => Ok(()),
208        SymlinkVerdict::Skip(reason) => Err(Error::UnsafePath {
209            path: target.to_string(),
210            reason,
211        }),
212    }
213}
214
215/// Decides one symlink entry named `entry`, to be created at `link_path`
216/// (what `safe_join` produced for it) pointing at `target`: create it,
217/// skip it, or refuse the extraction. **The one owner of that rule** —
218/// extraction, the hard-link-to-a-symlink path and the fuzz oracle's
219/// lexical rule all ask it.
220///
221/// - `Err(Error::UnsafePath)`, exit 7, naming `entry`: a genuine escape. An
222///   absolute target, a `..` run that climbs deeper than the link's own
223///   depth below `dest` (lexically — checked before the shape, so a target
224///   that escapes is never merely skipped), or a NUL byte.
225/// - `Ok(Skip(reason))`: a SHAPE this extraction will not create — `..`
226///   after a name (`CLIMBS_AFTER_A_NAME`) or an empty target
227///   (`EMPTY_SYMLINK_TARGET`). Skipped, not refused (user ruling,
228///   2026-10-07): GNU tar's `--transform 's,^,pkg-1.0/,'` rewrites symlink
229///   targets as well as names, so `bin/foo -> ../lib/foo` arrives as
230///   `pkg-1.0/../lib/foo`, and aborting the whole unpack over it left a
231///   half-written tree where 0.10.0 and GNU tar extract everything.
232/// - `Ok(Allowed)`: anything else.
233///
234/// The link's own PATH can be perfectly contained while its target is not,
235/// and a later entry written through that link lands wherever it points.
236/// The target is resolved relative to the link's parent, which is how the
237/// OS will resolve it.
238///
239/// # Why this does not call `safe_join`
240///
241/// It used to: it composed `rel.join(target)` into a string and handed that
242/// to `safe_join`, which meant a symlink target inherited a refusal written
243/// for entry NAMES. `safe_join` refuses a name that nets back to `dest`
244/// after consuming a real component (`a/..`), on the reasoning that no real
245/// archiver emits one. That reasoning does not carry over: `sub/top -> ..`
246/// is an ordinary symlink pointing at the extraction root, which bsdtar and
247/// GNU tar both extract without comment, and `dest` is trivially inside
248/// `dest`. stuffr aborted the whole extraction at exit 7 — naming a path
249/// (`sub/..`) that is not an entry in the archive at all, so the user could
250/// not find it.
251///
252/// The component walk is shared (`walk_within`); only the net-to-`dest`
253/// verdict differs, which is the whole point. Every genuine escape is still
254/// refused by the same walk: an absolute target, and any `..` run deeper
255/// than the link's own depth below `dest`.
256///
257/// # `..` only as a leading run (0.10.1)
258///
259/// A target may hold `..` only as a LEADING prefix — `..`, `../..`,
260/// `../../a/b` — never after a name: `x/s2/..`, `a/../b` and `./a/..` are
261/// skipped (`CLIMBS_AFTER_A_NAME`) whatever they net to. `.` components
262/// are noise, exactly as `walk_within` treats them, so a leading `./` is
263/// harmless and `./..` is still a leading run. An EMPTY target is skipped
264/// too (`EMPTY_SYMLINK_TARGET`): it names nothing, and Linux's `symlink(2)`
265/// rejects it with ENOENT, which would otherwise surface as exit 1.
266///
267/// Why: lexical normalisation reads `x/s2/..` as `x`, but the OS resolves
268/// `x/s2` FIRST, and if `x/s2` is itself a symlink the `..` climbs from
269/// wherever IT points. A tar holding `x/`, `x/s2 -> ..` (contained: it names
270/// `dest`) and `s1 -> x/s2/..` unpacked at exit 0 with `dest/s1` resolving
271/// to `dest/..`. Entry order is no defence — `s1` can precede `s2`, so no
272/// creation-time existence check sees the link it climbs through. The rule
273/// is therefore about the target's SHAPE, and needs no filesystem.
274///
275/// # Why that is sound — by induction over the links a run creates
276///
277/// Only links that are CREATED have to obey the shape: a skipped link
278/// creates nothing, so it adds nothing to resolve through. Every link that
279/// is created sits at a path with no symlinked ancestor (the extraction
280/// refuses one before creating anything — `stuffr`'s
281/// `refuse_symlinked_ancestors` — and never removes a directory it would
282/// have to replace with a link), so the link's own directory is a REAL
283/// directory inside `dest`. Resolving its target from there:
284///
285/// 1. The leading `..` run climbs through real directories only, so its
286///    lexical depth check above IS its physical answer: it ends at a real
287///    directory inside `dest`, or is refused.
288/// 2. Every component after that run is a name (or `.`). A name either is a
289///    real directory entry — descending, so still inside `dest` — or is
290///    another link this run created, which by the induction hypothesis
291///    resolves inside `dest`, and the walk continues from there, again only
292///    by names.
293/// 3. No `..` ever follows a name, so the walk never climbs out of whatever
294///    a link handed it. Hence every created link resolves inside `dest`.
295///
296/// What the argument does not cover, by design: a symlink that was in
297/// `dest` before the run started, which a target could name through a plain
298/// component — that is the destination's owner's link, not the archive's —
299/// and a concurrent local writer, the check-then-use window
300/// `refuse_symlinked_ancestors` documents. The `container` fuzz target's
301/// oracle checks the result PHYSICALLY (`check_extraction_contained`), so a
302/// hole in this argument would be a finding there.
303pub fn classify_symlink_target(
304    dest: &Path,
305    link_path: &Path,
306    entry: &str,
307    target: &str,
308) -> Result<SymlinkVerdict> {
309    let refuse = |reason: &'static str| Error::UnsafePath {
310        path: entry.to_string(),
311        reason,
312    };
313    let target_path = Path::new(target);
314    // The sibling door to `safe_join`'s own NUL refusal, closed in the same
315    // change and for the same reason: `std::os::unix::fs::symlink` rejects
316    // an interior NUL as `InvalidInput`, so a symlink TARGET carrying one
317    // reached the identical `Error::Io`/exit-1 path an entry NAME did.
318    // Closing only the half the review measured would leave the class open
319    // through a door two lines away.
320    if target.contains('\0') {
321        return Err(refuse("symlink target contains a NUL byte"));
322    }
323    // An empty target names nothing and has no components, so every rule
324    // below would accept it — and `symlink(2)` on Linux answers ENOENT,
325    // which reached the CLI as `Error::Io`, exit 1, on a target the archive
326    // chose (macOS creates `l -> ""` instead). Skipped: nothing is created.
327    if target.is_empty() {
328        return Ok(SymlinkVerdict::Skip(EMPTY_SYMLINK_TARGET));
329    }
330    // Checked before the walk purely so the reason names symlinks — the
331    // walk's own `RootDir` arm would refuse it anyway.
332    if target_path.is_absolute() {
333        return Err(refuse("absolute symlink target"));
334    }
335
336    let parent = link_path.parent().unwrap_or(dest);
337    let rel = parent.strip_prefix(dest).unwrap_or(Path::new(""));
338
339    let mut out = PathBuf::new();
340    let mut pushed = false;
341    // `rel` came out of `safe_join`, so it holds only `Normal` components
342    // and cannot fail — walked rather than asserted so the depth it
343    // contributes is computed by the same code that consumes it.
344    walk_within(&mut out, &mut pushed, rel).map_err(|_| refuse(CLIMBS_OUT_OF_DEST))?;
345
346    // The depth walk BEFORE the shape rule: a target whose lexical walk
347    // leaves `dest` is a genuine escape (exit 7) whatever its shape, and
348    // must not be downgraded to a skip by a `..` after a name — `../..`,
349    // and `a/../../..` alike.
350    walk_within(&mut out, &mut pushed, target_path).map_err(|_| refuse(CLIMBS_OUT_OF_DEST))?;
351
352    // The shape rule: a `..` after a name is skipped even when it nets
353    // inside `dest`, because only the OS knows where the name before it
354    // leads.
355    let mut named = false;
356    for comp in target_path.components() {
357        match comp {
358            Component::Normal(_) => named = true,
359            Component::ParentDir if named => {
360                return Ok(SymlinkVerdict::Skip(CLIMBS_AFTER_A_NAME));
361            }
362            _ => {}
363        }
364    }
365    // No net-to-`dest` check here, deliberately: a target resolving to
366    // `dest` itself is contained.
367    Ok(SymlinkVerdict::Allowed)
368}
369
370/// Why a filesystem refused a NAME: the one definition of "the filesystem
371/// refuses this name", shared by extraction (which skips the entry) and the
372/// fuzz containment oracle (which cannot resolve such a link physically).
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374#[non_exhaustive]
375pub enum NameRefusal {
376    /// A component, or the whole path, is past the filesystem's limit
377    /// (`ENAMETOOLONG`).
378    TooLong,
379    /// The name is not valid on this filesystem (`EILSEQ`: APFS refuses a
380    /// name that is not valid Unicode in its required form).
381    InvalidOnThisFilesystem,
382}
383
384/// Whether `e` is the filesystem refusing a NAME, and why.
385///
386/// `ENAMETOOLONG` is recognised as `ErrorKind::InvalidFilename` (what `std`
387/// maps it to, stable since 1.87), with the raw errno as the fallback for a
388/// platform that reports it otherwise (63 on macOS and the BSDs, 36 on
389/// Linux). `EILSEQ` is matched by its raw number, since `std` maps it to no
390/// stable `ErrorKind`: 92 on macOS and iOS, 84 on Linux. An unknown platform
391/// matches no `EILSEQ`, so the error is `None` and the caller fails closed.
392#[must_use]
393pub fn name_refused_by_filesystem(e: &std::io::Error) -> Option<NameRefusal> {
394    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
395    const ENAMETOOLONG: i32 = 63;
396    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "freebsd")))]
397    const ENAMETOOLONG: i32 = 36;
398    #[cfg(any(target_os = "macos", target_os = "ios"))]
399    const EILSEQ: Option<i32> = Some(92);
400    #[cfg(target_os = "linux")]
401    const EILSEQ: Option<i32> = Some(84);
402    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
403    const EILSEQ: Option<i32> = None;
404    if e.kind() == std::io::ErrorKind::InvalidFilename || e.raw_os_error() == Some(ENAMETOOLONG) {
405        Some(NameRefusal::TooLong)
406    } else if EILSEQ.is_some() && e.raw_os_error() == EILSEQ {
407        Some(NameRefusal::InvalidOnThisFilesystem)
408    } else {
409        None
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    #[test]
416    fn the_filesystem_refusing_a_name_is_classified() {
417        use super::{NameRefusal, name_refused_by_filesystem};
418        use std::io::{Error as IoError, ErrorKind};
419        #[cfg(target_os = "macos")]
420        const EILSEQ: i32 = 92;
421        #[cfg(target_os = "linux")]
422        const EILSEQ: i32 = 84;
423        #[cfg(any(target_os = "macos", target_os = "linux"))]
424        assert_eq!(
425            name_refused_by_filesystem(&IoError::from_raw_os_error(EILSEQ)),
426            Some(NameRefusal::InvalidOnThisFilesystem)
427        );
428        assert_eq!(
429            name_refused_by_filesystem(&IoError::from(ErrorKind::InvalidFilename)),
430            Some(NameRefusal::TooLong)
431        );
432        assert_eq!(
433            name_refused_by_filesystem(&IoError::from(ErrorKind::PermissionDenied)),
434            None
435        );
436    }
437
438    use std::path::Path;
439
440    use crate::error::Error;
441
442    #[test]
443    fn absolute_traversal_and_escaping_symlinks_are_all_refused() {
444        let dest = Path::new("/tmp/out");
445        for (name, why) in [
446            ("../../etc/passwd", "traversal"),
447            ("a/../../b", "traversal"),
448            ("/etc/passwd", "absolute"),
449            ("", "empty"),
450        ] {
451            let err = super::safe_join(dest, name).expect_err("{name} must be refused");
452            match err {
453                Error::UnsafePath { path, .. } => assert_eq!(path, name),
454                other => panic!("expected UnsafePath for {name} ({why}), got {other:?}"),
455            }
456        }
457    }
458
459    #[test]
460    fn ordinary_nested_names_are_accepted_unchanged() {
461        let dest = Path::new("/tmp/out");
462        assert_eq!(
463            super::safe_join(dest, "a/b/c.txt").unwrap(),
464            dest.join("a/b/c.txt")
465        );
466        // A leading `./` is noise, not an attack.
467        assert_eq!(
468            super::safe_join(dest, "./a.txt").unwrap(),
469            dest.join("a.txt")
470        );
471    }
472
473    #[test]
474    fn refusal_never_rewrites_the_name_it_refuses() {
475        // The README's non-negotiable, asserted directly: there is no code path
476        // that returns a SANITISED PathBuf for a hostile entry. If sanitising were
477        // ever added, this test is what fails.
478        let dest = Path::new("/tmp/out");
479        assert!(super::safe_join(dest, "../x").is_err());
480        assert!(super::safe_join(dest, "/x").is_err());
481    }
482
483    #[test]
484    fn a_symlink_whose_target_escapes_the_destination_is_refused() {
485        let dest = Path::new("/tmp/out");
486        assert!(super::check_symlink_target(dest, &dest.join("link"), "../../etc/passwd").is_err());
487        assert!(super::check_symlink_target(dest, &dest.join("link"), "/etc/passwd").is_err());
488        assert!(super::check_symlink_target(dest, &dest.join("a/link"), "b.txt").is_ok());
489    }
490
491    #[test]
492    fn dot_only_and_dot_prefixed_names_are_ordinary_filenames_not_traversal() {
493        // Component classification is exact-string equality against "." and
494        // "..", not "contains dots": a name that is only three (or more)
495        // dots, or merely starts with `..`, is a valid, if unusual, filename
496        // on every filesystem this runs on — not a traversal variant. A
497        // naive `contains("..")` check would reject these as false
498        // positives; a naive strip-`..`-substrings sanitiser would corrupt
499        // them. Neither applies here — all three are accepted unchanged.
500        let dest = Path::new("/tmp/out");
501        assert_eq!(super::safe_join(dest, "...").unwrap(), dest.join("..."));
502        assert_eq!(
503            super::safe_join(dest, "a/....b").unwrap(),
504            dest.join("a/....b")
505        );
506        assert_eq!(super::safe_join(dest, "..foo").unwrap(), dest.join("..foo"));
507    }
508
509    #[test]
510    fn backslash_heavy_names_stay_a_single_contained_component() {
511        // `stuffr` builds for macOS/Linux (see CLAUDE.md); `\` is an ordinary
512        // filename byte there, not a separator, so a Windows-shaped entry
513        // name from a foreign archive cannot smuggle a traversal past this
514        // function on this platform — it becomes one odd-looking but fully
515        // contained filename.
516        let dest = Path::new("/tmp/out");
517        assert_eq!(
518            super::safe_join(dest, "..\\..\\etc\\passwd").unwrap(),
519            dest.join("..\\..\\etc\\passwd")
520        );
521    }
522
523    /// A name carrying a NUL is refused outright, and a symlink target
524    /// carrying one is too.
525    ///
526    /// **This test asserted the opposite for its first shape until the final
527    /// whole-branch review's F3**: `safe.txt\0..` used to be ACCEPTED and
528    /// joined verbatim, on the reasoning that a NUL-poisoned segment is
529    /// classified `Normal` rather than `ParentDir` (classification is
530    /// exact-string equality), so the injection buys an attacker no
531    /// traversal. That reasoning was and is correct — it is simply not the
532    /// whole question. The accepted path then reached `std::fs`, which
533    /// rejects an interior NUL as `InvalidInput`, and `stuffr unpack` ended
534    /// at **exit 1** — the code reserved for stuffr itself failing — on a
535    /// name the ARCHIVE chose. That was the only exit 1 in the review's
536    /// 31,460-run corruption sweep.
537    ///
538    /// A refusal is strictly stronger than the property this test used to
539    /// pin, so nothing is lost by the change: `walk_within` is never reached
540    /// with a NUL-bearing string from either public entry point any more,
541    /// because both refuse one first.
542    #[test]
543    fn a_nul_bearing_name_or_symlink_target_is_refused_before_the_filesystem_sees_it() {
544        let dest = Path::new("/tmp/out");
545        for name in ["safe.txt\0..", "a\0/../../etc/passwd", "zt\0ee\0a.txt"] {
546            match super::safe_join(dest, name) {
547                Err(Error::UnsafePath { path, reason }) => {
548                    assert_eq!(path, name);
549                    assert!(
550                        // The second shape holds a genuine `..` component as
551                        // well, and either refusal is correct for it — what
552                        // must never happen is an `Ok`.
553                        reason == "entry name contains a NUL byte"
554                            || reason == "path traversal above the destination",
555                        "unexpected reason for {name:?}: {reason}"
556                    );
557                }
558                other => panic!("expected UnsafePath for {name:?}, got {other:?}"),
559            }
560        }
561        // The sibling door: a symlink TARGET reaches `symlink(2)`, which
562        // rejects an interior NUL the same way `File::create` does.
563        match super::check_symlink_target(dest, &dest.join("sub/link"), "tar\0get") {
564            Err(Error::UnsafePath { path, reason }) => {
565                assert_eq!(path, "tar\0get");
566                assert_eq!(reason, "symlink target contains a NUL byte");
567            }
568            other => panic!("expected UnsafePath, got {other:?}"),
569        }
570    }
571
572    /// The Phase 2 final review's I1. `sub/top -> ..` is a symlink pointing
573    /// at the extraction root: contained, since `dest` is inside `dest`, and
574    /// both bsdtar and GNU tar extract it without comment. It was refused
575    /// (exit 7, aborting the whole extraction) because
576    /// `check_symlink_target` composed `sub/..` and handed it to
577    /// `safe_join`, inheriting a rule written for entry NAMES — and the path
578    /// the refusal named, `sub/..`, is not an entry in the archive, so there
579    /// was nothing for the user to go and look at.
580    #[test]
581    fn a_symlink_target_that_resolves_to_the_destination_itself_is_contained() {
582        let dest = Path::new("/tmp/out");
583        // One level deep, netting exactly to `dest`.
584        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
585        // Two levels deep, netting exactly to `dest`.
586        assert!(super::check_symlink_target(dest, &dest.join("a/b/top"), "../..").is_ok());
587        // (`../sub/..`, down and back up again, was accepted here until
588        // 0.10.1: a `..` after a name is now skipped whatever it nets to —
589        // see `a_symlink_target_climbing_after_a_name_is_skipped`.)
590        // A link directly under `dest` pointing at `dest`.
591        assert!(super::check_symlink_target(dest, &dest.join("here"), ".").is_ok());
592        // And the sibling shapes that already worked must keep working.
593        assert!(super::check_symlink_target(dest, &dest.join("sub/self"), "../sub").is_ok());
594    }
595
596    /// The regression the fix above could plausibly introduce, pinned
597    /// separately: accepting a net-to-`dest` TARGET must not widen into
598    /// accepting one that goes a single step further.
599    #[test]
600    fn a_symlink_target_one_step_past_the_destination_is_still_refused() {
601        let dest = Path::new("/tmp/out");
602        for (link, target) in [
603            (dest.join("sub/top"), "../.."),
604            (dest.join("top"), ".."),
605            (dest.join("a/b/top"), "../../.."),
606            (dest.join("sub/top"), "../../etc/passwd"),
607            (dest.join("sub/top"), "../sub/../.."),
608            (dest.join("sub/top"), "/etc/passwd"),
609        ] {
610            let err = super::check_symlink_target(dest, &link, target)
611                .expect_err("{target} from {link:?} must be refused");
612            match err {
613                // The refusal names the TARGET the archive actually
614                // contains, never the composed path — which the user has no
615                // way to find. That was half of I1.
616                Error::UnsafePath { path, .. } => assert_eq!(
617                    path, target,
618                    "the refusal must name the archive's own target string"
619                ),
620                other => panic!("expected UnsafePath for {target}, got {other:?}"),
621            }
622        }
623    }
624
625    /// 0.10.1: a `..` after a name is SKIPPED, wherever the walk would land
626    /// (user ruling 2026-10-07 — it was exit 7 until the final fix wave).
627    ///
628    /// Lexical normalisation reads `x/s2/..` as `x`, but the OS resolves
629    /// `x/s2` FIRST — and when `x/s2` is itself a symlink (`x/s2 -> ..`, an
630    /// ordinary contained link), the `..` climbs from wherever it points. A
631    /// tar holding `x/`, `x/s2 -> ..` and `s1 -> x/s2/..` unpacked at exit 0
632    /// and left `dest/s1` resolving to `dest/..`. Every shape here nets
633    /// INSIDE `dest` lexically, which is exactly why the depth walk alone
634    /// accepted them. The last is GNU tar's `--transform 's,^,pkg-1.0/,'`
635    /// rewrite of `bin/foo -> ../lib/foo`.
636    #[test]
637    fn a_symlink_target_climbing_after_a_name_is_skipped() {
638        let dest = Path::new("/tmp/out");
639        for (link, target) in [
640            (dest.join("s1"), "x/s2/.."),
641            (dest.join("s1"), "a/../b"),
642            (dest.join("s1"), "./a/.."),
643            (dest.join("sub/top"), "../sub/.."),
644            (dest.join("a/b/l"), "../../x/y/../z"),
645            (dest.join("s1"), "a/./.."),
646            (dest.join("pkg-1.0/bin/foo"), "pkg-1.0/../lib/foo"),
647        ] {
648            assert_eq!(
649                super::classify_symlink_target(dest, &link, "e", target).unwrap(),
650                super::SymlinkVerdict::Skip(super::CLIMBS_AFTER_A_NAME),
651                "{target} from {link:?}"
652            );
653            // The all-or-nothing wrapper still refuses it, naming the target.
654            match super::check_symlink_target(dest, &link, target) {
655                Err(Error::UnsafePath { path, reason }) => {
656                    assert_eq!(path, target);
657                    assert_eq!(reason, super::CLIMBS_AFTER_A_NAME, "{target}");
658                }
659                other => panic!("{target} from {link:?} must be refused, got {other:?}"),
660            }
661        }
662    }
663
664    /// The skip never swallows a genuine escape: a target that ALSO climbs
665    /// out of `dest` lexically is exit 7 whatever its shape, and every
666    /// refusal names the ENTRY (F2 of the 0.10.1 final review), never the
667    /// target text, which is not something the user can find with `list`.
668    #[test]
669    fn an_escape_is_refused_naming_the_entry_even_when_its_shape_would_skip() {
670        let dest = Path::new("/tmp/out");
671        for (link, target, reason) in [
672            (dest.join("l"), "a/../../..", super::CLIMBS_OUT_OF_DEST),
673            (
674                dest.join("sub/l"),
675                "../sub/../..",
676                super::CLIMBS_OUT_OF_DEST,
677            ),
678            (dest.join("sub/l"), "../..", super::CLIMBS_OUT_OF_DEST),
679            (
680                dest.join("sub/l"),
681                "../../x/../y",
682                super::CLIMBS_OUT_OF_DEST,
683            ),
684            (dest.join("l"), "/etc/passwd", "absolute symlink target"),
685            (
686                dest.join("l"),
687                "a\0/..",
688                "symlink target contains a NUL byte",
689            ),
690        ] {
691            match super::classify_symlink_target(dest, &link, "the/entry", target) {
692                Err(Error::UnsafePath { path, reason: r }) => {
693                    assert_eq!(path, "the/entry", "{target:?}");
694                    assert_eq!(r, reason, "{target:?}");
695                }
696                other => panic!("{target:?} from {link:?} must be refused, got {other:?}"),
697            }
698        }
699    }
700
701    /// 0.10.1 fix round 1, then the final fix wave: an EMPTY target names
702    /// nothing and has no components, so the walk accepted it — and
703    /// `symlink(2)` on Linux answers ENOENT, which reached the CLI as
704    /// `Error::Io`, exit 1. (macOS creates `l -> ""` at exit 0.) Skipped.
705    #[test]
706    fn an_empty_symlink_target_is_skipped() {
707        let dest = Path::new("/tmp/out");
708        assert_eq!(
709            super::classify_symlink_target(dest, &dest.join("sub/l"), "sub/l", "").unwrap(),
710            super::SymlinkVerdict::Skip(super::EMPTY_SYMLINK_TARGET)
711        );
712        match super::check_symlink_target(dest, &dest.join("sub/l"), "") {
713            Err(Error::UnsafePath { path, reason }) => {
714                assert_eq!(path, "");
715                assert_eq!(reason, super::EMPTY_SYMLINK_TARGET);
716            }
717            other => panic!("the wrapper must still refuse it, got {other:?}"),
718        }
719    }
720
721    /// The over-strictness guard for the rule above: a LEADING `..` run
722    /// within the link's depth, plain names, and `.` anywhere stay accepted.
723    #[test]
724    fn a_leading_parent_run_and_plain_names_are_still_accepted() {
725        let dest = Path::new("/tmp/out");
726        for (link, target) in [
727            (dest.join("sub/top"), ".."),
728            (dest.join("sub/l"), "../a"),
729            (dest.join("a/b/l"), "../../a/b"),
730            (dest.join("a/b/l"), "./../.."),
731            (dest.join("l"), "a/b"),
732            (dest.join("l"), "./a"),
733            (dest.join("l"), "a/./b"),
734            (dest.join("l"), "a/b/."),
735            (dest.join("l"), "."),
736            (dest.join("l"), "..foo/...b"),
737        ] {
738            assert!(
739                super::check_symlink_target(dest, &link, target).is_ok(),
740                "{target} from {link:?} must be accepted"
741            );
742            assert_eq!(
743                super::classify_symlink_target(dest, &link, "e", target).unwrap(),
744                super::SymlinkVerdict::Allowed,
745                "{target} from {link:?}"
746            );
747        }
748    }
749
750    /// `safe_join`'s own net-to-destination rule is untouched by I1's fix:
751    /// an ENTRY NAME that pops back to the destination is still refused.
752    /// The two functions now disagree on purpose, and that is the fix.
753    #[test]
754    fn an_entry_name_that_nets_to_the_destination_is_still_refused_after_the_symlink_fix() {
755        let dest = Path::new("/tmp/out");
756        assert!(super::safe_join(dest, "sub/..").is_err());
757        assert!(super::safe_join(dest, "a/b/../..").is_err());
758        // While the same string as a symlink TARGET is fine.
759        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
760    }
761
762    #[test]
763    fn a_chain_of_parent_dirs_through_a_nested_symlink_is_refused() {
764        // The brief's own symlink test only exercises one level of nesting.
765        // A link two directories deep needs two `..` just to reach `dest`,
766        // so a target with three escapes past it — the composition Task 9
767        // actually relies on: `check_symlink_target` re-derives the correct
768        // depth from the link's OWN path rather than assuming a fixed one.
769        let dest = Path::new("/tmp/out");
770        assert!(super::check_symlink_target(dest, &dest.join("a/b/link"), "../../b.txt").is_ok());
771        assert!(
772            super::check_symlink_target(dest, &dest.join("a/b/link"), "../../../etc/passwd")
773                .is_err()
774        );
775    }
776
777    #[test]
778    fn dot_and_dot_slash_name_the_destination_itself() {
779        // `tar cf x.tar .` emits `./` as its first entry — the single most
780        // common way a tarball gets produced. Refusing it would refuse the
781        // majority of real-world archives at exit 7, a security refusal, on
782        // completely benign input. `.` and `./` cannot escape `dest`; they
783        // name it.
784        let dest = Path::new("/tmp/out");
785        assert_eq!(super::safe_join(dest, ".").unwrap(), dest);
786        assert_eq!(super::safe_join(dest, "./").unwrap(), dest);
787    }
788
789    #[test]
790    fn a_trailing_dot_resolves_within_its_parent_not_to_the_destination() {
791        let dest = Path::new("/tmp/out");
792        assert_eq!(super::safe_join(dest, "sub/.").unwrap(), dest.join("sub"));
793    }
794
795    #[test]
796    fn empty_name_is_still_refused_even_though_dot_is_now_accepted() {
797        // An empty string is malformed, not merely redundant with `.` — it
798        // stays refused with its own reason.
799        let dest = Path::new("/tmp/out");
800        assert!(super::safe_join(dest, "").is_err());
801    }
802
803    #[test]
804    fn traversal_that_nets_to_empty_is_still_refused_not_confused_with_naming_the_destination() {
805        // The regression this change could plausibly introduce: accepting
806        // `.` must not widen into accepting anything that merely RESOLVES to
807        // empty. `..`, `./..` and `a/../..` all attempt to pop past what was
808        // ever pushed — refused exactly as before, by the same
809        // pop-fails-immediately path this change did not touch.
810        let dest = Path::new("/tmp/out");
811        assert!(super::safe_join(dest, "..").is_err());
812        assert!(super::safe_join(dest, "./..").is_err());
813        assert!(super::safe_join(dest, "a/../..").is_err());
814        // `a/..` is the subtler case: it never pops past zero, so the old
815        // "pop fails" guard does not catch it — it is refused only because
816        // a real component was pushed and then popped away again, which is
817        // exactly the distinction `pushed_a_component` exists to draw.
818        assert!(super::safe_join(dest, "a/..").is_err());
819    }
820}