Skip to main content

hyperlight_js/
resolver.rs

1//! Module resolution and loading implementations.
2//!
3//! This module provides the core abstractions and implementations for loading
4//! JavaScript modules into the sandbox environment.
5
6use std::path::{Path, PathBuf};
7
8pub use oxc_resolver::{FileMetadata, FileSystem, ResolveError};
9use phf::Map;
10
11/// File system implementation that uses embedded modules compiled into the binary.
12///
13/// This implementation stores all module contents in a compile-time perfect hash map,
14/// eliminating any runtime file system access. This is the basic secure option for
15/// module loading as it provides a completely closed set of available modules without
16/// filesystem access.
17///
18/// # Example
19///
20/// ```no_run
21/// use hyperlight_js::embed_modules;
22///
23/// let fs = embed_modules! {
24///     "math.js" => "../tests/fixtures/math.js",
25///     "strings.js" => "../tests/fixtures/strings.js",
26/// };
27///
28/// ```
29#[derive(Clone, Copy)]
30pub struct FileSystemEmbedded {
31    modules: &'static Map<&'static str, &'static str>,
32}
33
34impl FileSystemEmbedded {
35    /// Create a new embedded file system with the given module map.
36    ///
37    /// See the `embed_modules!` macro for an easier way to create
38    pub const fn new(modules: &'static Map<&'static str, &'static str>) -> Self {
39        Self { modules }
40    }
41
42    /// Normalize a path for consistent lookups.
43    fn normalize_path<'a>(&self, path: &'a Path) -> Option<std::borrow::Cow<'a, str>> {
44        let s = path.to_str()?;
45
46        if s.contains('\\') || s.starts_with("./") || s.starts_with('/') {
47            Some(std::borrow::Cow::Owned(
48                s.replace('\\', "/")
49                    .trim_start_matches("./")
50                    .trim_start_matches('/')
51                    .to_string(),
52            ))
53        } else {
54            Some(std::borrow::Cow::Borrowed(s))
55        }
56    }
57
58    /// Check if a normalized path represents a directory by seeing if any
59    /// embedded modules have this path as a prefix.
60    fn is_directory(&self, normalized: &str) -> bool {
61        if normalized.is_empty() {
62            return !self.modules.is_empty();
63        }
64
65        let prefix = format!("{}/", normalized);
66        self.modules.keys().any(|key| key.starts_with(&prefix))
67    }
68}
69
70impl FileSystem for FileSystemEmbedded {
71    fn new() -> Self {
72        unreachable!("Use embed_modules! macro to create FileSystemEmbedded");
73    }
74
75    fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
76        self.read_to_string(path).map(|s| s.into_bytes())
77    }
78
79    fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
80        let normalized = self.normalize_path(path).ok_or_else(|| {
81            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid UTF-8 in path")
82        })?;
83
84        self.modules
85            .get(&normalized)
86            .map(|&content| content.to_string())
87            .ok_or_else(|| {
88                std::io::Error::new(
89                    std::io::ErrorKind::NotFound,
90                    format!("Module '{}' not found", normalized),
91                )
92            })
93    }
94
95    fn metadata(&self, path: &Path) -> std::io::Result<FileMetadata> {
96        let normalized = self.normalize_path(path).ok_or_else(|| {
97            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid UTF-8 in path")
98        })?;
99
100        let is_file = self.modules.contains_key(normalized.as_ref());
101        let is_dir = self.is_directory(normalized.as_ref());
102
103        if !is_file && !is_dir {
104            return Err(std::io::Error::new(
105                std::io::ErrorKind::NotFound,
106                format!("Path '{}' not found", normalized),
107            ));
108        }
109
110        Ok(FileMetadata::new(
111            is_file, is_dir, false, /* is_symlink */
112        ))
113    }
114
115    fn symlink_metadata(&self, path: &Path) -> std::io::Result<FileMetadata> {
116        self.metadata(path)
117    }
118
119    fn read_link(&self, _path: &Path) -> Result<PathBuf, ResolveError> {
120        Err(std::io::Error::new(
121            std::io::ErrorKind::InvalidInput,
122            "symlinks are not supported in embedded file system",
123        )
124        .into())
125    }
126
127    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
128        self.normalize_path(path)
129            .ok_or_else(|| {
130                std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid UTF-8 in path")
131            })
132            .map(|v| PathBuf::from(v.into_owned()))
133    }
134}
135
136/// Macro to create an embedded file system with compile-time included modules.
137///
138/// This macro simplifies the creation of an embedded file system by automatically
139/// generating the `phf_map` and wrapping it in a `FileSystemEmbedded`.
140///
141/// # Example
142///
143/// ```text
144/// embed_modules! {
145///     "module_path" => "file_path",
146///     "another_module" => "another_file",
147///     ...
148/// }
149/// ```
150///
151/// ```no_run
152/// use hyperlight_js::SandboxBuilder;
153/// use hyperlight_js::embed_modules;
154///
155/// let fs = embed_modules! {
156///     "math.js" => "../tests/fixtures/math.js",
157///     "strings.js" => "../tests/fixtures/strings.js",
158/// };
159///
160/// let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
161/// let sandbox = proto_js_sandbox
162///     .set_module_loader(fs)
163///     .unwrap()
164///     .load_runtime()
165///     .unwrap();
166/// ```
167///
168/// // With inline content:
169/// let fs = embed_modules! {
170///     "test.js" => @inline "console.log('test');",
171/// };
172///
173/// # Notes
174///
175/// * File paths are relative to the current file
176#[macro_export]
177macro_rules! embed_modules {
178    // Match file: prefix
179    ($($key:expr => $file:expr),* $(,)?) => {{
180        use $crate::FileSystemEmbedded;
181        use ::phf::{phf_map, Map};
182
183        static EMBEDDED_MODULES: Map<&'static str, &'static str> = phf_map! {
184            $(
185                $key => include_str!($file),
186            )*
187        };
188
189        FileSystemEmbedded::new(&EMBEDDED_MODULES)
190    }};
191
192    // Match @inline prefix
193    ($($key:expr => @inline $content:expr),* $(,)?) => {{
194        use $crate::FileSystemEmbedded;
195        use ::phf::{phf_map, Map};
196
197        static EMBEDDED_MODULES: Map<&'static str, &'static str> = phf_map! {
198            $(
199                $key => $content,
200            )*
201        };
202
203        FileSystemEmbedded::new(&EMBEDDED_MODULES)
204    }};
205}
206
207#[cfg(test)]
208mod tests {
209    use std::path::Path;
210
211    use super::*;
212
213    #[test]
214    fn test_file_read() {
215        let fs = embed_modules! {
216            "test.js" => @inline "console.log('hello');",
217        };
218
219        let content = fs.read_to_string(Path::new("test.js")).unwrap();
220        assert_eq!(content, "console.log('hello');");
221    }
222
223    #[test]
224    fn test_directory_detection() {
225        let fs = embed_modules! {
226            "foo/bar.js" => @inline "content",
227        };
228
229        let metadata = fs.metadata(Path::new("foo")).unwrap();
230        assert!(metadata.is_dir());
231        assert!(!metadata.is_file());
232    }
233
234    #[test]
235    fn test_file_metadata() {
236        let fs = embed_modules! {
237            "test.js" => @inline "content",
238        };
239
240        let metadata = fs.metadata(Path::new("test.js")).unwrap();
241        assert!(metadata.is_file());
242        assert!(!metadata.is_dir());
243    }
244
245    #[test]
246    fn test_prefix_collision() {
247        let fs = embed_modules! {
248            "foo.js" => @inline "content1",
249            "foobar.js" => @inline "content2",
250        };
251
252        assert!(fs.metadata(Path::new("foo")).is_err());
253        assert!(fs.metadata(Path::new("foo.js")).unwrap().is_file());
254    }
255
256    #[test]
257    fn test_not_found() {
258        let fs = embed_modules! {
259            "exists.js" => @inline "content",
260        };
261
262        let result = fs.read_to_string(Path::new("missing.js"));
263        assert!(result.is_err());
264    }
265}