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}