Skip to main content

ferridriver_script/
modules.rs

1//! Module loader rooted at the `script_root` sandbox.
2//!
3//! Scripts can import other JS files via ES module syntax:
4//!
5//! ```js
6//! import { helper } from './helpers.js';
7//! import data from './fixtures/users.js';
8//! ```
9//!
10//! All import paths are:
11//! 1. Resolved relative to the importing module's directory (or the sandbox
12//!    root for inline scripts with no base).
13//! 2. Validated against the [`PathSandbox`] just like `fs.readFile` — absolute
14//!    paths, `..` components, and symlink escapes are rejected.
15//! 3. Loaded from disk via rquickjs's built-in `ScriptLoader` (`.js` by
16//!    default; `.mjs` also accepted).
17//!
18//! Bare specifiers (e.g. `import lodash from 'lodash'`) are rejected — there
19//! is no node_modules resolution on purpose, the sandbox is self-contained.
20
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use rquickjs::{Ctx, Error, Module, Result, loader::Loader, loader::Resolver, module::Declared};
25
26use crate::fs::PathSandbox;
27
28/// Path-sanitising resolver that maps ES module specifiers to absolute paths
29/// inside the sandbox root.
30#[derive(Debug, Clone)]
31pub struct SandboxResolver {
32  sandbox: Arc<PathSandbox>,
33}
34
35impl SandboxResolver {
36  #[must_use]
37  pub fn new(sandbox: Arc<PathSandbox>) -> Self {
38    Self { sandbox }
39  }
40
41  /// Resolve `name` against `base` and return the path relative to the
42  /// sandbox root (without canonicalising — that happens in [`PathSandbox`]).
43  fn join_relative(&self, base: &str, name: &str) -> PathBuf {
44    // `base` is an absolute path inside the sandbox root (what a previous
45    // resolve returned) or empty for inline scripts. Take its parent dir and
46    // append `name`; if base is empty we're at the sandbox root.
47    let base_dir: PathBuf = if base.is_empty() {
48      self.sandbox.root().to_path_buf()
49    } else {
50      Path::new(base)
51        .parent()
52        .map_or_else(|| self.sandbox.root().to_path_buf(), PathBuf::from)
53    };
54    base_dir.join(name)
55  }
56}
57
58impl Resolver for SandboxResolver {
59  fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> Result<String> {
60    // Reject bare specifiers up front — we don't support node_modules or
61    // package resolution. Only relative (`./x`, `../x`) and explicit absolute
62    // paths inside the sandbox are allowed; the latter is still rejected
63    // syntactically by PathSandbox::resolve_read.
64    if !(name.starts_with("./") || name.starts_with("../") || name.starts_with('/')) {
65      return Err(Error::new_loading_message(
66        name,
67        "bare module specifiers are not supported inside the sandbox",
68      ));
69    }
70
71    let joined = self.join_relative(base, name);
72
73    // Re-express as a path relative to the sandbox root so PathSandbox's
74    // sanitizer runs — it canonicalises and verifies no escape.
75    let rel = joined
76      .strip_prefix(self.sandbox.root())
77      .unwrap_or(&joined)
78      .to_string_lossy()
79      .into_owned();
80
81    let resolved = self
82      .sandbox
83      .resolve_read(&rel)
84      .map_err(|e| Error::new_loading_message(name, e.message.clone()))?;
85
86    Ok(resolved.to_string_lossy().into_owned())
87  }
88}
89
90/// Loader wrapper that accepts only paths the [`SandboxResolver`] produced.
91///
92/// rquickjs splits resolution from loading; we re-check path containment here
93/// so a future resolver bug cannot smuggle a path outside the sandbox into
94/// the loader. Reading the file itself goes through `tokio::fs` synchronously
95/// via `std::fs::read` — same as rquickjs's built-in `ScriptLoader`.
96#[derive(Debug, Clone)]
97pub struct SandboxLoader {
98  sandbox: Arc<PathSandbox>,
99}
100
101impl SandboxLoader {
102  #[must_use]
103  pub fn new(sandbox: Arc<PathSandbox>) -> Self {
104    Self { sandbox }
105  }
106}
107
108impl Loader for SandboxLoader {
109  fn load<'js>(&mut self, ctx: &Ctx<'js>, name: &str) -> Result<Module<'js, Declared>> {
110    // Defensive check: the resolver should have returned a path inside the
111    // sandbox, but verify before reading from disk.
112    let path = Path::new(name);
113    if !path.starts_with(self.sandbox.root()) {
114      return Err(Error::new_loading_message(name, "path escapes script_root"));
115    }
116
117    let allowed_ext = matches!(path.extension().and_then(|e| e.to_str()), Some("js" | "mjs"));
118    if !allowed_ext {
119      return Err(Error::new_loading_message(
120        name,
121        "only .js and .mjs modules are supported",
122      ));
123    }
124
125    let source = std::fs::read(path).map_err(|e| Error::new_loading_message(name, e.to_string()))?;
126    Module::declare(ctx.clone(), name, source)
127  }
128}
129
130#[cfg(test)]
131mod tests {
132  use super::*;
133
134  fn mk_sandbox() -> (tempfile::TempDir, Arc<PathSandbox>) {
135    let tmp = tempfile::tempdir().expect("tempdir");
136    let sb = Arc::new(PathSandbox::new(tmp.path()).expect("sandbox"));
137    (tmp, sb)
138  }
139
140  #[test]
141  fn resolver_rejects_bare_specifiers() {
142    let (_tmp, sb) = mk_sandbox();
143    let mut r = SandboxResolver::new(sb);
144    // We need a Ctx but the resolver only uses it in its trait contract for
145    // error construction — construct a fresh runtime to get one.
146    let rt = rquickjs::Runtime::new().expect("runtime");
147    let cx = rquickjs::Context::full(&rt).expect("context");
148    cx.with(|ctx| {
149      let err = r.resolve(&ctx, "", "lodash").unwrap_err();
150      assert!(err.to_string().contains("bare module"));
151    });
152  }
153
154  #[test]
155  fn resolver_rejects_traversal() {
156    let (_tmp, sb) = mk_sandbox();
157    let mut r = SandboxResolver::new(sb);
158    let rt = rquickjs::Runtime::new().expect("runtime");
159    let cx = rquickjs::Context::full(&rt).expect("context");
160    cx.with(|ctx| {
161      let err = r.resolve(&ctx, "", "../escape.js").unwrap_err();
162      assert!(err.is_loading());
163    });
164  }
165
166  #[test]
167  fn resolver_accepts_valid_relative() {
168    let (tmp, sb) = mk_sandbox();
169    std::fs::write(tmp.path().join("helper.js"), b"export const x = 1;").unwrap();
170    let mut r = SandboxResolver::new(sb);
171    let rt = rquickjs::Runtime::new().expect("runtime");
172    let cx = rquickjs::Context::full(&rt).expect("context");
173    cx.with(|ctx| {
174      let resolved = r.resolve(&ctx, "", "./helper.js").expect("resolve");
175      assert!(resolved.ends_with("helper.js"));
176    });
177  }
178}