ferridriver_script/
modules.rs1use 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#[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 fn join_relative(&self, base: &str, name: &str) -> PathBuf {
44 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 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 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#[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 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 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}