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