Skip to main content

stuffr_core/
containment.rs

1//! Entry-path containment: the single decision point for whether an
2//! archive entry's name (or a symlink's target) may be written under a
3//! destination directory.
4//!
5//! Pure and filesystem-free by design — see the module-level doc comments on
6//! each function for why.
7
8use std::path::{Component, Path, PathBuf};
9
10use crate::error::{Error, Result};
11
12/// Joins `entry_name` onto `dest`, refusing anything that escapes.
13///
14/// Resolved COMPONENT-WISE and without touching the filesystem, so the
15/// decision cannot depend on what happens to exist — and so it is exhaustively
16/// unit-testable with no archive and no temp directory. There is deliberately
17/// no code path that returns a sanitised path for a hostile name: the README's
18/// contract is "refused, not silently sanitised", and a caller that received a
19/// cleaned-up path would have no way to know it had been handed one.
20///
21/// `.` and `./` name the destination directory itself and are ACCEPTED,
22/// returning `dest` unchanged: `tar cf x.tar .` — the single most common way
23/// a tarball gets produced — emits `./` as its first entry, and refusing it
24/// would reject the majority of real-world tarballs at exit 7. Naming the
25/// destination cannot escape it, and escaping is the only thing this
26/// function exists to prevent. This is different from a name like `a/..`,
27/// which also nets to the destination but only after actually consuming a
28/// real component — see the `pushed_a_component` note below for why the two
29/// are told apart rather than collapsed onto one "ends up empty" check.
30pub fn safe_join(dest: &Path, entry_name: &str) -> Result<PathBuf> {
31    if entry_name.is_empty() {
32        return Err(Error::UnsafePath {
33            path: entry_name.to_string(),
34            reason: "empty entry name",
35        });
36    }
37
38    let mut out = PathBuf::new();
39    // Tracks whether any real (`Normal`) component was ever pushed, as
40    // distinct from `out` being empty. `.` and `./` leave both false and
41    // empty — never having accumulated anything to pop, they are the
42    // destination itself. `a/..` leaves `out` empty too, but only after
43    // pushing `a` and then popping it away again; collapsing these two onto
44    // a single "is `out` empty at the end" check would silently start
45    // accepting that second shape, which is real traversal that happens to
46    // net to zero rather than a name for the destination.
47    let mut pushed_a_component = false;
48    walk_within(&mut out, &mut pushed_a_component, Path::new(entry_name)).map_err(|reason| {
49        Error::UnsafePath {
50            path: entry_name.to_string(),
51            reason,
52        }
53    })?;
54
55    if out.as_os_str().is_empty() {
56        return if pushed_a_component {
57            // `a/..`, `a/b/../..`: a real component was consumed and then
58            // popped away again. Never produced by a real archiver (which
59            // emits bare `.`/`./` for the root, not this), so refusing it
60            // costs nothing in compatibility and keeps the traversal check
61            // from being second-guessed by a coincidental net-zero.
62            //
63            // This rule is about ENTRY NAMES specifically, which is why
64            // `check_symlink_target` below does not go through this
65            // function any more — see its own doc.
66            Err(Error::UnsafePath {
67                path: entry_name.to_string(),
68                reason: "path traversal nets back to the destination",
69            })
70        } else {
71            // `.` or `./`: nothing was ever pushed, so nothing was popped
72            // away either — this names `dest` itself, not an escape from it.
73            Ok(dest.to_path_buf())
74        };
75    }
76    Ok(dest.join(out))
77}
78
79/// Walks `path`'s components onto `out`, popping for each `..`.
80///
81/// The single component-classification loop both public functions share, so
82/// there is exactly ONE place that decides what `..`, `.`, a root and a
83/// normal component mean. Returns the refusal `reason` rather than a full
84/// `Error`, because the two callers name different things in the error's
85/// `path` field: `safe_join` names the entry, `check_symlink_target` names
86/// the TARGET — never the composed string, which is not something the user
87/// can find in their archive.
88///
89/// Operates on `Path` components rather than a re-joined string, so nothing
90/// round-trips through `to_string_lossy` on the way. A lossy conversion was
91/// never a bypass here (`.`, `..` and `/` are ASCII and survive it) but it
92/// could mangle a non-UTF-8 target into a DIFFERENT contained name, and
93/// there is no reason to keep it now that the composition is component-wise.
94fn walk_within(
95    out: &mut PathBuf,
96    pushed_a_component: &mut bool,
97    path: &Path,
98) -> std::result::Result<(), &'static str> {
99    for comp in path.components() {
100        match comp {
101            // An absolute path or a Windows drive prefix ignores `dest`
102            // entirely — the classic "tar bomb writes to /etc" shape.
103            Component::RootDir | Component::Prefix(_) => return Err("absolute path"),
104            // `./` is noise, not an attack.
105            Component::CurDir => {}
106            // Pop only within what we have accumulated. `a/../b` is fine;
107            // `../b` and `a/../../b` are not. Comparing against the
108            // accumulated depth rather than canonicalising is what keeps this
109            // filesystem-independent — and immune to a symlink that appears
110            // between the check and the write.
111            Component::ParentDir => {
112                if !out.pop() {
113                    return Err("path traversal above the destination");
114                }
115            }
116            Component::Normal(part) => {
117                out.push(part);
118                *pushed_a_component = true;
119            }
120        }
121    }
122    Ok(())
123}
124
125/// Refuses a symlink whose target would resolve outside `dest`.
126///
127/// The subtler escape: the link's own PATH can be perfectly contained while
128/// its target is not, and a later entry written through that link lands
129/// wherever it points. The target is resolved relative to the link's parent,
130/// which is how the OS will resolve it.
131///
132/// # Why this does not call `safe_join`
133///
134/// It used to: it composed `rel.join(target)` into a string and handed that
135/// to `safe_join`, which meant a symlink target inherited a refusal written
136/// for entry NAMES. `safe_join` refuses a name that nets back to `dest`
137/// after consuming a real component (`a/..`), on the reasoning that no real
138/// archiver emits one. That reasoning does not carry over: `sub/top -> ..`
139/// is an ordinary symlink pointing at the extraction root, which bsdtar and
140/// GNU tar both extract without comment, and `dest` is trivially inside
141/// `dest`. stuffr aborted the whole extraction at exit 7 — naming a path
142/// (`sub/..`) that is not an entry in the archive at all, so the user could
143/// not find it.
144///
145/// The component walk is shared ([`walk_within`]); only the net-to-`dest`
146/// verdict differs, which is the whole point. Every genuine escape is still
147/// refused by the same walk: an absolute target, and any `..` run deeper
148/// than the link's own depth below `dest`.
149pub fn check_symlink_target(dest: &Path, link_path: &Path, target: &str) -> Result<()> {
150    let target_path = Path::new(target);
151    // Checked before the walk purely so the reason names symlinks — the
152    // walk's own `RootDir` arm would refuse it anyway.
153    if target_path.is_absolute() {
154        return Err(Error::UnsafePath {
155            path: target.to_string(),
156            reason: "absolute symlink target",
157        });
158    }
159
160    let parent = link_path.parent().unwrap_or(dest);
161    let rel = parent.strip_prefix(dest).unwrap_or(Path::new(""));
162
163    let mut out = PathBuf::new();
164    let mut pushed = false;
165    // `rel` came out of `safe_join`, so it holds only `Normal` components
166    // and cannot fail — walked rather than asserted so the depth it
167    // contributes is computed by the same code that consumes it.
168    walk_within(&mut out, &mut pushed, rel).map_err(|reason| Error::UnsafePath {
169        path: target.to_string(),
170        reason,
171    })?;
172    walk_within(&mut out, &mut pushed, target_path).map_err(|reason| Error::UnsafePath {
173        path: target.to_string(),
174        reason,
175    })?;
176    // No net-to-`dest` check here, deliberately: `out` being empty means the
177    // target resolves to `dest` itself, which is contained.
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use std::path::Path;
184
185    use crate::error::Error;
186
187    #[test]
188    fn absolute_traversal_and_escaping_symlinks_are_all_refused() {
189        let dest = Path::new("/tmp/out");
190        for (name, why) in [
191            ("../../etc/passwd", "traversal"),
192            ("a/../../b", "traversal"),
193            ("/etc/passwd", "absolute"),
194            ("", "empty"),
195        ] {
196            let err = super::safe_join(dest, name).expect_err("{name} must be refused");
197            match err {
198                Error::UnsafePath { path, .. } => assert_eq!(path, name),
199                other => panic!("expected UnsafePath for {name} ({why}), got {other:?}"),
200            }
201        }
202    }
203
204    #[test]
205    fn ordinary_nested_names_are_accepted_unchanged() {
206        let dest = Path::new("/tmp/out");
207        assert_eq!(
208            super::safe_join(dest, "a/b/c.txt").unwrap(),
209            dest.join("a/b/c.txt")
210        );
211        // A leading `./` is noise, not an attack.
212        assert_eq!(
213            super::safe_join(dest, "./a.txt").unwrap(),
214            dest.join("a.txt")
215        );
216    }
217
218    #[test]
219    fn refusal_never_rewrites_the_name_it_refuses() {
220        // The README's non-negotiable, asserted directly: there is no code path
221        // that returns a SANITISED PathBuf for a hostile entry. If sanitising were
222        // ever added, this test is what fails.
223        let dest = Path::new("/tmp/out");
224        assert!(super::safe_join(dest, "../x").is_err());
225        assert!(super::safe_join(dest, "/x").is_err());
226    }
227
228    #[test]
229    fn a_symlink_whose_target_escapes_the_destination_is_refused() {
230        let dest = Path::new("/tmp/out");
231        assert!(super::check_symlink_target(dest, &dest.join("link"), "../../etc/passwd").is_err());
232        assert!(super::check_symlink_target(dest, &dest.join("link"), "/etc/passwd").is_err());
233        assert!(super::check_symlink_target(dest, &dest.join("a/link"), "b.txt").is_ok());
234    }
235
236    #[test]
237    fn dot_only_and_dot_prefixed_names_are_ordinary_filenames_not_traversal() {
238        // Component classification is exact-string equality against "." and
239        // "..", not "contains dots": a name that is only three (or more)
240        // dots, or merely starts with `..`, is a valid, if unusual, filename
241        // on every filesystem this runs on — not a traversal variant. A
242        // naive `contains("..")` check would reject these as false
243        // positives; a naive strip-`..`-substrings sanitiser would corrupt
244        // them. Neither applies here — all three are accepted unchanged.
245        let dest = Path::new("/tmp/out");
246        assert_eq!(super::safe_join(dest, "...").unwrap(), dest.join("..."));
247        assert_eq!(
248            super::safe_join(dest, "a/....b").unwrap(),
249            dest.join("a/....b")
250        );
251        assert_eq!(super::safe_join(dest, "..foo").unwrap(), dest.join("..foo"));
252    }
253
254    #[test]
255    fn backslash_heavy_names_stay_a_single_contained_component() {
256        // `stuffr` builds for macOS/Linux (see CLAUDE.md); `\` is an ordinary
257        // filename byte there, not a separator, so a Windows-shaped entry
258        // name from a foreign archive cannot smuggle a traversal past this
259        // function on this platform — it becomes one odd-looking but fully
260        // contained filename.
261        let dest = Path::new("/tmp/out");
262        assert_eq!(
263            super::safe_join(dest, "..\\..\\etc\\passwd").unwrap(),
264            dest.join("..\\..\\etc\\passwd")
265        );
266    }
267
268    #[test]
269    fn embedded_nul_does_not_disguise_a_traversal_component() {
270        let dest = Path::new("/tmp/out");
271        // A NUL-poisoned segment that merely CONTAINS ".." is still classified
272        // Normal, not ParentDir, because classification is exact-string
273        // equality — a null-byte injection buys the attacker nothing here.
274        assert_eq!(
275            super::safe_join(dest, "safe.txt\0..").unwrap(),
276            dest.join("safe.txt\0..")
277        );
278        // Nor can a poisoned segment hide a REAL `..` component in a
279        // different `/`-separated position: each segment is classified on
280        // its own regardless of what a sibling segment contains.
281        assert!(super::safe_join(dest, "a\0/../../etc/passwd").is_err());
282    }
283
284    /// The Phase 2 final review's I1. `sub/top -> ..` is a symlink pointing
285    /// at the extraction root: contained, since `dest` is inside `dest`, and
286    /// both bsdtar and GNU tar extract it without comment. It was refused
287    /// (exit 7, aborting the whole extraction) because
288    /// `check_symlink_target` composed `sub/..` and handed it to
289    /// `safe_join`, inheriting a rule written for entry NAMES — and the path
290    /// the refusal named, `sub/..`, is not an entry in the archive, so there
291    /// was nothing for the user to go and look at.
292    #[test]
293    fn a_symlink_target_that_resolves_to_the_destination_itself_is_contained() {
294        let dest = Path::new("/tmp/out");
295        // One level deep, netting exactly to `dest`.
296        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
297        // Two levels deep, netting exactly to `dest`.
298        assert!(super::check_symlink_target(dest, &dest.join("a/b/top"), "../..").is_ok());
299        // Down and back up again, netting to `dest`.
300        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "../sub/..").is_ok());
301        // A link directly under `dest` pointing at `dest`.
302        assert!(super::check_symlink_target(dest, &dest.join("here"), ".").is_ok());
303        // And the sibling shapes that already worked must keep working.
304        assert!(super::check_symlink_target(dest, &dest.join("sub/self"), "../sub").is_ok());
305    }
306
307    /// The regression the fix above could plausibly introduce, pinned
308    /// separately: accepting a net-to-`dest` TARGET must not widen into
309    /// accepting one that goes a single step further.
310    #[test]
311    fn a_symlink_target_one_step_past_the_destination_is_still_refused() {
312        let dest = Path::new("/tmp/out");
313        for (link, target) in [
314            (dest.join("sub/top"), "../.."),
315            (dest.join("top"), ".."),
316            (dest.join("a/b/top"), "../../.."),
317            (dest.join("sub/top"), "../../etc/passwd"),
318            (dest.join("sub/top"), "../sub/../.."),
319            (dest.join("sub/top"), "/etc/passwd"),
320        ] {
321            let err = super::check_symlink_target(dest, &link, target)
322                .expect_err("{target} from {link:?} must be refused");
323            match err {
324                // The refusal names the TARGET the archive actually
325                // contains, never the composed path — which the user has no
326                // way to find. That was half of I1.
327                Error::UnsafePath { path, .. } => assert_eq!(
328                    path, target,
329                    "the refusal must name the archive's own target string"
330                ),
331                other => panic!("expected UnsafePath for {target}, got {other:?}"),
332            }
333        }
334    }
335
336    /// `safe_join`'s own net-to-destination rule is untouched by I1's fix:
337    /// an ENTRY NAME that pops back to the destination is still refused.
338    /// The two functions now disagree on purpose, and that is the fix.
339    #[test]
340    fn an_entry_name_that_nets_to_the_destination_is_still_refused_after_the_symlink_fix() {
341        let dest = Path::new("/tmp/out");
342        assert!(super::safe_join(dest, "sub/..").is_err());
343        assert!(super::safe_join(dest, "a/b/../..").is_err());
344        // While the same string as a symlink TARGET is fine.
345        assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
346    }
347
348    #[test]
349    fn a_chain_of_parent_dirs_through_a_nested_symlink_is_refused() {
350        // The brief's own symlink test only exercises one level of nesting.
351        // A link two directories deep needs two `..` just to reach `dest`,
352        // so a target with three escapes past it — the composition Task 9
353        // actually relies on: `check_symlink_target` re-derives the correct
354        // depth from the link's OWN path rather than assuming a fixed one.
355        let dest = Path::new("/tmp/out");
356        assert!(super::check_symlink_target(dest, &dest.join("a/b/link"), "../../b.txt").is_ok());
357        assert!(
358            super::check_symlink_target(dest, &dest.join("a/b/link"), "../../../etc/passwd")
359                .is_err()
360        );
361    }
362
363    #[test]
364    fn dot_and_dot_slash_name_the_destination_itself() {
365        // `tar cf x.tar .` emits `./` as its first entry — the single most
366        // common way a tarball gets produced. Refusing it would refuse the
367        // majority of real-world archives at exit 7, a security refusal, on
368        // completely benign input. `.` and `./` cannot escape `dest`; they
369        // name it.
370        let dest = Path::new("/tmp/out");
371        assert_eq!(super::safe_join(dest, ".").unwrap(), dest);
372        assert_eq!(super::safe_join(dest, "./").unwrap(), dest);
373    }
374
375    #[test]
376    fn a_trailing_dot_resolves_within_its_parent_not_to_the_destination() {
377        let dest = Path::new("/tmp/out");
378        assert_eq!(super::safe_join(dest, "sub/.").unwrap(), dest.join("sub"));
379    }
380
381    #[test]
382    fn empty_name_is_still_refused_even_though_dot_is_now_accepted() {
383        // An empty string is malformed, not merely redundant with `.` — it
384        // stays refused with its own reason.
385        let dest = Path::new("/tmp/out");
386        assert!(super::safe_join(dest, "").is_err());
387    }
388
389    #[test]
390    fn traversal_that_nets_to_empty_is_still_refused_not_confused_with_naming_the_destination() {
391        // The regression this change could plausibly introduce: accepting
392        // `.` must not widen into accepting anything that merely RESOLVES to
393        // empty. `..`, `./..` and `a/../..` all attempt to pop past what was
394        // ever pushed — refused exactly as before, by the same
395        // pop-fails-immediately path this change did not touch.
396        let dest = Path::new("/tmp/out");
397        assert!(super::safe_join(dest, "..").is_err());
398        assert!(super::safe_join(dest, "./..").is_err());
399        assert!(super::safe_join(dest, "a/../..").is_err());
400        // `a/..` is the subtler case: it never pops past zero, so the old
401        // "pop fails" guard does not catch it — it is refused only because
402        // a real component was pushed and then popped away again, which is
403        // exactly the distinction `pushed_a_component` exists to draw.
404        assert!(super::safe_join(dest, "a/..").is_err());
405    }
406}