Skip to main content

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}