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}