Skip to main content

ferridriver_script/
fs.rs

1//! Scoped filesystem module exposed to scripts as `fs`.
2//!
3//! Every path passed in from JS is validated against a root directory:
4//!
5//! 1. Reject absolute paths — only paths relative to the root are accepted.
6//! 2. Reject any `..` component in the requested path.
7//! 3. Canonicalise the final path and verify the result stays inside the
8//!    canonicalised root (rejects symlinks that escape the root).
9//!
10//! The canonicalisation happens at the parent directory for write operations
11//! (the target file may not exist yet) and at the target itself for reads.
12
13use std::path::{Component, Path, PathBuf};
14
15use crate::error::ScriptError;
16
17/// Enforces sandbox containment for paths used by the `fs` module.
18///
19/// Cheap to clone — only holds the canonicalised root.
20#[derive(Debug, Clone)]
21pub struct PathSandbox {
22  root: PathBuf,
23}
24
25impl PathSandbox {
26  /// Build a sandbox rooted at `root`. The root is canonicalised up front,
27  /// so subsequent containment checks do not need to re-resolve it.
28  pub fn new(root: impl AsRef<Path>) -> Result<Self, ScriptError> {
29    let root = root.as_ref();
30    let canonical = std::fs::canonicalize(root)
31      .map_err(|e| ScriptError::internal(format!("script_root {} is not a valid directory: {e}", root.display())))?;
32    if !canonical.is_dir() {
33      return Err(ScriptError::internal(format!(
34        "script_root {} is not a directory",
35        canonical.display()
36      )));
37    }
38    Ok(Self { root: canonical })
39  }
40
41  /// Root directory that all paths must stay inside.
42  #[must_use]
43  pub fn root(&self) -> &Path {
44    &self.root
45  }
46
47  /// Validate a path for a **read** operation.
48  ///
49  /// The path must exist and, after canonicalisation, live under the root.
50  pub fn resolve_read(&self, rel: &str) -> Result<PathBuf, ScriptError> {
51    let candidate = Self::syntactic_check(rel)?;
52    let full = self.root.join(candidate);
53    let canonical = std::fs::canonicalize(&full)
54      .map_err(|e| ScriptError::sandbox(format!("fs: cannot resolve {}: {e}", full.display())))?;
55    if !canonical.starts_with(&self.root) {
56      return Err(ScriptError::sandbox(format!(
57        "fs: path escapes script_root: {}",
58        canonical.display()
59      )));
60    }
61    Ok(canonical)
62  }
63
64  /// Validate a path for a **write** operation.
65  ///
66  /// The target file may not exist yet, so canonicalisation is applied to
67  /// the parent directory; the final filename is appended unchanged and
68  /// validated not to contain separators itself.
69  pub fn resolve_write(&self, rel: &str) -> Result<PathBuf, ScriptError> {
70    let candidate = Self::syntactic_check(rel)?;
71    let full = self.root.join(&candidate);
72    let parent = full
73      .parent()
74      .ok_or_else(|| ScriptError::sandbox(format!("fs: path has no parent directory: {}", full.display())))?;
75    if !parent.exists() {
76      std::fs::create_dir_all(parent)
77        .map_err(|e| ScriptError::sandbox(format!("fs: cannot create parent directory: {e}")))?;
78    }
79    let canonical_parent = std::fs::canonicalize(parent)
80      .map_err(|e| ScriptError::sandbox(format!("fs: cannot resolve parent directory {}: {e}", parent.display())))?;
81    if !canonical_parent.starts_with(&self.root) {
82      return Err(ScriptError::sandbox(format!(
83        "fs: parent directory escapes script_root: {}",
84        canonical_parent.display()
85      )));
86    }
87    let Some(name) = full.file_name() else {
88      return Err(ScriptError::sandbox("fs: path has no filename"));
89    };
90    let target = canonical_parent.join(name);
91    // The parent is canonicalised, but the final component is appended
92    // unverified. If it is itself a symlink, `tokio::fs::write` would
93    // follow it and clobber a file outside the sandbox. Reject it: the
94    // sandbox never lets a script create symlinks, so a symlink here
95    // was pre-seeded and is always an escape attempt.
96    if let Ok(meta) = std::fs::symlink_metadata(&target)
97      && meta.file_type().is_symlink()
98    {
99      return Err(ScriptError::sandbox(format!(
100        "fs: refusing to write through symlink: {}",
101        target.display()
102      )));
103    }
104    Ok(target)
105  }
106
107  fn syntactic_check(rel: &str) -> Result<PathBuf, ScriptError> {
108    if rel.is_empty() {
109      return Err(ScriptError::sandbox("fs: empty path"));
110    }
111    let path = Path::new(rel);
112    if path.is_absolute() {
113      return Err(ScriptError::sandbox(format!("fs: absolute paths not allowed: {rel}")));
114    }
115    for component in path.components() {
116      match component {
117        Component::ParentDir => {
118          return Err(ScriptError::sandbox(format!(
119            "fs: path traversal (..) not allowed: {rel}"
120          )));
121        },
122        Component::Prefix(_) | Component::RootDir => {
123          return Err(ScriptError::sandbox(format!("fs: path must be relative: {rel}")));
124        },
125        Component::CurDir | Component::Normal(_) => {},
126      }
127    }
128    Ok(path.to_path_buf())
129  }
130}
131
132#[cfg(test)]
133mod tests {
134  use super::*;
135
136  fn tmp_sandbox() -> (tempfile::TempDir, PathSandbox) {
137    let tmp = tempfile::tempdir().expect("tempdir");
138    let sb = PathSandbox::new(tmp.path()).expect("sandbox");
139    (tmp, sb)
140  }
141
142  #[test]
143  fn rejects_absolute_path() {
144    let (_tmp, sb) = tmp_sandbox();
145    assert!(sb.resolve_read("/etc/passwd").is_err());
146    assert!(sb.resolve_write("/tmp/out.txt").is_err());
147  }
148
149  #[test]
150  fn rejects_parent_dir() {
151    let (_tmp, sb) = tmp_sandbox();
152    assert!(sb.resolve_read("../escape").is_err());
153    assert!(sb.resolve_read("nested/../../escape").is_err());
154    assert!(sb.resolve_write("../escape").is_err());
155  }
156
157  #[test]
158  fn rejects_empty() {
159    let (_tmp, sb) = tmp_sandbox();
160    assert!(sb.resolve_read("").is_err());
161    assert!(sb.resolve_write("").is_err());
162  }
163
164  #[test]
165  fn resolves_valid_read() {
166    let (tmp, sb) = tmp_sandbox();
167    std::fs::write(tmp.path().join("ok.txt"), b"hello").unwrap();
168    let resolved = sb.resolve_read("ok.txt").expect("resolve");
169    assert!(resolved.starts_with(sb.root()));
170    assert_eq!(resolved.file_name().unwrap(), "ok.txt");
171  }
172
173  #[test]
174  fn resolves_valid_write_creates_parent() {
175    let (tmp, sb) = tmp_sandbox();
176    let resolved = sb.resolve_write("nested/deep/new.txt").expect("resolve");
177    assert!(resolved.starts_with(sb.root()));
178    assert!(tmp.path().join("nested/deep").is_dir());
179  }
180
181  #[cfg(unix)]
182  #[test]
183  fn rejects_symlink_write_final_component() {
184    use std::os::unix::fs::symlink;
185    let (tmp, sb) = tmp_sandbox();
186    let outside = tempfile::tempdir().unwrap();
187    // A pre-seeded symlink at the write target pointing outside the
188    // sandbox must NOT be writable through (the parent is in-root but
189    // the final component escapes).
190    symlink(outside.path().join("escape.txt"), tmp.path().join("out.txt")).unwrap();
191    let err = sb.resolve_write("out.txt").unwrap_err();
192    assert_eq!(err.kind, crate::error::ScriptErrorKind::SandboxViolation);
193    assert!(
194      !outside.path().join("escape.txt").exists(),
195      "must not have created the target"
196    );
197  }
198
199  #[cfg(unix)]
200  #[test]
201  fn rejects_symlink_escape() {
202    use std::os::unix::fs::symlink;
203    let (tmp, sb) = tmp_sandbox();
204    let outside = tempfile::tempdir().unwrap();
205    std::fs::write(outside.path().join("secret"), b"nope").unwrap();
206    symlink(outside.path().join("secret"), tmp.path().join("link")).unwrap();
207    let err = sb.resolve_read("link").unwrap_err();
208    assert_eq!(err.kind, crate::error::ScriptErrorKind::SandboxViolation);
209  }
210}