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