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    // EXTRACT-6: on Windows a component can be accepted above and still not
99    // name the file it spells — `a:b` writes an NTFS stream of `a`,
100    // `CON.txt` opens the console, `x.` and `x ` are stored as `x`. Refused
101    // there as the escapes they are; the predicate is tested everywhere.
102    #[cfg(windows)]
103    for comp in Path::new(entry_name).components() {
104        if let Component::Normal(part) = comp
105            && let Some(why) = part.to_str().and_then(windows_name_hazard)
106        {
107            return Err(Error::UnsafePath {
108                path: entry_name.to_string(),
109                reason: format!(
110                    "component `{}` {why}",
111                    crate::display::fmt_name(part.to_str().unwrap_or_default())
112                ),
113            });
114        }
115    }
116
117    if out.as_os_str().is_empty() {
118        return if pushed_a_component {
119            // `a/..`, `a/b/../..`: a real component was consumed and then
120            // popped away again. Never produced by a real archiver (which
121            // emits bare `.`/`./` for the root, not this), so refusing it
122            // costs nothing in compatibility and keeps the traversal check
123            // from being second-guessed by a coincidental net-zero.
124            //
125            // This rule is about ENTRY NAMES specifically, which is why
126            // `check_symlink_target` below does not go through this
127            // function any more — see its own doc.
128            Err(Error::UnsafePath {
129                path: entry_name.to_string(),
130                reason: "path traversal nets back to the destination".to_string(),
131            })
132        } else {
133            // `.` or `./`: nothing was ever pushed, so nothing was popped
134            // away either — this names `dest` itself, not an escape from it.
135            Ok(dest.to_path_buf())
136        };
137    }
138    Ok(dest.join(out))
139}
140
141/// Walks `path`'s components onto `out`, popping for each `..`.
142///
143/// The single component-classification loop both public functions share, so
144/// there is exactly ONE place that decides what `..`, `.`, a root and a
145/// normal component mean. Returns the refusal `reason` rather than a full
146/// `Error`, so each caller builds its own: `safe_join` names the entry with
147/// this reason, and `classify_symlink_target` replaces it with
148/// `CLIMBS_OUT_OF_DEST` and names the ENTRY too (0.10.1) — never the composed
149/// string, which is not something the user can find in their archive.
150///
151/// Operates on `Path` components rather than a re-joined string, so nothing
152/// round-trips through `to_string_lossy` on the way. A lossy conversion was
153/// never a bypass here (`.`, `..` and `/` are ASCII and survive it) but it
154/// could mangle a non-UTF-8 target into a DIFFERENT contained name, and
155/// there is no reason to keep it now that the composition is component-wise.
156fn walk_within(
157    out: &mut PathBuf,
158    pushed_a_component: &mut bool,
159    path: &Path,
160) -> std::result::Result<(), &'static str> {
161    for comp in path.components() {
162        match comp {
163            // An absolute path or a Windows drive prefix ignores `dest`
164            // entirely — the classic "tar bomb writes to /etc" shape.
165            Component::RootDir | Component::Prefix(_) => return Err("absolute path"),
166            // `./` is noise, not an attack.
167            Component::CurDir => {}
168            // Pop only within what we have accumulated. `a/../b` is fine;
169            // `../b` and `a/../../b` are not. Comparing against the
170            // accumulated depth rather than canonicalising is what keeps this
171            // filesystem-independent — and immune to a symlink that appears
172            // between the check and the write.
173            Component::ParentDir => {
174                if !out.pop() {
175                    return Err("path traversal above the destination");
176                }
177            }
178            Component::Normal(part) => {
179                out.push(part);
180                *pushed_a_component = true;
181            }
182        }
183    }
184    Ok(())
185}
186
187/// Why one path component cannot be a Windows file name that means what it
188/// says, or `None` when it can (finding EXTRACT-6).
189///
190/// A pure predicate on the component's text, tested on every platform;
191/// [`safe_join`] applies it on Windows only, as an [`Error::UnsafePath`]
192/// refusal (exit 7). Elsewhere these names are ordinary files and are
193/// extracted as such. Flagged:
194///
195/// - **`:`** — `name:stream` writes an NTFS alternate data stream of `name`
196///   (and `C:x` is drive-relative); the file the archive names never exists.
197/// - **A reserved device name** — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
198///   `CONOUT$`, `COM0`-`COM9`, `LPT0`-`LPT9` and `COM`/`LPT` with a
199///   superscript `¹` `²` `³`, in any case, alone or before any extension
200///   (`nul.tar.gz`), trailing spaces before the extension included
201///   (`CON .txt`): opening one opens the device, not a file.
202/// - **A trailing `.` or space** — Win32 strips them, so `x.` and `x `
203///   land on `x`, another entry's file.
204/// - **`<` `>` `"` `|` `?` `*`, or a control character** — names Windows
205///   refuses outright; refused here with a reason rather than left to
206///   surface as an I/O error.
207#[must_use]
208pub fn windows_name_hazard(component: &str) -> Option<&'static str> {
209    if component.contains(':') {
210        return Some("holds `:`, which Windows reads as an alternate data stream or a drive");
211    }
212    if component
213        .chars()
214        .any(|c| matches!(c, '<' | '>' | '"' | '|' | '?' | '*') || u32::from(c) < 0x20)
215    {
216        return Some("holds a character Windows does not allow in a file name");
217    }
218    if component.ends_with('.') || component.ends_with(' ') {
219        return Some("ends in a dot or a space, which Windows strips from a file name");
220    }
221    let stem = component
222        .split('.')
223        .next()
224        .unwrap_or(component)
225        .trim_end_matches(' ');
226    let reserved = ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"]
227        .iter()
228        .any(|r| stem.eq_ignore_ascii_case(r));
229    let numbered = ["COM", "LPT"].iter().any(|p| {
230        stem.get(..3)
231            .is_some_and(|head| head.eq_ignore_ascii_case(p))
232            && matches!(
233                stem.get(3..),
234                Some("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³")
235            )
236    });
237    (reserved || numbered).then_some("is a reserved Windows device name")
238}
239
240/// [`classify_symlink_target`]'s skip reason for an empty target.
241pub(crate) const EMPTY_SYMLINK_TARGET: &str = "symlink target is empty";
242
243/// [`classify_symlink_target`]'s skip reason for a target holding `..` after
244/// a name.
245pub(crate) const CLIMBS_AFTER_A_NAME: &str =
246    "symlink target uses `..` after a name, which can climb out through another link";
247
248/// [`classify_symlink_target`]'s refusal for a leading `..` run that climbs
249/// past the destination.
250/// Quotes the target, escaped the way entry names are in messages.
251pub(crate) fn climbs_out_of_dest(target: &str) -> String {
252    format!(
253        "symlink target `{}` climbs out of the destination",
254        crate::display::fmt_name(target)
255    )
256}
257
258/// What extraction may do with one symlink entry, decided by
259/// [`classify_symlink_target`]. A genuine escape is not a verdict: it is
260/// `Err(Error::UnsafePath)`, exit 7.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262#[non_exhaustive]
263pub enum SymlinkVerdict {
264    /// Create the link: its target resolves inside the destination.
265    Allowed,
266    /// Do not create the link; name the entry in an
267    /// [`crate::Fidelity::EntrySkipped`] with this reason and carry on. The
268    /// target's SHAPE is one this extraction cannot prove contained (`..`
269    /// after a name) or names nothing (empty). Nothing is created, so
270    /// nothing can escape.
271    Skip(&'static str),
272}
273
274/// Refuses a symlink whose target would resolve outside `dest`.
275///
276/// The original, all-or-nothing form of [`classify_symlink_target`], kept
277/// for its callers: a [`SymlinkVerdict::Skip`] is reported here as
278/// `Error::UnsafePath` too, so anything this accepts is a link
279/// `classify_symlink_target` would create. The refusal names the TARGET,
280/// as it always has; extraction calls `classify_symlink_target`, which names
281/// the entry.
282pub fn check_symlink_target(dest: &Path, link_path: &Path, target: &str) -> Result<()> {
283    match classify_symlink_target(dest, link_path, target, target)? {
284        SymlinkVerdict::Allowed => Ok(()),
285        SymlinkVerdict::Skip(reason) => Err(Error::UnsafePath {
286            path: target.to_string(),
287            reason: reason.to_string(),
288        }),
289    }
290}
291
292/// Decides one symlink entry named `entry`, to be created at `link_path`
293/// (what `safe_join` produced for it) pointing at `target`: create it,
294/// skip it, or refuse the extraction. **The one owner of that rule** —
295/// extraction, the hard-link-to-a-symlink path and the fuzz oracle's
296/// lexical rule all ask it.
297///
298/// - `Err(Error::UnsafePath)`, exit 7, naming `entry`: a genuine escape. An
299///   absolute target, a `..` run that climbs deeper than the link's own
300///   depth below `dest` (lexically — checked before the shape, so a target
301///   that escapes is never merely skipped), or a NUL byte.
302/// - `Ok(Skip(reason))`: a SHAPE this extraction will not create — `..`
303///   after a name (`CLIMBS_AFTER_A_NAME`) or an empty target
304///   (`EMPTY_SYMLINK_TARGET`). Skipped, not refused (user ruling,
305///   2026-10-07): GNU tar's `--transform 's,^,pkg-1.0/,'` rewrites symlink
306///   targets as well as names, so `bin/foo -> ../lib/foo` arrives as
307///   `pkg-1.0/../lib/foo`, and aborting the whole unpack over it left a
308///   half-written tree where 0.10.0 and GNU tar extract everything.
309/// - `Ok(Allowed)`: anything else.
310///
311/// The link's own PATH can be perfectly contained while its target is not,
312/// and a later entry written through that link lands wherever it points.
313/// The target is resolved relative to the link's parent, which is how the
314/// OS will resolve it.
315///
316/// # Why this does not call `safe_join`
317///
318/// It used to: it composed `rel.join(target)` into a string and handed that
319/// to `safe_join`, which meant a symlink target inherited a refusal written
320/// for entry NAMES. `safe_join` refuses a name that nets back to `dest`
321/// after consuming a real component (`a/..`), on the reasoning that no real
322/// archiver emits one. That reasoning does not carry over: `sub/top -> ..`
323/// is an ordinary symlink pointing at the extraction root, which bsdtar and
324/// GNU tar both extract without comment, and `dest` is trivially inside
325/// `dest`. stuffr aborted the whole extraction at exit 7 — naming a path
326/// (`sub/..`) that is not an entry in the archive at all, so the user could
327/// not find it.
328///
329/// The component walk is shared (`walk_within`); only the net-to-`dest`
330/// verdict differs, which is the whole point. Every genuine escape is still
331/// refused by the same walk: an absolute target, and any `..` run deeper
332/// than the link's own depth below `dest`.
333///
334/// # `..` only as a leading run (0.10.1)
335///
336/// A target may hold `..` only as a LEADING prefix — `..`, `../..`,
337/// `../../a/b` — never after a name: `x/s2/..`, `a/../b` and `./a/..` are
338/// skipped (`CLIMBS_AFTER_A_NAME`) whatever they net to. `.` components
339/// are noise, exactly as `walk_within` treats them, so a leading `./` is
340/// harmless and `./..` is still a leading run. An EMPTY target is skipped
341/// too (`EMPTY_SYMLINK_TARGET`): it names nothing, and Linux's `symlink(2)`
342/// rejects it with ENOENT, which would otherwise surface as exit 1.
343///
344/// Why: lexical normalisation reads `x/s2/..` as `x`, but the OS resolves
345/// `x/s2` FIRST, and if `x/s2` is itself a symlink the `..` climbs from
346/// wherever IT points. A tar holding `x/`, `x/s2 -> ..` (contained: it names
347/// `dest`) and `s1 -> x/s2/..` unpacked at exit 0 with `dest/s1` resolving
348/// to `dest/..`. Entry order is no defence — `s1` can precede `s2`, so no
349/// creation-time existence check sees the link it climbs through. The rule
350/// is therefore about the target's SHAPE, and needs no filesystem.
351///
352/// # Why that is sound — by induction over the links a run creates
353///
354/// Only links that are CREATED have to obey the shape: a skipped link
355/// creates nothing, so it adds nothing to resolve through. Every link that
356/// is created sits at a path with no symlinked ancestor (the extraction
357/// refuses one before creating anything — `stuffr`'s
358/// `refuse_symlinked_ancestors` — and never removes a directory it would
359/// have to replace with a link), so the link's own directory is a REAL
360/// directory inside `dest`. Resolving its target from there:
361///
362/// 1. The leading `..` run climbs through real directories only, so its
363///    lexical depth check above IS its physical answer: it ends at a real
364///    directory inside `dest`, or is refused.
365/// 2. Every component after that run is a name (or `.`). A name either is a
366///    real directory entry — descending, so still inside `dest` — or is
367///    another link this run created, which by the induction hypothesis
368///    resolves inside `dest`, and the walk continues from there, again only
369///    by names.
370/// 3. No `..` ever follows a name, so the walk never climbs out of whatever
371///    a link handed it. Hence every created link resolves inside `dest`.
372///
373/// What the argument does not cover, by design: a symlink that was in
374/// `dest` before the run started, which a target could name through a plain
375/// component — that is the destination's owner's link, not the archive's —
376/// and a concurrent local writer, the check-then-use window
377/// `refuse_symlinked_ancestors` documents. The `container` fuzz target's
378/// oracle checks the result PHYSICALLY (`check_extraction_contained`), so a
379/// hole in this argument would be a finding there.
380pub fn classify_symlink_target(
381    dest: &Path,
382    link_path: &Path,
383    entry: &str,
384    target: &str,
385) -> Result<SymlinkVerdict> {
386    let refuse = |reason: String| Error::UnsafePath {
387        path: entry.to_string(),
388        reason,
389    };
390    let target_path = Path::new(target);
391    // The sibling door to `safe_join`'s own NUL refusal, closed in the same
392    // change and for the same reason: `std::os::unix::fs::symlink` rejects
393    // an interior NUL as `InvalidInput`, so a symlink TARGET carrying one
394    // reached the identical `Error::Io`/exit-1 path an entry NAME did.
395    // Closing only the half the review measured would leave the class open
396    // through a door two lines away.
397    if target.contains('\0') {
398        return Err(refuse(format!(
399            "symlink target `{}` contains a NUL byte",
400            crate::display::fmt_name(target)
401        )));
402    }
403    // An empty target names nothing and has no components, so every rule
404    // below would accept it — and `symlink(2)` on Linux answers ENOENT,
405    // which reached the CLI as `Error::Io`, exit 1, on a target the archive
406    // chose (macOS creates `l -> ""` instead). Skipped: nothing is created.
407    if target.is_empty() {
408        return Ok(SymlinkVerdict::Skip(EMPTY_SYMLINK_TARGET));
409    }
410    // Checked before the walk purely so the reason names symlinks — the
411    // walk's own `RootDir` arm would refuse it anyway.
412    if target_path.is_absolute() {
413        return Err(refuse(format!(
414            "symlink target `{}` is absolute",
415            crate::display::fmt_name(target)
416        )));
417    }
418
419    let parent = link_path.parent().unwrap_or(dest);
420    let rel = parent.strip_prefix(dest).unwrap_or(Path::new(""));
421
422    let mut out = PathBuf::new();
423    let mut pushed = false;
424    // `rel` came out of `safe_join`, so it holds only `Normal` components
425    // and cannot fail — walked rather than asserted so the depth it
426    // contributes is computed by the same code that consumes it.
427    walk_within(&mut out, &mut pushed, rel).map_err(|_| refuse(climbs_out_of_dest(target)))?;
428
429    // The depth walk BEFORE the shape rule: a target whose lexical walk
430    // leaves `dest` is a genuine escape (exit 7) whatever its shape, and
431    // must not be downgraded to a skip by a `..` after a name — `../..`,
432    // and `a/../../..` alike.
433    walk_within(&mut out, &mut pushed, target_path)
434        .map_err(|_| refuse(climbs_out_of_dest(target)))?;
435
436    // The shape rule: a `..` after a name is skipped even when it nets
437    // inside `dest`, because only the OS knows where the name before it
438    // leads.
439    let mut named = false;
440    for comp in target_path.components() {
441        match comp {
442            Component::Normal(_) => named = true,
443            Component::ParentDir if named => {
444                return Ok(SymlinkVerdict::Skip(CLIMBS_AFTER_A_NAME));
445            }
446            _ => {}
447        }
448    }
449    // No net-to-`dest` check here, deliberately: a target resolving to
450    // `dest` itself is contained.
451    Ok(SymlinkVerdict::Allowed)
452}
453
454/// Why a filesystem refused a NAME: the one definition of "the filesystem
455/// refuses this name", shared by extraction (which skips the entry) and the
456/// fuzz containment oracle (which cannot resolve such a link physically).
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458#[non_exhaustive]
459pub enum NameRefusal {
460    /// A component, or the whole path, is past the filesystem's limit
461    /// (`ENAMETOOLONG`).
462    TooLong,
463    /// The name is not valid on this filesystem (`EILSEQ`: APFS refuses a
464    /// name that is not valid Unicode in its required form).
465    InvalidOnThisFilesystem,
466}
467
468/// Whether `e` is the filesystem refusing a NAME, and why.
469///
470/// `ENAMETOOLONG` is recognised as `ErrorKind::InvalidFilename` (what `std`
471/// maps it to, stable since 1.87), with the raw errno as the fallback for a
472/// platform that reports it otherwise (63 on macOS and the BSDs, 36 on
473/// Linux). `EILSEQ` is matched by its raw number, since `std` maps it to no
474/// stable `ErrorKind`: 92 on macOS and iOS, 84 on Linux. An unknown platform
475/// matches no `EILSEQ`, so the error is `None` and the caller fails closed.
476#[must_use]
477pub fn name_refused_by_filesystem(e: &std::io::Error) -> Option<NameRefusal> {
478    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
479    const ENAMETOOLONG: i32 = 63;
480    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "freebsd")))]
481    const ENAMETOOLONG: i32 = 36;
482    #[cfg(any(target_os = "macos", target_os = "ios"))]
483    const EILSEQ: Option<i32> = Some(92);
484    #[cfg(target_os = "linux")]
485    const EILSEQ: Option<i32> = Some(84);
486    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
487    const EILSEQ: Option<i32> = None;
488    if e.kind() == std::io::ErrorKind::InvalidFilename || e.raw_os_error() == Some(ENAMETOOLONG) {
489        Some(NameRefusal::TooLong)
490    } else if EILSEQ.is_some() && e.raw_os_error() == EILSEQ {
491        Some(NameRefusal::InvalidOnThisFilesystem)
492    } else {
493        None
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    #[test]
500    fn the_filesystem_refusing_a_name_is_classified() {
501        use super::{NameRefusal, name_refused_by_filesystem};
502        use std::io::{Error as IoError, ErrorKind};
503        #[cfg(target_os = "macos")]
504        const EILSEQ: i32 = 92;
505        #[cfg(target_os = "linux")]
506        const EILSEQ: i32 = 84;
507        #[cfg(any(target_os = "macos", target_os = "linux"))]
508        assert_eq!(
509            name_refused_by_filesystem(&IoError::from_raw_os_error(EILSEQ)),
510            Some(NameRefusal::InvalidOnThisFilesystem)
511        );
512        assert_eq!(
513            name_refused_by_filesystem(&IoError::from(ErrorKind::InvalidFilename)),
514            Some(NameRefusal::TooLong)
515        );
516        assert_eq!(
517            name_refused_by_filesystem(&IoError::from(ErrorKind::PermissionDenied)),
518            None
519        );
520    }
521
522    use std::path::Path;
523
524    use crate::error::Error;
525
526    #[test]
527    fn windows_name_hazards_are_flagged_on_every_platform() {
528        use super::windows_name_hazard as flagged;
529        for bad in [
530            "a:b",
531            "file.txt:hidden",
532            "C:",
533            "CON",
534            "con",
535            "Con.txt",
536            "nul.tar.gz",
537            "NUL ",
538            "CON .txt",
539            "aux.c",
540            "PRN",
541            "conin$",
542            "CONOUT$.log",
543            "COM0",
544            "com1",
545            "COM9.txt",
546            "lpt1",
547            "LPT9.dat",
548            "COM\u{b9}",
549            "lpt\u{b2}.txt",
550            "COM\u{b3}",
551            "x.",
552            "x ",
553            "dir...",
554            "a<b",
555            "a>b",
556            "a\"b",
557            "a|b",
558            "a?b",
559            "a*b",
560            "a\u{1}b",
561            "tab\there",
562        ] {
563            assert!(flagged(bad).is_some(), "{bad:?} should be flagged");
564        }
565        for good in [
566            "a",
567            "file.txt",
568            ".hidden",
569            "CONFIG",
570            "console.log",
571            "nullable.rs",
572            "auxiliary",
573            "COM10",
574            "COM",
575            "LPT",
576            "LPTX",
577            "com1x",
578            "x.CON",
579            "my CON.txt",
580            "COM\u{b4}",
581            "naïve 日本",
582            "a b",
583        ] {
584            assert_eq!(flagged(good), None, "{good:?} should pass");
585        }
586    }
587
588    /// On Windows those components are refused by `safe_join` (exit 7);
589    /// elsewhere they are ordinary names and are joined.
590    #[test]
591    fn safe_join_applies_the_windows_predicate_only_on_windows() {
592        let dest = Path::new("out");
593        for name in ["d/a:b", "CON.txt", "d/nul", "x.", "dir/x /f"] {
594            let r = super::safe_join(dest, name);
595            if cfg!(windows) {
596                match r {
597                    Err(Error::UnsafePath { path, reason }) => {
598                        assert_eq!(path, name);
599                        assert!(reason.starts_with("component `"), "{reason}");
600                    }
601                    other => panic!("expected UnsafePath for {name:?}, got {other:?}"),
602                }
603            } else {
604                assert_eq!(r.unwrap(), dest.join(name));
605            }
606        }
607    }
608
609    #[test]
610    fn absolute_traversal_and_escaping_symlinks_are_all_refused() {
611        let dest = Path::new("/tmp/out");
612        for (name, why) in [
613            ("../../etc/passwd", "traversal"),
614            ("a/../../b", "traversal"),
615            ("/etc/passwd", "absolute"),
616            ("", "empty"),
617        ] {
618            let err = super::safe_join(dest, name).expect_err("{name} must be refused");
619            match err {
620                Error::UnsafePath { path, .. } => assert_eq!(path, name),
621                other => panic!("expected UnsafePath for {name} ({why}), got {other:?}"),
622            }
623        }
624    }
625
626    #[test]
627    fn ordinary_nested_names_are_accepted_unchanged() {
628        let dest = Path::new("/tmp/out");
629        assert_eq!(
630            super::safe_join(dest, "a/b/c.txt").unwrap(),
631            dest.join("a/b/c.txt")
632        );
633        // A leading `./` is noise, not an attack.
634        assert_eq!(
635            super::safe_join(dest, "./a.txt").unwrap(),
636            dest.join("a.txt")
637        );
638    }
639
640    #[test]
641    fn refusal_never_rewrites_the_name_it_refuses() {
642        // The README's non-negotiable, asserted directly: there is no code path
643        // that returns a SANITISED PathBuf for a hostile entry. If sanitising were
644        // ever added, this test is what fails.
645        let dest = Path::new("/tmp/out");
646        assert!(super::safe_join(dest, "../x").is_err());
647        assert!(super::safe_join(dest, "/x").is_err());
648    }
649
650    #[test]
651    fn a_symlink_whose_target_escapes_the_destination_is_refused() {
652        let dest = Path::new("/tmp/out");
653        assert!(super::check_symlink_target(dest, &dest.join("link"), "../../etc/passwd").is_err());
654        assert!(super::check_symlink_target(dest, &dest.join("link"), "/etc/passwd").is_err());
655        assert!(super::check_symlink_target(dest, &dest.join("a/link"), "b.txt").is_ok());
656    }
657
658    #[test]
659    fn dot_only_and_dot_prefixed_names_are_ordinary_filenames_not_traversal() {
660        // Component classification is exact-string equality against "." and
661        // "..", not "contains dots": a name that is only three (or more)
662        // dots, or merely starts with `..`, is a valid, if unusual, filename
663        // on every filesystem this runs on — not a traversal variant. A
664        // naive `contains("..")` check would reject these as false
665        // positives; a naive strip-`..`-substrings sanitiser would corrupt
666        // them. Neither applies here — all three are accepted unchanged.
667        let dest = Path::new("/tmp/out");
668        assert_eq!(super::safe_join(dest, "...").unwrap(), dest.join("..."));
669        assert_eq!(
670            super::safe_join(dest, "a/....b").unwrap(),
671            dest.join("a/....b")
672        );
673        assert_eq!(super::safe_join(dest, "..foo").unwrap(), dest.join("..foo"));
674    }
675
676    #[test]
677    fn backslash_heavy_names_stay_a_single_contained_component() {
678        // `stuffr` builds for macOS/Linux (see CLAUDE.md); `\` is an ordinary
679        // filename byte there, not a separator, so a Windows-shaped entry
680        // name from a foreign archive cannot smuggle a traversal past this
681        // function on this platform — it becomes one odd-looking but fully
682        // contained filename.
683        let dest = Path::new("/tmp/out");
684        assert_eq!(
685            super::safe_join(dest, "..\\..\\etc\\passwd").unwrap(),
686            dest.join("..\\..\\etc\\passwd")
687        );
688    }
689
690    /// A name carrying a NUL is refused outright, and a symlink target
691    /// carrying one is too.
692    ///
693    /// **This test asserted the opposite for its first shape until the final
694    /// whole-branch review's F3**: `safe.txt\0..` used to be ACCEPTED and
695    /// joined verbatim, on the reasoning that a NUL-poisoned segment is
696    /// classified `Normal` rather than `ParentDir` (classification is
697    /// exact-string equality), so the injection buys an attacker no
698    /// traversal. That reasoning was and is correct — it is simply not the
699    /// whole question. The accepted path then reached `std::fs`, which
700    /// rejects an interior NUL as `InvalidInput`, and `stuffr unpack` ended
701    /// at **exit 1** — the code reserved for stuffr itself failing — on a
702    /// name the ARCHIVE chose. That was the only exit 1 in the review's
703    /// 31,460-run corruption sweep.
704    ///
705    /// A refusal is strictly stronger than the property this test used to
706    /// pin, so nothing is lost by the change: `walk_within` is never reached
707    /// with a NUL-bearing string from either public entry point any more,
708    /// because both refuse one first.
709    #[test]
710    fn a_nul_bearing_name_or_symlink_target_is_refused_before_the_filesystem_sees_it() {
711        let dest = Path::new("/tmp/out");
712        for name in ["safe.txt\0..", "a\0/../../etc/passwd", "zt\0ee\0a.txt"] {
713            match super::safe_join(dest, name) {
714                Err(Error::UnsafePath { path, reason }) => {
715                    assert_eq!(path, name);
716                    assert!(
717                        // The second shape holds a genuine `..` component as
718                        // well, and either refusal is correct for it — what
719                        // must never happen is an `Ok`.
720                        reason == "entry name contains a NUL byte"
721                            || reason == "path traversal above the destination",
722                        "unexpected reason for {name:?}: {reason}"
723                    );
724                }
725                other => panic!("expected UnsafePath for {name:?}, got {other:?}"),
726            }
727        }
728        // The sibling door: a symlink TARGET reaches `symlink(2)`, which
729        // rejects an interior NUL the same way `File::create` does.
730        match super::check_symlink_target(dest, &dest.join("sub/link"), "tar\0get") {
731            Err(Error::UnsafePath { path, reason }) => {
732                assert_eq!(path, "tar\0get");
733                assert_eq!(reason, "symlink target `tar\\0get` contains a NUL byte");
734                assert!(!reason.contains('\0'), "raw NUL leaked");
735            }
736            other => panic!("expected UnsafePath, got {other:?}"),
737        }
738    }
739
740    /// The Phase 2 final review's I1. `sub/top -> ..` is a symlink pointing
741    /// at the extraction root: contained, since `dest` is inside `dest`, and
742    /// both bsdtar and GNU tar extract it without comment. It was refused
743    /// (exit 7, aborting the whole extraction) because
744    /// `check_symlink_target` composed `sub/..` and handed it to
745    /// `safe_join`, inheriting a rule written for entry NAMES — and the path
746    /// the refusal named, `sub/..`, is not an entry in the archive, so there
747    /// was nothing for the user to go and look at.
748    #[test]
749    fn a_symlink_target_that_resolves_to_the_destination_itself_is_contained() {
750        let dest = Path::new("/tmp/out");
751        // One level deep, netting exactly to `dest`.
752        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
753        // Two levels deep, netting exactly to `dest`.
754        assert!(super::check_symlink_target(dest, &dest.join("a/b/top"), "../..").is_ok());
755        // (`../sub/..`, down and back up again, was accepted here until
756        // 0.10.1: a `..` after a name is now skipped whatever it nets to —
757        // see `a_symlink_target_climbing_after_a_name_is_skipped`.)
758        // A link directly under `dest` pointing at `dest`.
759        assert!(super::check_symlink_target(dest, &dest.join("here"), ".").is_ok());
760        // And the sibling shapes that already worked must keep working.
761        assert!(super::check_symlink_target(dest, &dest.join("sub/self"), "../sub").is_ok());
762    }
763
764    /// The regression the fix above could plausibly introduce, pinned
765    /// separately: accepting a net-to-`dest` TARGET must not widen into
766    /// accepting one that goes a single step further.
767    #[test]
768    fn a_symlink_target_one_step_past_the_destination_is_still_refused() {
769        let dest = Path::new("/tmp/out");
770        for (link, target) in [
771            (dest.join("sub/top"), "../.."),
772            (dest.join("top"), ".."),
773            (dest.join("a/b/top"), "../../.."),
774            (dest.join("sub/top"), "../../etc/passwd"),
775            (dest.join("sub/top"), "../sub/../.."),
776            (dest.join("sub/top"), "/etc/passwd"),
777        ] {
778            let err = super::check_symlink_target(dest, &link, target)
779                .expect_err("{target} from {link:?} must be refused");
780            match err {
781                // The refusal names the TARGET the archive actually
782                // contains, never the composed path — which the user has no
783                // way to find. That was half of I1.
784                Error::UnsafePath { path, .. } => assert_eq!(
785                    path, target,
786                    "the refusal must name the archive's own target string"
787                ),
788                other => panic!("expected UnsafePath for {target}, got {other:?}"),
789            }
790        }
791    }
792
793    /// 0.10.1: a `..` after a name is SKIPPED, wherever the walk would land
794    /// (user ruling 2026-10-07 — it was exit 7 until the final fix wave).
795    ///
796    /// Lexical normalisation reads `x/s2/..` as `x`, but the OS resolves
797    /// `x/s2` FIRST — and when `x/s2` is itself a symlink (`x/s2 -> ..`, an
798    /// ordinary contained link), the `..` climbs from wherever it points. A
799    /// tar holding `x/`, `x/s2 -> ..` and `s1 -> x/s2/..` unpacked at exit 0
800    /// and left `dest/s1` resolving to `dest/..`. Every shape here nets
801    /// INSIDE `dest` lexically, which is exactly why the depth walk alone
802    /// accepted them. The last is GNU tar's `--transform 's,^,pkg-1.0/,'`
803    /// rewrite of `bin/foo -> ../lib/foo`.
804    #[test]
805    fn a_symlink_target_climbing_after_a_name_is_skipped() {
806        let dest = Path::new("/tmp/out");
807        for (link, target) in [
808            (dest.join("s1"), "x/s2/.."),
809            (dest.join("s1"), "a/../b"),
810            (dest.join("s1"), "./a/.."),
811            (dest.join("sub/top"), "../sub/.."),
812            (dest.join("a/b/l"), "../../x/y/../z"),
813            (dest.join("s1"), "a/./.."),
814            (dest.join("pkg-1.0/bin/foo"), "pkg-1.0/../lib/foo"),
815        ] {
816            assert_eq!(
817                super::classify_symlink_target(dest, &link, "e", target).unwrap(),
818                super::SymlinkVerdict::Skip(super::CLIMBS_AFTER_A_NAME),
819                "{target} from {link:?}"
820            );
821            // The all-or-nothing wrapper still refuses it, naming the target.
822            match super::check_symlink_target(dest, &link, target) {
823                Err(Error::UnsafePath { path, reason }) => {
824                    assert_eq!(path, target);
825                    assert_eq!(reason, super::CLIMBS_AFTER_A_NAME, "{target}");
826                }
827                other => panic!("{target} from {link:?} must be refused, got {other:?}"),
828            }
829        }
830    }
831
832    /// The skip never swallows a genuine escape: a target that ALSO climbs
833    /// out of `dest` lexically is exit 7 whatever its shape, and every
834    /// refusal names the ENTRY (F2 of the 0.10.1 final review), never the
835    /// target text, which is not something the user can find with `list`.
836    #[test]
837    fn an_escape_is_refused_naming_the_entry_even_when_its_shape_would_skip() {
838        let dest = Path::new("/tmp/out");
839        for (link, target, reason) in [
840            (
841                dest.join("l"),
842                "a/../../..",
843                super::climbs_out_of_dest("a/../../.."),
844            ),
845            (
846                dest.join("sub/l"),
847                "../sub/../..",
848                super::climbs_out_of_dest("../sub/../.."),
849            ),
850            (
851                dest.join("sub/l"),
852                "../..",
853                super::climbs_out_of_dest("../.."),
854            ),
855            (
856                dest.join("sub/l"),
857                "../../x/../y",
858                super::climbs_out_of_dest("../../x/../y"),
859            ),
860            (
861                dest.join("l"),
862                "/etc/passwd",
863                "symlink target `/etc/passwd` is absolute".to_string(),
864            ),
865            (
866                dest.join("l"),
867                "a\0/..",
868                "symlink target `a\\0/..` contains a NUL byte".to_string(),
869            ),
870        ] {
871            match super::classify_symlink_target(dest, &link, "the/entry", target) {
872                Err(Error::UnsafePath { path, reason: r }) => {
873                    assert_eq!(path, "the/entry", "{target:?}");
874                    assert_eq!(r, reason, "{target:?}");
875                }
876                other => panic!("{target:?} from {link:?} must be refused, got {other:?}"),
877            }
878        }
879    }
880
881    /// The reason quotes the target, escaped the way entry names are: an
882    /// archive-supplied control character never reaches a terminal raw.
883    #[test]
884    fn a_quoted_target_is_escaped() {
885        let dest = Path::new("/tmp/out");
886        let target = "../\x1b`x/../..";
887        match super::classify_symlink_target(dest, &dest.join("l"), "e", target) {
888            Err(Error::UnsafePath { reason, .. }) => {
889                assert_eq!(
890                    reason,
891                    "symlink target `../\\x1b`x/../..` climbs out of the destination"
892                );
893                assert!(!reason.contains('\x1b'));
894            }
895            other => panic!("expected UnsafePath, got {other:?}"),
896        }
897    }
898
899    /// 0.10.1 fix round 1, then the final fix wave: an EMPTY target names
900    /// nothing and has no components, so the walk accepted it — and
901    /// `symlink(2)` on Linux answers ENOENT, which reached the CLI as
902    /// `Error::Io`, exit 1. (macOS creates `l -> ""` at exit 0.) Skipped.
903    #[test]
904    fn an_empty_symlink_target_is_skipped() {
905        let dest = Path::new("/tmp/out");
906        assert_eq!(
907            super::classify_symlink_target(dest, &dest.join("sub/l"), "sub/l", "").unwrap(),
908            super::SymlinkVerdict::Skip(super::EMPTY_SYMLINK_TARGET)
909        );
910        match super::check_symlink_target(dest, &dest.join("sub/l"), "") {
911            Err(Error::UnsafePath { path, reason }) => {
912                assert_eq!(path, "");
913                assert_eq!(reason, super::EMPTY_SYMLINK_TARGET);
914            }
915            other => panic!("the wrapper must still refuse it, got {other:?}"),
916        }
917    }
918
919    /// The over-strictness guard for the rule above: a LEADING `..` run
920    /// within the link's depth, plain names, and `.` anywhere stay accepted.
921    #[test]
922    fn a_leading_parent_run_and_plain_names_are_still_accepted() {
923        let dest = Path::new("/tmp/out");
924        for (link, target) in [
925            (dest.join("sub/top"), ".."),
926            (dest.join("sub/l"), "../a"),
927            (dest.join("a/b/l"), "../../a/b"),
928            (dest.join("a/b/l"), "./../.."),
929            (dest.join("l"), "a/b"),
930            (dest.join("l"), "./a"),
931            (dest.join("l"), "a/./b"),
932            (dest.join("l"), "a/b/."),
933            (dest.join("l"), "."),
934            (dest.join("l"), "..foo/...b"),
935        ] {
936            assert!(
937                super::check_symlink_target(dest, &link, target).is_ok(),
938                "{target} from {link:?} must be accepted"
939            );
940            assert_eq!(
941                super::classify_symlink_target(dest, &link, "e", target).unwrap(),
942                super::SymlinkVerdict::Allowed,
943                "{target} from {link:?}"
944            );
945        }
946    }
947
948    /// `safe_join`'s own net-to-destination rule is untouched by I1's fix:
949    /// an ENTRY NAME that pops back to the destination is still refused.
950    /// The two functions now disagree on purpose, and that is the fix.
951    #[test]
952    fn an_entry_name_that_nets_to_the_destination_is_still_refused_after_the_symlink_fix() {
953        let dest = Path::new("/tmp/out");
954        assert!(super::safe_join(dest, "sub/..").is_err());
955        assert!(super::safe_join(dest, "a/b/../..").is_err());
956        // While the same string as a symlink TARGET is fine.
957        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
958    }
959
960    #[test]
961    fn a_chain_of_parent_dirs_through_a_nested_symlink_is_refused() {
962        // The brief's own symlink test only exercises one level of nesting.
963        // A link two directories deep needs two `..` just to reach `dest`,
964        // so a target with three escapes past it — the composition Task 9
965        // actually relies on: `check_symlink_target` re-derives the correct
966        // depth from the link's OWN path rather than assuming a fixed one.
967        let dest = Path::new("/tmp/out");
968        assert!(super::check_symlink_target(dest, &dest.join("a/b/link"), "../../b.txt").is_ok());
969        assert!(
970            super::check_symlink_target(dest, &dest.join("a/b/link"), "../../../etc/passwd")
971                .is_err()
972        );
973    }
974
975    #[test]
976    fn dot_and_dot_slash_name_the_destination_itself() {
977        // `tar cf x.tar .` emits `./` as its first entry — the single most
978        // common way a tarball gets produced. Refusing it would refuse the
979        // majority of real-world archives at exit 7, a security refusal, on
980        // completely benign input. `.` and `./` cannot escape `dest`; they
981        // name it.
982        let dest = Path::new("/tmp/out");
983        assert_eq!(super::safe_join(dest, ".").unwrap(), dest);
984        assert_eq!(super::safe_join(dest, "./").unwrap(), dest);
985    }
986
987    #[test]
988    fn a_trailing_dot_resolves_within_its_parent_not_to_the_destination() {
989        let dest = Path::new("/tmp/out");
990        assert_eq!(super::safe_join(dest, "sub/.").unwrap(), dest.join("sub"));
991    }
992
993    #[test]
994    fn empty_name_is_still_refused_even_though_dot_is_now_accepted() {
995        // An empty string is malformed, not merely redundant with `.` — it
996        // stays refused with its own reason.
997        let dest = Path::new("/tmp/out");
998        assert!(super::safe_join(dest, "").is_err());
999    }
1000
1001    #[test]
1002    fn traversal_that_nets_to_empty_is_still_refused_not_confused_with_naming_the_destination() {
1003        // The regression this change could plausibly introduce: accepting
1004        // `.` must not widen into accepting anything that merely RESOLVES to
1005        // empty. `..`, `./..` and `a/../..` all attempt to pop past what was
1006        // ever pushed — refused exactly as before, by the same
1007        // pop-fails-immediately path this change did not touch.
1008        let dest = Path::new("/tmp/out");
1009        assert!(super::safe_join(dest, "..").is_err());
1010        assert!(super::safe_join(dest, "./..").is_err());
1011        assert!(super::safe_join(dest, "a/../..").is_err());
1012        // `a/..` is the subtler case: it never pops past zero, so the old
1013        // "pop fails" guard does not catch it — it is refused only because
1014        // a real component was pushed and then popped away again, which is
1015        // exactly the distinction `pushed_a_component` exists to draw.
1016        assert!(super::safe_join(dest, "a/..").is_err());
1017    }
1018}