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