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}