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/// Drop Windows' verbatim prefix (`\\?\`) from an already-rendered path.
23///
24/// `canonicalize` returns verbatim paths, and every path this module hands
25/// outward came through it. The prefix is correct and is never what a caller
26/// sent, so leaving it in means one file has two names — one on the wire and
27/// one in the banner. One helper because there are two consumers already and a
28/// third would otherwise open-code it again: `relative` and `describe` each
29/// stripped it separately before this existed.
30fn strip_verbatim(rendered: &str) -> &str {
31    rendered.strip_prefix(r"\\?\").unwrap_or(rendered)
32}
33
34/// What the API is allowed to reach.
35///
36/// Two shapes, one resolver. Every path still reaches the disk through the same
37/// walk-down-and-check discipline in `resolve_existing`/`resolve_for_create` —
38/// only the anchor a request is measured against, and the containment verdict,
39/// differ. Adding a second path-resolution route instead would mean the
40/// existence-oracle, symlink, and traversal reasoning those two functions carry
41/// has to hold in a place it was never reviewed for.
42#[derive(Debug, Clone)]
43enum Scope {
44    /// One subtree. Request paths are relative to it; nothing outside is
45    /// reachable. This is what `--fs-root` selects.
46    Jailed(PathBuf),
47    /// Everything the account running this process can already reach. Request
48    /// paths are absolute, and each is measured against the filesystem anchor
49    /// it names (a drive root on Windows, `/` on Unix).
50    ///
51    /// Not a hole in the jail — the jail was never a boundary against a token
52    /// holding `exec`, which can read and write anything this process can. See
53    /// `KNOWN_CAPABILITIES` in `src/security/capability.rs`. What this shape
54    /// buys is that the file API reaches the same places `exec` does, so an
55    /// agent does not have to fall back to piping bytes through a command for
56    /// any destination outside one chosen subtree.
57    Machine(Vec<PathBuf>),
58}
59
60/// What the filesystem API may touch.
61///
62/// Held by value in the app state; every filesystem path in the API is produced
63/// by one of these methods and by no other route.
64#[derive(Debug, Clone)]
65pub struct FsRoot {
66    scope: Scope,
67}
68
69impl FsRoot {
70    /// Anchor a jail at `root`, which must already exist.
71    ///
72    /// Canonicalised once here so every later comparison is against a path with
73    /// symlinks already resolved — otherwise a symlinked root would make every
74    /// containment check compare unlike things.
75    pub fn new(root: impl AsRef<Path>) -> std::io::Result<Self> {
76        Ok(Self {
77            scope: Scope::Jailed(root.as_ref().canonicalize()?),
78        })
79    }
80
81    /// Reach everything this account can, with no subtree restriction.
82    ///
83    /// The default when `--fs-root` is not given. Anchors are enumerated once,
84    /// here, so a drive that appears later is not silently reachable by a
85    /// server that started before it existed.
86    pub fn machine_wide() -> Self {
87        Self {
88            scope: Scope::Machine(platform::filesystem_anchors()),
89        }
90    }
91
92    /// The jail's own path, or `None` when the scope is the whole machine.
93    ///
94    /// Returns an `Option` rather than a bare `Path` because machine-wide scope
95    /// genuinely has no single path: on Windows there is nothing above `C:\`
96    /// and `D:\` to name. A caller that needs one — the audit-log containment
97    /// check at startup, say — has to say what it does when there isn't one.
98    pub fn jail_path(&self) -> Option<&Path> {
99        match &self.scope {
100            Scope::Jailed(root) => Some(root),
101            Scope::Machine(_) => None,
102        }
103    }
104
105    /// One line naming the effective scope, for the startup banner.
106    ///
107    /// The banner is the only thing standing between an operator and a scope
108    /// wider than they assumed, now that the file API no longer needs a flag to
109    /// exist — so this states what is reachable, not which flag was passed.
110    pub fn describe(&self) -> String {
111        match &self.scope {
112            Scope::Jailed(root) => Self::displayable(root),
113            Scope::Machine(anchors) => {
114                let names: Vec<String> = anchors.iter().map(|a| Self::displayable(a)).collect();
115                format!("whole machine ({})", names.join(", "))
116            }
117        }
118    }
119
120    /// A path as an operator would write it.
121    ///
122    /// `canonicalize` yields verbatim paths on Windows, so an anchor prints as
123    /// `\\?\C:\` unless the prefix is stripped — correct, and unreadable in a
124    /// banner whose whole job is telling someone at a glance what the file API
125    /// can reach.
126    fn displayable(path: &Path) -> String {
127        strip_verbatim(&path.display().to_string()).to_string()
128    }
129
130    /// Whether `resolved` sits inside the scope.
131    ///
132    /// One predicate for both shapes, so the walk in `resolve_existing` and
133    /// `resolve_for_create` stays identical: a jail asks "under the root", a
134    /// machine-wide scope asks "under any anchor". The second is close to
135    /// vacuous by construction, which is the point — there is no outside to
136    /// leak the existence of.
137    fn contains(&self, resolved: &Path) -> bool {
138        match &self.scope {
139            Scope::Jailed(root) => resolved.starts_with(root),
140            Scope::Machine(anchors) => anchors.iter().any(|a| resolved.starts_with(a)),
141        }
142    }
143
144    /// Where a request path is measured from, and the components below it.
145    ///
146    /// A jail always anchors at its own root and takes a relative path. A
147    /// machine-wide scope takes an absolute path and anchors at whatever
148    /// filesystem root that path names — so `D:/x` is measured against `D:\`
149    /// and `C:/x` against `C:\`, and a symlink from one to the other is still
150    /// inside the scope because `contains` asks about every anchor.
151    fn anchor_and_parts<'a>(&self, rel: &'a str) -> Result<(PathBuf, Vec<&'a str>), FsError> {
152        match &self.scope {
153            Scope::Jailed(root) => Ok((root.clone(), Self::components(rel)?)),
154            Scope::Machine(anchors) => {
155                let (named, rest) = Self::split_absolute(rel)?;
156                // Canonicalised before the membership check so both sides are
157                // in the same form. On Windows that form is verbatim
158                // (`\\?\C:\`), which is what `canonicalize` returns for every
159                // resolved path further down — comparing a plain `C:\` against
160                // those would fail for everything that exists.
161                let anchor = named.canonicalize().map_err(|_| FsError::Escapes)?;
162                if !anchors.iter().any(|a| a == &anchor) {
163                    // Not "no such drive" — that would answer differently for a
164                    // drive that exists than for one that does not, which is the
165                    // same existence oracle the jail is careful to avoid, just
166                    // one level up.
167                    return Err(FsError::Escapes);
168                }
169                let parts = if rest.is_empty() {
170                    Vec::new()
171                } else {
172                    Self::components(rest)?
173                };
174                Ok((anchor, parts))
175            }
176        }
177    }
178
179    /// Split an absolute request path into its filesystem anchor and the rest.
180    ///
181    /// Accepts `C:/x`, `C:\x`, and `/x`; the separator style is the caller's
182    /// choice, as it already is inside a jail. A relative path is refused here
183    /// rather than resolved against the process's working directory: "relative
184    /// to wherever the server happens to have been started" is not something a
185    /// remote caller can reason about.
186    fn split_absolute(rel: &str) -> Result<(PathBuf, &str), FsError> {
187        if rel.is_empty() {
188            return Err(FsError::Malformed("path is empty"));
189        }
190        if rel.starts_with("\\\\") || rel.starts_with("//") {
191            return Err(FsError::Malformed(
192                "UNC paths are not addressable; name a local path",
193            ));
194        }
195        let bytes = rel.as_bytes();
196        if bytes.len() >= 2 && bytes[1] == b':' {
197            let drive = &rel[..2];
198            let rest = rel[2..].trim_start_matches(['/', '\\']);
199            return Ok((PathBuf::from(format!("{drive}\\")), rest));
200        }
201        if let Some(rest) = rel.strip_prefix('/') {
202            return Ok((PathBuf::from("/"), rest));
203        }
204        Err(FsError::Malformed(
205            "path must be absolute when no --fs-root is set",
206        ))
207    }
208
209    /// Split a request path into components, refusing anything not of the
210    /// documented shape (root-relative, POSIX separators).
211    ///
212    /// Backslashes are treated as separators too: a Windows-shaped path from a
213    /// careless client should be split and checked, not smuggled through as one
214    /// giant component that no rule matches.
215    fn components(rel: &str) -> Result<Vec<&str>, FsError> {
216        if rel.is_empty() {
217            return Err(FsError::Malformed("path is empty"));
218        }
219        if rel.starts_with('/') || rel.starts_with('\\') {
220            return Err(FsError::Malformed("path must be relative to the root"));
221        }
222        // `C:` or any drive-letter prefix.
223        let bytes = rel.as_bytes();
224        if bytes.len() >= 2 && bytes[1] == b':' {
225            return Err(FsError::Malformed("path must not name a drive"));
226        }
227
228        let mut out = Vec::new();
229        for part in rel.split(['/', '\\']) {
230            if part == "." {
231                continue;
232            }
233            if part == ".." {
234                // Kept as a component so canonicalisation can resolve it; the
235                // containment check is what decides the outcome.
236                out.push(part);
237                continue;
238            }
239            platform::check_component(part).map_err(FsError::Malformed)?;
240            out.push(part);
241        }
242        if out.is_empty() {
243            return Err(FsError::Malformed("path is empty"));
244        }
245        Ok(out)
246    }
247
248    /// Resolve a path that must already exist.
249    ///
250    /// Containment is decided by canonicalising the deepest part of the path
251    /// that exists, never by the *kind* of error a full canonicalisation
252    /// returned. Branching on the error kind is what leaks: a path whose parent
253    /// is a file fails with ENOTDIR while a path whose parent is absent fails
254    /// with NotFound, so answering differently tells the caller which files
255    /// exist outside the jail. It also mishandles a symlink that points out of
256    /// the root — the link resolves, the target does not exist, and a lexical
257    /// check sees a path that never left.
258    ///
259    /// Walking down instead means every real directory on the way is resolved
260    /// through its symlinks and checked, and the verdict never depends on an
261    /// errno. `resolve_for_create` uses the same discipline.
262    pub fn resolve_existing(&self, rel: &str) -> Result<PathBuf, FsError> {
263        // `.` names the root itself. Addressing the root is part of the jail's
264        // addressing scheme, so it is answered here rather than special-cased by
265        // each handler that needs it — `list` needs it first, but it is not the
266        // only caller that ever will.
267        //
268        // `""` deliberately stays an error: an API where an omitted or empty
269        // parameter silently means "the entire tree" is a footgun. Naming the
270        // root should be explicit.
271        //
272        // Only the bare `.` needs this. `./app` and `app/.` already work —
273        // `components` strips `.` as a no-op, leaving a non-empty path.
274        if rel == "." {
275            // Already canonicalised in `new`, so containment holds trivially.
276            // Machine-wide scope has no "the root" for `.` to name, and falls
277            // through to `anchor_and_parts`, which refuses a relative path.
278            if let Some(root) = self.jail_path() {
279                return Ok(root.to_path_buf());
280            }
281        }
282
283        let (anchor, parts) = self.anchor_and_parts(rel)?;
284        if parts.is_empty() {
285            // The anchor itself (`C:/`), already a canonical filesystem root.
286            return Ok(anchor);
287        }
288
289        let mut base = anchor.clone();
290        let mut missing = false;
291        for part in &parts {
292            let candidate = base.join(part);
293            match candidate.canonicalize() {
294                Ok(resolved) => {
295                    // Checked at every level, so a symlink out of the jail is
296                    // caught the moment it is traversed rather than at the end.
297                    if !self.contains(&resolved) {
298                        return Err(FsError::Escapes);
299                    }
300                    base = resolved;
301                }
302                Err(_) => {
303                    // A name that exists as a symlink but will not canonicalise
304                    // is a dangling link, and where it points cannot be checked
305                    // — `canonicalize` fails outright on one, revealing neither
306                    // that a link was involved nor its target. Refuse it.
307                    //
308                    // Uniformly `Escapes`, never a split on where the target
309                    // would have been: deciding that lexically would answer
310                    // differently for a link pointing inside than for one
311                    // pointing outside, which is the existence oracle again by
312                    // another route. Over-refusing a broken link inside the
313                    // jail is the cheap side of that trade.
314                    if candidate.symlink_metadata().is_ok() {
315                        return Err(FsError::Escapes);
316                    }
317                    // Nothing further can be resolved. Whether this is a
318                    // refusal or a plain miss is decided lexically from here,
319                    // identically for every error the OS might have given.
320                    missing = true;
321                    break;
322                }
323            }
324        }
325
326        if missing {
327            // Measured from the anchor this request named, not from "the root":
328            // machine-wide scope has several, and asking the wrong one would
329            // turn a plain miss on `D:` into an escape verdict.
330            let joined = parts.iter().fold(anchor, |acc, p| acc.join(p));
331            return match self.lexically_within(&joined) {
332                true => Err(FsError::NotFound),
333                false => Err(FsError::Escapes),
334            };
335        }
336
337        Ok(base)
338    }
339
340    /// Resolve a path that does not exist yet (an upload target).
341    ///
342    /// The target itself cannot be canonicalised, so the nearest existing
343    /// ancestor is canonicalised instead and the remaining segments are checked
344    /// lexically. Those segments may not contain `..`: with nothing on disk to
345    /// resolve against, a traversal there would go unnoticed until the write.
346    pub fn resolve_for_create(&self, rel: &str) -> Result<PathBuf, FsError> {
347        let (anchor, parts) = self.anchor_and_parts(rel)?;
348        if parts.contains(&"..") {
349            return Err(FsError::Escapes);
350        }
351        if parts.is_empty() {
352            // A filesystem anchor is never a create target.
353            return Err(FsError::Malformed("path must name an entry to create"));
354        }
355
356        // Walk down from the anchor, canonicalising while the path still exists.
357        let mut base = anchor;
358        let mut tail: Vec<&str> = Vec::new();
359        for (index, part) in parts.iter().enumerate() {
360            let candidate = base.join(part);
361            match candidate.canonicalize() {
362                Ok(resolved) => {
363                    if !self.contains(&resolved) {
364                        return Err(FsError::Escapes);
365                    }
366                    base = resolved;
367                }
368                Err(_) => {
369                    // Same dangling-symlink refusal as `resolve_existing`, and
370                    // load-bearing here rather than merely tidy: handing back a
371                    // path whose last existing component is a link pointing out
372                    // of the jail means whatever writes to it writes outside.
373                    if candidate.symlink_metadata().is_ok() {
374                        return Err(FsError::Escapes);
375                    }
376                    tail = parts[index..].to_vec();
377                    break;
378                }
379            }
380        }
381
382        if !self.contains(&base) {
383            return Err(FsError::Escapes);
384        }
385        Ok(tail.iter().fold(base, |acc, p| acc.join(p)))
386    }
387
388    /// Render an absolute path as the string the API names it by.
389    ///
390    /// Inside a jail that is a root-relative POSIX string. Machine-wide it is
391    /// the absolute path itself, with `\` normalised to `/` so one separator
392    /// style comes back regardless of which one went in — the value is echoed
393    /// in responses, used as the `list` cursor, and keyed on to detect two
394    /// uploads racing for one destination, so it has to be stable per file.
395    ///
396    /// Returns `None` for anything outside the scope, so a caller cannot
397    /// accidentally publish a path it should not have.
398    pub fn relative(&self, abs: &Path) -> Option<String> {
399        let root = match &self.scope {
400            Scope::Jailed(root) => root.as_path(),
401            Scope::Machine(_) => {
402                if !self.contains(abs) {
403                    return None;
404                }
405                // The verbatim prefix is an artefact of `canonicalize` on
406                // Windows, not something a caller sent or could send — the
407                // request that produced this path spelled it `C:/x`, and
408                // echoing back `//?/C:/x` would name the same file a second
409                // way. Stripped so one file has exactly one name on the wire.
410                let text = abs.to_string_lossy();
411                return Some(strip_verbatim(&text).replace('\\', "/"));
412            }
413        };
414        let rest = abs.strip_prefix(root).ok()?;
415        let mut out = String::new();
416        for component in rest.components() {
417            if let Component::Normal(part) = component {
418                if !out.is_empty() {
419                    out.push('/');
420                }
421                out.push_str(&part.to_string_lossy());
422            }
423        }
424        Some(out)
425    }
426
427    /// `lexical_within` against whichever anchor applies.
428    fn lexically_within(&self, candidate: &Path) -> bool {
429        match &self.scope {
430            Scope::Jailed(root) => Self::lexical_within(root, candidate),
431            Scope::Machine(anchors) => anchors.iter().any(|a| Self::lexical_within(a, candidate)),
432        }
433    }
434
435    /// Whether `candidate` sits under `root` by string shape alone.
436    ///
437    /// Used only to choose between 404 and 403 for a path that does not exist,
438    /// where there is nothing on disk to canonicalise.
439    fn lexical_within(root: &Path, candidate: &Path) -> bool {
440        let mut depth: i64 = 0;
441        let Ok(rest) = candidate.strip_prefix(root) else {
442            return false;
443        };
444        for component in rest.components() {
445            match component {
446                Component::ParentDir => depth -= 1,
447                Component::Normal(_) => depth += 1,
448                _ => {}
449            }
450            if depth < 0 {
451                return false;
452            }
453        }
454        true
455    }
456}
457
458#[cfg(test)]
459mod machine_wide_tests {
460    use super::*;
461
462    /// A real file, and the absolute path a caller would name it by.
463    ///
464    /// Machine-wide scope takes absolute paths, so these cannot reuse
465    /// `root_with`'s root-relative fixtures — the point of the mode is that
466    /// there is no root to be relative to.
467    fn a_real_file() -> (tempfile::TempDir, std::path::PathBuf, String) {
468        let dir = tempfile::tempdir().expect("tempdir");
469        let file = dir.path().join("payload.txt");
470        std::fs::write(&file, b"x").expect("write");
471        // Canonicalised so the expectation matches what `resolve_existing`
472        // returns on a platform whose temp directory is reached through a
473        // symlink — the difference that made the walk test fail on macOS.
474        let canonical = file.canonicalize().expect("canonicalize");
475        // Named the way the API names it, not by hand: on Windows
476        // `canonicalize` yields a verbatim path (`\\?\C:\…`) that no caller
477        // would send and that `relative` deliberately strips.
478        let named = FsRoot::machine_wide()
479            .relative(&canonical)
480            .expect("a real file is in scope");
481        (dir, canonical, named)
482    }
483
484    #[test]
485    fn an_absolute_path_resolves() {
486        let (_dir, canonical, named) = a_real_file();
487        let scope = FsRoot::machine_wide();
488
489        assert_eq!(scope.resolve_existing(&named), Ok(canonical));
490    }
491
492    /// The mode's whole reason to exist: `--fs-root C:\` cannot reach `D:`,
493    /// because Windows has no path above its drives. If this ever regresses to
494    /// a single anchor, that limitation comes back and the file API stops
495    /// reaching where `exec` does.
496    #[test]
497    fn every_filesystem_anchor_is_in_scope() {
498        let scope = FsRoot::machine_wide();
499        let anchors = platform::filesystem_anchors();
500        assert!(!anchors.is_empty(), "a machine has at least one");
501
502        for anchor in &anchors {
503            let named = scope
504                .relative(anchor)
505                .expect("an anchor is in its own scope");
506            assert_eq!(
507                scope.resolve_existing(&named),
508                Ok(anchor.clone()),
509                "anchor {} must resolve to itself",
510                anchor.display()
511            );
512        }
513    }
514
515    /// Not silently resolved against the process's working directory: a remote
516    /// caller has no way to know what that is.
517    #[test]
518    fn a_relative_path_is_refused_rather_than_resolved_against_the_cwd() {
519        let scope = FsRoot::machine_wide();
520
521        assert_eq!(
522            scope.resolve_existing("payload.txt"),
523            Err(FsError::Malformed(
524                "path must be absolute when no --fs-root is set"
525            ))
526        );
527        // `.` names the jail's root, and there is no jail here.
528        assert!(matches!(
529            scope.resolve_existing("."),
530            Err(FsError::Malformed(_))
531        ));
532    }
533
534    /// The value echoed in responses, used as the `list` cursor, and keyed on
535    /// to detect two uploads racing for one destination — so one file must
536    /// name itself the same way regardless of the separator the caller used.
537    #[test]
538    fn one_file_gets_one_name() {
539        let (_dir, canonical, named) = a_real_file();
540        let scope = FsRoot::machine_wide();
541
542        assert_eq!(scope.resolve_existing(&named), Ok(canonical.clone()));
543        assert_eq!(scope.relative(&canonical), Some(named));
544    }
545
546    /// On Windows `C:\x` and `C:/x` name one file, so both spellings have to
547    /// resolve to one path — the upload claim key is this string, and two names
548    /// for one destination is the aliasing that lets two sessions race onto it.
549    ///
550    /// Deliberately not asserted on Unix, where it would be false: `\` is an
551    /// ordinary filename character there, not a separator, so `\tmp\x` is a
552    /// relative path naming a file called `\tmp\x` — refused rather than
553    /// silently treated as absolute. Asserting separator-independence on both
554    /// platforms is what made this test fail on Unix; the property is real, it
555    /// just belongs to Windows.
556    #[cfg(windows)]
557    #[test]
558    fn both_windows_separators_name_the_same_file() {
559        let (_dir, _canonical, named) = a_real_file();
560        let scope = FsRoot::machine_wide();
561
562        let via_forward = scope.resolve_existing(&named).expect("forward slashes");
563        let via_back = scope
564            .resolve_existing(&named.replace('/', "\\"))
565            .expect("backslashes");
566        assert_eq!(via_forward, via_back);
567    }
568
569    /// A backslash-led path is not absolute on Unix, and must not be taken for
570    /// one: silently reading it as a rooted path would resolve a request that
571    /// named a file this scope was never asked about.
572    #[cfg(unix)]
573    #[test]
574    fn a_backslash_led_path_is_not_absolute_on_unix() {
575        let scope = FsRoot::machine_wide();
576
577        assert_eq!(
578            scope.resolve_existing("\\tmp\\payload.txt"),
579            Err(FsError::Malformed(
580                "path must be absolute when no --fs-root is set"
581            ))
582        );
583    }
584
585    #[test]
586    fn a_missing_file_is_not_found_rather_than_an_escape() {
587        let (dir, _canonical, _named) = a_real_file();
588        let absent = dir.path().join("absent.txt");
589        let scope = FsRoot::machine_wide();
590
591        assert_eq!(
592            scope.resolve_existing(&absent.to_string_lossy().replace('\\', "/")),
593            Err(FsError::NotFound)
594        );
595    }
596
597    /// A UNC path is refused rather than half-supported: `\\server\share` has
598    /// no anchor in `filesystem_anchors`, and answering "not in scope" for it
599    /// while answering something else for a local path would be a difference
600    /// worth reasoning about. Named explicitly so adding UNC support later is
601    /// a deliberate act.
602    #[test]
603    fn a_unc_path_is_refused_as_malformed() {
604        let scope = FsRoot::machine_wide();
605
606        assert_eq!(
607            scope.resolve_existing("//server/share/x"),
608            Err(FsError::Malformed(
609                "UNC paths are not addressable; name a local path"
610            ))
611        );
612        assert_eq!(
613            scope.resolve_existing("\\\\server\\share\\x"),
614            Err(FsError::Malformed(
615                "UNC paths are not addressable; name a local path"
616            ))
617        );
618    }
619
620    /// `jail_path` is what every caller that needs a single directory keys on
621    /// — the audit-log containment check, the startup orphan sweep, the
622    /// staging directory. Each has to behave differently here, so returning
623    /// `None` is load-bearing rather than cosmetic.
624    #[test]
625    fn machine_wide_scope_has_no_single_path() {
626        assert!(FsRoot::machine_wide().jail_path().is_none());
627
628        let dir = tempfile::tempdir().expect("tempdir");
629        let jailed = FsRoot::new(dir.path()).expect("root");
630        assert!(jailed.jail_path().is_some());
631    }
632
633    /// The banner is the only thing telling an operator the file API now
634    /// reaches past whatever directory they started the server in.
635    #[test]
636    fn the_banner_line_names_what_is_reachable() {
637        let described = FsRoot::machine_wide().describe();
638        assert!(described.contains("whole machine"), "{described}");
639        for anchor in platform::filesystem_anchors() {
640            let readable = FsRoot::displayable(&anchor);
641            assert!(
642                described.contains(&readable),
643                "{described} must name {readable}"
644            );
645        }
646        // The verbatim prefix `canonicalize` produces on Windows is an
647        // implementation detail; a banner that printed `\\?\C:\` would be
648        // correct and unreadable.
649        assert!(!described.contains(r"\\?\"), "{described}");
650
651        let dir = tempfile::tempdir().expect("tempdir");
652        let jailed = FsRoot::new(dir.path()).expect("root");
653        assert!(!jailed.describe().contains("whole machine"));
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    fn root_with(files: &[&str]) -> (tempfile::TempDir, FsRoot) {
662        let dir = tempfile::tempdir().expect("tempdir");
663        for file in files {
664            let path = dir.path().join(file);
665            if let Some(parent) = path.parent() {
666                std::fs::create_dir_all(parent).expect("mkdir");
667            }
668            std::fs::write(&path, b"x").expect("write");
669        }
670        let root = FsRoot::new(dir.path()).expect("root");
671        (dir, root)
672    }
673
674    /// Like `root_with`, but for a test that also needs to place something
675    /// *outside* the jail (a probe file, a sibling directory, a symlink
676    /// target).
677    ///
678    /// The jail root is a subdirectory of the returned `TempDir` rather than
679    /// the `TempDir` itself, so anything a test writes as a sibling of the
680    /// root is still inside the fixture that auto-cleans on drop. Without
681    /// this, a test that panics before a manual cleanup line runs — which is
682    /// exactly what these tests are designed to do when `FsRoot` regresses —
683    /// leaks a file into the shared OS temp directory permanently.
684    fn root_with_outside(files: &[&str]) -> (tempfile::TempDir, FsRoot) {
685        let outer = tempfile::tempdir().expect("tempdir");
686        let root_dir = outer.path().join("root");
687        for file in files {
688            let path = root_dir.join(file);
689            if let Some(parent) = path.parent() {
690                std::fs::create_dir_all(parent).expect("mkdir");
691            }
692            std::fs::write(&path, b"x").expect("write");
693        }
694        let root = FsRoot::new(&root_dir).expect("root");
695        (outer, root)
696    }
697
698    /// Create a symlink for a test, tolerating the privilege some Windows
699    /// accounts and CI runners lack (`SeCreateSymbolicLinkPrivilege`).
700    ///
701    /// Returns whether the link was created. A caller uses this to skip the
702    /// test body early rather than let a missing privilege turn into a
703    /// failing suite — the check under test is about path containment, not
704    /// about the environment's symlink permissions.
705    fn try_symlink(target: &Path, link: &Path) -> bool {
706        #[cfg(unix)]
707        {
708            std::os::unix::fs::symlink(target, link).is_ok()
709        }
710        #[cfg(windows)]
711        {
712            std::os::windows::fs::symlink_file(target, link).is_ok()
713        }
714        #[cfg(not(any(unix, windows)))]
715        {
716            let _ = (target, link);
717            false
718        }
719    }
720
721    #[test]
722    fn a_file_inside_the_root_resolves() {
723        let (_dir, root) = root_with(&["app/config.json"]);
724        let resolved = root.resolve_existing("app/config.json").expect("resolve");
725        assert!(resolved.ends_with("config.json"));
726    }
727
728    #[test]
729    fn dot_dot_traversal_is_refused() {
730        let (_dir, root) = root_with(&["app/config.json"]);
731        assert_eq!(
732            root.resolve_existing("../outside.txt"),
733            Err(FsError::Escapes)
734        );
735        assert_eq!(
736            root.resolve_existing("app/../../outside.txt"),
737            Err(FsError::Escapes)
738        );
739    }
740
741    #[test]
742    fn a_filename_containing_two_dots_resolves() {
743        // Regression against the old `validate_working_dir` substring rule.
744        let (_dir, root) = root_with(&["my..file.txt"]);
745        assert!(root.resolve_existing("my..file.txt").is_ok());
746    }
747
748    #[test]
749    fn absolute_paths_are_refused() {
750        let (_dir, root) = root_with(&["app/config.json"]);
751        assert!(matches!(
752            root.resolve_existing("/etc/passwd"),
753            Err(FsError::Malformed(_))
754        ));
755        assert!(matches!(
756            root.resolve_existing("C:/Windows/System32/config"),
757            Err(FsError::Malformed(_))
758        ));
759        assert!(matches!(
760            root.resolve_existing("\\\\server\\share\\file"),
761            Err(FsError::Malformed(_))
762        ));
763    }
764
765    #[test]
766    fn reserved_and_stream_names_are_refused() {
767        let (_dir, root) = root_with(&["app/config.json"]);
768        assert!(matches!(
769            root.resolve_existing("NUL"),
770            Err(FsError::Malformed(_))
771        ));
772        assert!(matches!(
773            root.resolve_existing("app/config.json:hidden"),
774            Err(FsError::Malformed(_))
775        ));
776    }
777
778    #[test]
779    fn a_missing_file_inside_the_root_is_not_found() {
780        let (_dir, root) = root_with(&["app/config.json"]);
781        assert_eq!(
782            root.resolve_existing("app/absent.json"),
783            Err(FsError::NotFound)
784        );
785    }
786
787    #[test]
788    fn a_single_dot_names_the_root_itself() {
789        // `list` needs to enumerate the root; without this there is no way to
790        // name it at all.
791        let (_dir, root) = root_with(&["app/config.json"]);
792        assert_eq!(
793            root.resolve_existing("."),
794            Ok(root.jail_path().expect("jailed").to_path_buf())
795        );
796
797        // An empty path stays an error: "the whole tree" must be asked for
798        // explicitly, never by omission.
799        assert!(matches!(
800            root.resolve_existing(""),
801            Err(FsError::Malformed(_))
802        ));
803
804        // The root is not a creatable target.
805        assert!(root.resolve_for_create(".").is_err());
806    }
807
808    #[test]
809    fn an_escape_looks_the_same_whether_or_not_the_target_exists() {
810        // The oracle this guards against: if a caller can tell "outside and
811        // real" from "outside and absent", the jail reports on the filesystem
812        // beyond it.
813        let (outer, root) = root_with_outside(&["app/config.json"]);
814
815        let present = outer.path().join("st-probe-present.txt");
816        std::fs::write(&present, b"secret").expect("write probe");
817
818        let existing = root.resolve_existing("../st-probe-present.txt");
819        let absent = root.resolve_existing("../st-probe-absent.txt");
820
821        assert_eq!(existing, Err(FsError::Escapes));
822        assert_eq!(absent, Err(FsError::Escapes));
823        assert_eq!(existing, absent, "the refusal must not reveal existence");
824    }
825
826    #[test]
827    fn an_escape_through_an_existing_directory_is_refused() {
828        // Exercises the walk's containment check directly rather than the
829        // lexical fallback: every component here resolves to something that
830        // is really on disk, so the "missing" branch never trips and the
831        // verdict can only come from `!resolved.starts_with(&self.root)`. If
832        // that check were removed, this would resolve successfully to a real
833        // file outside the jail instead of failing.
834        let (outer, root) = root_with_outside(&["app/config.json"]);
835        let sibling = outer.path().join("st-sibling-dir");
836        std::fs::create_dir_all(&sibling).expect("mkdir sibling");
837        std::fs::write(sibling.join("target.txt"), b"secret").expect("write sibling file");
838
839        let result = root.resolve_existing("app/../../st-sibling-dir/target.txt");
840
841        assert_eq!(result, Err(FsError::Escapes));
842    }
843
844    #[test]
845    fn a_dangling_symlink_pointing_outside_the_root_is_refused_by_resolve_existing() {
846        // `canonicalize` fails outright on a dangling link, handing back
847        // nothing to decide containment from — the exact gap that let a
848        // dangling link into a missing outside target through as `NotFound`
849        // instead of `Escapes`.
850        let (outer, root) = root_with_outside(&["app/config.json"]);
851        let link = root.jail_path().expect("jailed").join("dangle-existing");
852        let missing_target = outer.path().join("st-dangling-target.txt"); // never created
853
854        if !try_symlink(&missing_target, &link) {
855            return; // symlink privilege unavailable on this runner; skip
856        }
857
858        assert_eq!(
859            root.resolve_existing("dangle-existing"),
860            Err(FsError::Escapes)
861        );
862    }
863
864    #[test]
865    fn a_dangling_symlink_pointing_outside_the_root_is_refused_by_resolve_for_create() {
866        // Load-bearing rather than merely tidy: `resolve_for_create` feeds
867        // upload destinations, so handing back a path through this link would
868        // mean the write itself lands outside the root.
869        let (outer, root) = root_with_outside(&["app/config.json"]);
870        let link = root.jail_path().expect("jailed").join("dangle-create");
871        let missing_target = outer.path().join("st-dangling-target-2.txt"); // never created
872
873        if !try_symlink(&missing_target, &link) {
874            return; // symlink privilege unavailable on this runner; skip
875        }
876
877        assert_eq!(
878            root.resolve_for_create("dangle-create/new.bin"),
879            Err(FsError::Escapes)
880        );
881    }
882
883    #[test]
884    fn a_create_target_need_not_exist_yet() {
885        let (_dir, root) = root_with(&["app/config.json"]);
886        let target = root
887            .resolve_for_create("app/new.bin")
888            .expect("create target");
889        assert!(target.ends_with("new.bin"));
890        assert!(!target.exists());
891    }
892
893    #[test]
894    fn a_create_target_may_not_escape_through_a_missing_segment() {
895        let (_dir, root) = root_with(&["app/config.json"]);
896        assert!(matches!(
897            root.resolve_for_create("app/../../escape.bin"),
898            Err(FsError::Escapes) | Err(FsError::Malformed(_))
899        ));
900    }
901
902    #[test]
903    fn relative_renders_posix_separators() {
904        let (_dir, root) = root_with(&["app/config.json"]);
905        let abs = root.resolve_existing("app/config.json").expect("resolve");
906        assert_eq!(root.relative(&abs).as_deref(), Some("app/config.json"));
907    }
908
909    #[cfg(unix)]
910    #[test]
911    fn a_symlink_out_of_the_root_is_refused() {
912        let (dir, root) = root_with(&["app/config.json"]);
913        let outside = dir
914            .path()
915            .parent()
916            .expect("parent")
917            .join("st-outside-target");
918        std::fs::write(&outside, b"secret").expect("write outside");
919        std::os::unix::fs::symlink(&outside, dir.path().join("link")).expect("symlink");
920
921        assert_eq!(root.resolve_existing("link"), Err(FsError::Escapes));
922
923        std::fs::remove_file(&outside).ok();
924    }
925}