shell_tunnel/fs/root.rs
1//! The jail boundary: the only way a path reaches the filesystem.
2
3use std::path::{Component, Path, PathBuf};
4
5use crate::fs::platform;
6
7/// Why a path was refused.
8///
9/// Deliberately coarse. Distinguishing "outside the root and exists" from
10/// "outside the root and does not exist" would make the API an oracle for the
11/// filesystem beyond the jail.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum FsError {
14 /// The path is not of an acceptable shape (400).
15 Malformed(&'static str),
16 /// The path resolves outside the root (403).
17 Escapes,
18 /// The path is inside the root but does not exist (404).
19 NotFound,
20}
21
22/// A directory the API may touch, and nothing outside it.
23///
24/// Held by value in the app state; every filesystem path in the API is produced
25/// by one of these methods and by no other route.
26#[derive(Debug, Clone)]
27pub struct FsRoot {
28 root: PathBuf,
29}
30
31impl FsRoot {
32 /// Anchor a jail at `root`, which must already exist.
33 ///
34 /// Canonicalised once here so every later comparison is against a path with
35 /// symlinks already resolved — otherwise a symlinked root would make every
36 /// containment check compare unlike things.
37 pub fn new(root: impl AsRef<Path>) -> std::io::Result<Self> {
38 Ok(Self {
39 root: root.as_ref().canonicalize()?,
40 })
41 }
42
43 /// The jail's own path.
44 pub fn path(&self) -> &Path {
45 &self.root
46 }
47
48 /// Split a request path into components, refusing anything not of the
49 /// documented shape (root-relative, POSIX separators).
50 ///
51 /// Backslashes are treated as separators too: a Windows-shaped path from a
52 /// careless client should be split and checked, not smuggled through as one
53 /// giant component that no rule matches.
54 fn components(rel: &str) -> Result<Vec<&str>, FsError> {
55 if rel.is_empty() {
56 return Err(FsError::Malformed("path is empty"));
57 }
58 if rel.starts_with('/') || rel.starts_with('\\') {
59 return Err(FsError::Malformed("path must be relative to the root"));
60 }
61 // `C:` or any drive-letter prefix.
62 let bytes = rel.as_bytes();
63 if bytes.len() >= 2 && bytes[1] == b':' {
64 return Err(FsError::Malformed("path must not name a drive"));
65 }
66
67 let mut out = Vec::new();
68 for part in rel.split(['/', '\\']) {
69 if part == "." {
70 continue;
71 }
72 if part == ".." {
73 // Kept as a component so canonicalisation can resolve it; the
74 // containment check is what decides the outcome.
75 out.push(part);
76 continue;
77 }
78 platform::check_component(part).map_err(FsError::Malformed)?;
79 out.push(part);
80 }
81 if out.is_empty() {
82 return Err(FsError::Malformed("path is empty"));
83 }
84 Ok(out)
85 }
86
87 /// Resolve a path that must already exist.
88 ///
89 /// Containment is decided by canonicalising the deepest part of the path
90 /// that exists, never by the *kind* of error a full canonicalisation
91 /// returned. Branching on the error kind is what leaks: a path whose parent
92 /// is a file fails with ENOTDIR while a path whose parent is absent fails
93 /// with NotFound, so answering differently tells the caller which files
94 /// exist outside the jail. It also mishandles a symlink that points out of
95 /// the root — the link resolves, the target does not exist, and a lexical
96 /// check sees a path that never left.
97 ///
98 /// Walking down instead means every real directory on the way is resolved
99 /// through its symlinks and checked, and the verdict never depends on an
100 /// errno. `resolve_for_create` uses the same discipline.
101 pub fn resolve_existing(&self, rel: &str) -> Result<PathBuf, FsError> {
102 // `.` names the root itself. Addressing the root is part of the jail's
103 // addressing scheme, so it is answered here rather than special-cased by
104 // each handler that needs it — `list` needs it first, but it is not the
105 // only caller that ever will.
106 //
107 // `""` deliberately stays an error: an API where an omitted or empty
108 // parameter silently means "the entire tree" is a footgun. Naming the
109 // root should be explicit.
110 //
111 // Only the bare `.` needs this. `./app` and `app/.` already work —
112 // `components` strips `.` as a no-op, leaving a non-empty path.
113 if rel == "." {
114 // Already canonicalised in `new`, so containment holds trivially.
115 return Ok(self.root.clone());
116 }
117
118 let parts = Self::components(rel)?;
119
120 let mut base = self.root.clone();
121 let mut missing = false;
122 for part in &parts {
123 let candidate = base.join(part);
124 match candidate.canonicalize() {
125 Ok(resolved) => {
126 // Checked at every level, so a symlink out of the jail is
127 // caught the moment it is traversed rather than at the end.
128 if !resolved.starts_with(&self.root) {
129 return Err(FsError::Escapes);
130 }
131 base = resolved;
132 }
133 Err(_) => {
134 // A name that exists as a symlink but will not canonicalise
135 // is a dangling link, and where it points cannot be checked
136 // — `canonicalize` fails outright on one, revealing neither
137 // that a link was involved nor its target. Refuse it.
138 //
139 // Uniformly `Escapes`, never a split on where the target
140 // would have been: deciding that lexically would answer
141 // differently for a link pointing inside than for one
142 // pointing outside, which is the existence oracle again by
143 // another route. Over-refusing a broken link inside the
144 // jail is the cheap side of that trade.
145 if candidate.symlink_metadata().is_ok() {
146 return Err(FsError::Escapes);
147 }
148 // Nothing further can be resolved. Whether this is a
149 // refusal or a plain miss is decided lexically from here,
150 // identically for every error the OS might have given.
151 missing = true;
152 break;
153 }
154 }
155 }
156
157 if missing {
158 let joined = parts.iter().fold(self.root.clone(), |acc, p| acc.join(p));
159 return match Self::lexical_within(&self.root, &joined) {
160 true => Err(FsError::NotFound),
161 false => Err(FsError::Escapes),
162 };
163 }
164
165 Ok(base)
166 }
167
168 /// Resolve a path that does not exist yet (an upload target).
169 ///
170 /// The target itself cannot be canonicalised, so the nearest existing
171 /// ancestor is canonicalised instead and the remaining segments are checked
172 /// lexically. Those segments may not contain `..`: with nothing on disk to
173 /// resolve against, a traversal there would go unnoticed until the write.
174 pub fn resolve_for_create(&self, rel: &str) -> Result<PathBuf, FsError> {
175 let parts = Self::components(rel)?;
176 if parts.contains(&"..") {
177 return Err(FsError::Escapes);
178 }
179
180 // Walk down from the root, canonicalising while the path still exists.
181 let mut base = self.root.clone();
182 let mut tail: Vec<&str> = Vec::new();
183 for (index, part) in parts.iter().enumerate() {
184 let candidate = base.join(part);
185 match candidate.canonicalize() {
186 Ok(resolved) => {
187 if !resolved.starts_with(&self.root) {
188 return Err(FsError::Escapes);
189 }
190 base = resolved;
191 }
192 Err(_) => {
193 // Same dangling-symlink refusal as `resolve_existing`, and
194 // load-bearing here rather than merely tidy: handing back a
195 // path whose last existing component is a link pointing out
196 // of the jail means whatever writes to it writes outside.
197 if candidate.symlink_metadata().is_ok() {
198 return Err(FsError::Escapes);
199 }
200 tail = parts[index..].to_vec();
201 break;
202 }
203 }
204 }
205
206 if !base.starts_with(&self.root) {
207 return Err(FsError::Escapes);
208 }
209 Ok(tail.iter().fold(base, |acc, p| acc.join(p)))
210 }
211
212 /// Render an absolute path inside the jail as a root-relative POSIX string.
213 ///
214 /// Returns `None` for anything outside, so a caller cannot accidentally
215 /// publish a path it should not have.
216 pub fn relative(&self, abs: &Path) -> Option<String> {
217 let rest = abs.strip_prefix(&self.root).ok()?;
218 let mut out = String::new();
219 for component in rest.components() {
220 if let Component::Normal(part) = component {
221 if !out.is_empty() {
222 out.push('/');
223 }
224 out.push_str(&part.to_string_lossy());
225 }
226 }
227 Some(out)
228 }
229
230 /// Whether `candidate` sits under `root` by string shape alone.
231 ///
232 /// Used only to choose between 404 and 403 for a path that does not exist,
233 /// where there is nothing on disk to canonicalise.
234 fn lexical_within(root: &Path, candidate: &Path) -> bool {
235 let mut depth: i64 = 0;
236 let Ok(rest) = candidate.strip_prefix(root) else {
237 return false;
238 };
239 for component in rest.components() {
240 match component {
241 Component::ParentDir => depth -= 1,
242 Component::Normal(_) => depth += 1,
243 _ => {}
244 }
245 if depth < 0 {
246 return false;
247 }
248 }
249 true
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn root_with(files: &[&str]) -> (tempfile::TempDir, FsRoot) {
258 let dir = tempfile::tempdir().expect("tempdir");
259 for file in files {
260 let path = dir.path().join(file);
261 if let Some(parent) = path.parent() {
262 std::fs::create_dir_all(parent).expect("mkdir");
263 }
264 std::fs::write(&path, b"x").expect("write");
265 }
266 let root = FsRoot::new(dir.path()).expect("root");
267 (dir, root)
268 }
269
270 /// Like `root_with`, but for a test that also needs to place something
271 /// *outside* the jail (a probe file, a sibling directory, a symlink
272 /// target).
273 ///
274 /// The jail root is a subdirectory of the returned `TempDir` rather than
275 /// the `TempDir` itself, so anything a test writes as a sibling of the
276 /// root is still inside the fixture that auto-cleans on drop. Without
277 /// this, a test that panics before a manual cleanup line runs — which is
278 /// exactly what these tests are designed to do when `FsRoot` regresses —
279 /// leaks a file into the shared OS temp directory permanently.
280 fn root_with_outside(files: &[&str]) -> (tempfile::TempDir, FsRoot) {
281 let outer = tempfile::tempdir().expect("tempdir");
282 let root_dir = outer.path().join("root");
283 for file in files {
284 let path = root_dir.join(file);
285 if let Some(parent) = path.parent() {
286 std::fs::create_dir_all(parent).expect("mkdir");
287 }
288 std::fs::write(&path, b"x").expect("write");
289 }
290 let root = FsRoot::new(&root_dir).expect("root");
291 (outer, root)
292 }
293
294 /// Create a symlink for a test, tolerating the privilege some Windows
295 /// accounts and CI runners lack (`SeCreateSymbolicLinkPrivilege`).
296 ///
297 /// Returns whether the link was created. A caller uses this to skip the
298 /// test body early rather than let a missing privilege turn into a
299 /// failing suite — the check under test is about path containment, not
300 /// about the environment's symlink permissions.
301 fn try_symlink(target: &Path, link: &Path) -> bool {
302 #[cfg(unix)]
303 {
304 std::os::unix::fs::symlink(target, link).is_ok()
305 }
306 #[cfg(windows)]
307 {
308 std::os::windows::fs::symlink_file(target, link).is_ok()
309 }
310 #[cfg(not(any(unix, windows)))]
311 {
312 let _ = (target, link);
313 false
314 }
315 }
316
317 #[test]
318 fn a_file_inside_the_root_resolves() {
319 let (_dir, root) = root_with(&["app/config.json"]);
320 let resolved = root.resolve_existing("app/config.json").expect("resolve");
321 assert!(resolved.ends_with("config.json"));
322 }
323
324 #[test]
325 fn dot_dot_traversal_is_refused() {
326 let (_dir, root) = root_with(&["app/config.json"]);
327 assert_eq!(
328 root.resolve_existing("../outside.txt"),
329 Err(FsError::Escapes)
330 );
331 assert_eq!(
332 root.resolve_existing("app/../../outside.txt"),
333 Err(FsError::Escapes)
334 );
335 }
336
337 #[test]
338 fn a_filename_containing_two_dots_resolves() {
339 // Regression against the old `validate_working_dir` substring rule.
340 let (_dir, root) = root_with(&["my..file.txt"]);
341 assert!(root.resolve_existing("my..file.txt").is_ok());
342 }
343
344 #[test]
345 fn absolute_paths_are_refused() {
346 let (_dir, root) = root_with(&["app/config.json"]);
347 assert!(matches!(
348 root.resolve_existing("/etc/passwd"),
349 Err(FsError::Malformed(_))
350 ));
351 assert!(matches!(
352 root.resolve_existing("C:/Windows/System32/config"),
353 Err(FsError::Malformed(_))
354 ));
355 assert!(matches!(
356 root.resolve_existing("\\\\server\\share\\file"),
357 Err(FsError::Malformed(_))
358 ));
359 }
360
361 #[test]
362 fn reserved_and_stream_names_are_refused() {
363 let (_dir, root) = root_with(&["app/config.json"]);
364 assert!(matches!(
365 root.resolve_existing("NUL"),
366 Err(FsError::Malformed(_))
367 ));
368 assert!(matches!(
369 root.resolve_existing("app/config.json:hidden"),
370 Err(FsError::Malformed(_))
371 ));
372 }
373
374 #[test]
375 fn a_missing_file_inside_the_root_is_not_found() {
376 let (_dir, root) = root_with(&["app/config.json"]);
377 assert_eq!(
378 root.resolve_existing("app/absent.json"),
379 Err(FsError::NotFound)
380 );
381 }
382
383 #[test]
384 fn a_single_dot_names_the_root_itself() {
385 // `list` needs to enumerate the root; without this there is no way to
386 // name it at all.
387 let (_dir, root) = root_with(&["app/config.json"]);
388 assert_eq!(root.resolve_existing("."), Ok(root.path().to_path_buf()));
389
390 // An empty path stays an error: "the whole tree" must be asked for
391 // explicitly, never by omission.
392 assert!(matches!(
393 root.resolve_existing(""),
394 Err(FsError::Malformed(_))
395 ));
396
397 // The root is not a creatable target.
398 assert!(root.resolve_for_create(".").is_err());
399 }
400
401 #[test]
402 fn an_escape_looks_the_same_whether_or_not_the_target_exists() {
403 // The oracle this guards against: if a caller can tell "outside and
404 // real" from "outside and absent", the jail reports on the filesystem
405 // beyond it.
406 let (outer, root) = root_with_outside(&["app/config.json"]);
407
408 let present = outer.path().join("st-probe-present.txt");
409 std::fs::write(&present, b"secret").expect("write probe");
410
411 let existing = root.resolve_existing("../st-probe-present.txt");
412 let absent = root.resolve_existing("../st-probe-absent.txt");
413
414 assert_eq!(existing, Err(FsError::Escapes));
415 assert_eq!(absent, Err(FsError::Escapes));
416 assert_eq!(existing, absent, "the refusal must not reveal existence");
417 }
418
419 #[test]
420 fn an_escape_through_an_existing_directory_is_refused() {
421 // Exercises the walk's containment check directly rather than the
422 // lexical fallback: every component here resolves to something that
423 // is really on disk, so the "missing" branch never trips and the
424 // verdict can only come from `!resolved.starts_with(&self.root)`. If
425 // that check were removed, this would resolve successfully to a real
426 // file outside the jail instead of failing.
427 let (outer, root) = root_with_outside(&["app/config.json"]);
428 let sibling = outer.path().join("st-sibling-dir");
429 std::fs::create_dir_all(&sibling).expect("mkdir sibling");
430 std::fs::write(sibling.join("target.txt"), b"secret").expect("write sibling file");
431
432 let result = root.resolve_existing("app/../../st-sibling-dir/target.txt");
433
434 assert_eq!(result, Err(FsError::Escapes));
435 }
436
437 #[test]
438 fn a_dangling_symlink_pointing_outside_the_root_is_refused_by_resolve_existing() {
439 // `canonicalize` fails outright on a dangling link, handing back
440 // nothing to decide containment from — the exact gap that let a
441 // dangling link into a missing outside target through as `NotFound`
442 // instead of `Escapes`.
443 let (outer, root) = root_with_outside(&["app/config.json"]);
444 let link = root.path().join("dangle-existing");
445 let missing_target = outer.path().join("st-dangling-target.txt"); // never created
446
447 if !try_symlink(&missing_target, &link) {
448 return; // symlink privilege unavailable on this runner; skip
449 }
450
451 assert_eq!(
452 root.resolve_existing("dangle-existing"),
453 Err(FsError::Escapes)
454 );
455 }
456
457 #[test]
458 fn a_dangling_symlink_pointing_outside_the_root_is_refused_by_resolve_for_create() {
459 // Load-bearing rather than merely tidy: `resolve_for_create` feeds
460 // upload destinations, so handing back a path through this link would
461 // mean the write itself lands outside the root.
462 let (outer, root) = root_with_outside(&["app/config.json"]);
463 let link = root.path().join("dangle-create");
464 let missing_target = outer.path().join("st-dangling-target-2.txt"); // never created
465
466 if !try_symlink(&missing_target, &link) {
467 return; // symlink privilege unavailable on this runner; skip
468 }
469
470 assert_eq!(
471 root.resolve_for_create("dangle-create/new.bin"),
472 Err(FsError::Escapes)
473 );
474 }
475
476 #[test]
477 fn a_create_target_need_not_exist_yet() {
478 let (_dir, root) = root_with(&["app/config.json"]);
479 let target = root
480 .resolve_for_create("app/new.bin")
481 .expect("create target");
482 assert!(target.ends_with("new.bin"));
483 assert!(!target.exists());
484 }
485
486 #[test]
487 fn a_create_target_may_not_escape_through_a_missing_segment() {
488 let (_dir, root) = root_with(&["app/config.json"]);
489 assert!(matches!(
490 root.resolve_for_create("app/../../escape.bin"),
491 Err(FsError::Escapes) | Err(FsError::Malformed(_))
492 ));
493 }
494
495 #[test]
496 fn relative_renders_posix_separators() {
497 let (_dir, root) = root_with(&["app/config.json"]);
498 let abs = root.resolve_existing("app/config.json").expect("resolve");
499 assert_eq!(root.relative(&abs).as_deref(), Some("app/config.json"));
500 }
501
502 #[cfg(unix)]
503 #[test]
504 fn a_symlink_out_of_the_root_is_refused() {
505 let (dir, root) = root_with(&["app/config.json"]);
506 let outside = dir
507 .path()
508 .parent()
509 .expect("parent")
510 .join("st-outside-target");
511 std::fs::write(&outside, b"secret").expect("write outside");
512 std::os::unix::fs::symlink(&outside, dir.path().join("link")).expect("symlink");
513
514 assert_eq!(root.resolve_existing("link"), Err(FsError::Escapes));
515
516 std::fs::remove_file(&outside).ok();
517 }
518}