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