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}