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