Skip to main content

boa_engine/module/loader/
mod.rs

1use std::any::Any;
2use std::cell::RefCell;
3use std::path::{Component, Path, PathBuf};
4use std::rc::Rc;
5
6use dynify::dynify;
7use rustc_hash::FxHashMap;
8
9use boa_gc::GcRefCell;
10use boa_parser::Source;
11
12use crate::script::Script;
13use crate::{
14    Context, JsError, JsNativeError, JsResult, JsString, js_error, js_string, object::JsObject,
15    realm::Realm, vm::ActiveRunnable,
16};
17
18use super::Module;
19use crate::module::{ImportAttribute, ModuleRequest};
20
21pub mod embedded;
22
23/// Resolves paths from the referrer and the specifier, normalize the paths and ensure the path
24/// is within a base. If the base is empty, that last verification will be skipped.
25///
26/// The returned specifier is a resolved absolute path that is guaranteed to be
27/// a descendant of `base`. All path component that are either empty or `.` and
28/// `..` have been resolved.
29///
30/// # Errors
31/// This predicate will return an error if the specifier is relative but the referrer
32/// does not have a path, or if the resolved path is outside `base`.
33///
34/// # Examples
35/// ```
36/// #[cfg(target_family = "unix")]
37/// # {
38/// # use std::path::Path;
39/// # use boa_engine::{Context, js_string};
40/// # use boa_engine::module::resolve_module_specifier;
41/// assert_eq!(
42///     resolve_module_specifier(
43///         Some(Path::new("/base")),
44///         &js_string!("../a.js"),
45///         Some(Path::new("/base/hello/ref.js")),
46///         &mut Context::default()
47///     ),
48///     Ok("/base/a.js".into())
49/// );
50/// # }
51/// ```
52pub fn resolve_module_specifier(
53    base: Option<&Path>,
54    specifier: &JsString,
55    referrer: Option<&Path>,
56    _context: &mut Context,
57) -> JsResult<PathBuf> {
58    let base_path = base.map_or_else(|| PathBuf::from(""), PathBuf::from);
59    let referrer_dir = referrer.and_then(|p| p.parent());
60
61    let specifier = specifier.to_std_string_escaped();
62
63    // On Windows, also replace `/` with `\`. JavaScript imports use `/` as path separator.
64    #[cfg(target_family = "windows")]
65    let specifier = cow_utils::CowUtils::cow_replace(&*specifier, '/', "\\");
66
67    let short_path = Path::new(&*specifier);
68
69    // In ECMAScript, a path is considered relative if it starts with
70    // `./` or `../`. In Rust it's any path that start with `/`.
71    let is_relative = short_path.starts_with(".") || short_path.starts_with("..");
72
73    let long_path = if is_relative {
74        if let Some(r_path) = referrer_dir {
75            base_path.join(r_path).join(short_path)
76        } else {
77            return Err(js_error!(TypeError: "relative path without referrer"));
78        }
79    } else {
80        base_path.join(&*specifier)
81    };
82
83    if long_path.is_relative() && base.is_some() {
84        return Err(js_error!(TypeError: "resolved path is relative"));
85    }
86
87    // Normalize the path. We cannot use `canonicalize` here because it will fail
88    // if the path doesn't exist.
89    let path = long_path
90        .components()
91        .filter(|c| c != &Component::CurDir || c == &Component::Normal("".as_ref()))
92        .try_fold(PathBuf::new(), |mut acc, c| {
93            if c == Component::ParentDir {
94                if acc.as_os_str().is_empty() {
95                    return Err(js_error!(TypeError: "path is outside the module root"));
96                }
97                acc.pop();
98            } else {
99                acc.push(c);
100            }
101            Ok(acc)
102        })?;
103
104    if path.starts_with(&base_path) {
105        Ok(path)
106    } else {
107        Err(js_error!(TypeError: "path is outside the module root"))
108    }
109}
110
111/// The referrer from which a load request of a module originates.
112#[derive(Debug, Clone)]
113pub enum Referrer {
114    /// A [**Source Text Module Record**](https://tc39.es/ecma262/#sec-source-text-module-records).
115    Module(Module),
116    /// A [**Realm**](https://tc39.es/ecma262/#sec-code-realms).
117    Realm(Realm),
118    /// A [**Script Record**](https://tc39.es/ecma262/#sec-script-records)
119    Script(Script),
120}
121
122impl Referrer {
123    /// Gets the path of the referrer, if it has one.
124    #[must_use]
125    pub fn path(&self) -> Option<&Path> {
126        match self {
127            Self::Module(module) => module.path(),
128            Self::Realm(_) => None,
129            Self::Script(script) => script.path(),
130        }
131    }
132}
133
134impl From<ActiveRunnable> for Referrer {
135    fn from(value: ActiveRunnable) -> Self {
136        match value {
137            ActiveRunnable::Script(script) => Self::Script(script),
138            ActiveRunnable::Module(module) => Self::Module(module),
139        }
140    }
141}
142
143/// Module loading related host hooks.
144///
145/// This trait allows to customize the behaviour of the engine on module load requests and
146/// `import.meta` requests.
147#[dynify]
148pub trait ModuleLoader: Any {
149    /// Host hook [`HostLoadImportedModule ( referrer, specifier, hostDefined, payload )`][spec].
150    ///
151    /// This hook allows to customize the module loading functionality of the engine. Technically,
152    /// this should call the [`FinishLoadingImportedModule`][finish] operation, but this simpler API just provides
153    /// an async method that replaces `FinishLoadingImportedModule`.
154    ///
155    /// # Requirements
156    ///
157    /// - The host environment must perform `FinishLoadingImportedModule(referrer, specifier, payload, result)`,
158    ///   where result is either a normal completion containing the loaded Module Record or a throw
159    ///   completion, either synchronously or asynchronously. This is replaced by simply returning from
160    ///   the method.
161    /// - If this operation is called multiple times with the same `(referrer, specifier)` pair and
162    ///   it performs FinishLoadingImportedModule(referrer, specifier, payload, result) where result
163    ///   is a normal completion, then it must perform
164    ///   `FinishLoadingImportedModule(referrer, specifier, payload, result)` with the same result each
165    ///   time.
166    /// - The operation must treat payload as an opaque value to be passed through to
167    ///   `FinishLoadingImportedModule`. (can be ignored)
168    ///
169    /// [spec]: https://tc39.es/ecma262/#sec-HostLoadImportedModule
170    /// [finish]: https://tc39.es/ecma262/#sec-FinishLoadingImportedModule
171    #[allow(async_fn_in_trait, reason = "all our APIs are single-threaded")]
172    async fn load_imported_module(
173        self: Rc<Self>,
174        referrer: Referrer,
175        request: ModuleRequest,
176        context: &RefCell<&mut Context>,
177    ) -> JsResult<Module>;
178
179    /// Host hooks [`HostGetImportMetaProperties ( moduleRecord )`][meta] and
180    /// [`HostFinalizeImportMeta ( importMeta, moduleRecord )`][final].
181    ///
182    /// This unifies both APIs into a single hook that can be overridden on both cases.
183    /// The most common usage is to add properties to `import_meta` and return, but this also
184    /// allows modifying the import meta object in more exotic ways before exposing it to ECMAScript
185    /// code.
186    ///
187    /// The default implementation of `HostGetImportMetaProperties` is to return a new empty List.
188    ///
189    /// [meta]: https://tc39.es/ecma262/#sec-hostgetimportmetaproperties
190    /// [final]: https://tc39.es/ecma262/#sec-hostfinalizeimportmeta
191    #[allow(unused_variables, reason = "this should be overridden by implementors")]
192    fn init_import_meta(
193        self: Rc<Self>,
194        import_meta: &JsObject,
195        module: &Module,
196        context: &mut Context,
197    ) {
198    }
199}
200
201/// A module loader that throws when trying to load any modules.
202///
203/// Useful to disable the module system on platforms that don't have a filesystem, for example.
204#[derive(Debug, Clone, Copy)]
205pub struct IdleModuleLoader;
206
207impl ModuleLoader for IdleModuleLoader {
208    async fn load_imported_module(
209        self: Rc<Self>,
210        _referrer: Referrer,
211        _request: ModuleRequest,
212        _context: &RefCell<&mut Context>,
213    ) -> JsResult<Module> {
214        Err(JsNativeError::typ()
215            .with_message("module resolution is disabled for this context")
216            .into())
217    }
218}
219
220/// A module loader that uses a map of specifier -> Module to resolve.
221/// If the module was not registered, it will not be resolved.
222///
223/// A resolution relative to the referrer is performed when loading a
224/// module.
225#[derive(Default, Debug, Clone)]
226pub struct MapModuleLoader {
227    inner: RefCell<FxHashMap<PathBuf, Module>>,
228}
229
230impl MapModuleLoader {
231    /// Creates an empty map module loader.
232    #[must_use]
233    #[inline]
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// Insert or replace a mapping in the inner map, returning any previous module
239    /// if there was one.
240    #[inline]
241    pub fn insert(&self, specifier: impl AsRef<str>, module: Module) -> Option<Module> {
242        self.inner
243            .borrow_mut()
244            .insert(PathBuf::from(specifier.as_ref()), module)
245    }
246
247    /// Clear the map.
248    pub fn clear(&self) {
249        self.inner.borrow_mut().clear();
250    }
251}
252
253impl FromIterator<(String, Module)> for MapModuleLoader {
254    fn from_iter<T: IntoIterator<Item = (String, Module)>>(iter: T) -> Self {
255        Self {
256            inner: RefCell::new(
257                iter.into_iter()
258                    .map(|(k, v)| (PathBuf::from(k), v))
259                    .collect(),
260            ),
261        }
262    }
263}
264
265impl ModuleLoader for MapModuleLoader {
266    fn load_imported_module(
267        self: Rc<Self>,
268        referrer: Referrer,
269        request: ModuleRequest,
270        context: &RefCell<&mut Context>,
271    ) -> impl Future<Output = JsResult<Module>> {
272        let result = (|| {
273            let path = resolve_module_specifier(
274                None,
275                request.specifier(),
276                referrer.path(),
277                &mut context.borrow_mut(),
278            )?;
279            if let Some(module) = self.inner.borrow().get(&path) {
280                Ok(module.clone())
281            } else {
282                Err(js_error!(TypeError: "Module could not be found."))
283            }
284        })();
285
286        async { result }
287    }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Hash)]
291struct ModuleCacheKey {
292    path: PathBuf,
293    attributes: Box<[ImportAttribute]>,
294}
295
296impl ModuleCacheKey {
297    fn new(path: PathBuf, attributes: &[ImportAttribute]) -> Self {
298        let mut attributes = attributes.to_vec();
299        attributes.sort_unstable_by(|left, right| left.key().cmp(right.key()));
300        Self {
301            path,
302            attributes: attributes.into_boxed_slice(),
303        }
304    }
305}
306
307/// A simple module loader that loads modules relative to a root path.
308///
309/// # Note
310///
311/// This loader only works by using the type methods [`SimpleModuleLoader::insert`] and
312/// [`SimpleModuleLoader::get`]. The utility methods on [`ModuleLoader`] don't work at the moment,
313/// but we'll unify both APIs in the future.
314#[derive(Debug)]
315pub struct SimpleModuleLoader {
316    root: PathBuf,
317    module_map: GcRefCell<FxHashMap<ModuleCacheKey, Module>>,
318}
319
320impl SimpleModuleLoader {
321    /// Creates a new `SimpleModuleLoader` from a root module path.
322    pub fn new<P: AsRef<Path>>(root: P) -> JsResult<Self> {
323        if cfg!(target_family = "wasm") {
324            return Err(JsNativeError::typ()
325                .with_message("cannot resolve a relative path in Wasm targets")
326                .into());
327        }
328        let root = root.as_ref();
329        let absolute = root.canonicalize().map_err(|e| {
330            JsNativeError::typ()
331                .with_message(format!("could not set module root `{}`", root.display()))
332                .with_cause(JsError::from_rust(e))
333        })?;
334        Ok(Self {
335            root: absolute,
336            module_map: GcRefCell::default(),
337        })
338    }
339
340    /// Inserts a new module onto the module map.
341    #[inline]
342    pub fn insert(&self, path: PathBuf, module: Module) {
343        self.insert_with_attributes(path, &[], module);
344    }
345
346    /// Inserts a new module onto the module map with the given attributes.
347    #[inline]
348    pub fn insert_with_attributes(
349        &self,
350        path: PathBuf,
351        attributes: &[ImportAttribute],
352        module: Module,
353    ) {
354        self.module_map
355            .borrow_mut()
356            .insert(ModuleCacheKey::new(path, attributes), module);
357    }
358
359    /// Gets a module from its original path.
360    #[inline]
361    pub fn get(&self, path: &Path) -> Option<Module> {
362        self.get_with_attributes(path, &[])
363    }
364
365    /// Gets a module from its original path and attributes.
366    #[inline]
367    pub fn get_with_attributes(
368        &self,
369        path: &Path,
370        attributes: &[ImportAttribute],
371    ) -> Option<Module> {
372        self.module_map
373            .borrow()
374            .get(&ModuleCacheKey::new(path.to_path_buf(), attributes))
375            .cloned()
376    }
377}
378
379impl ModuleLoader for SimpleModuleLoader {
380    fn load_imported_module(
381        self: Rc<Self>,
382        referrer: Referrer,
383        request: ModuleRequest,
384        context: &RefCell<&mut Context>,
385    ) -> impl Future<Output = JsResult<Module>> {
386        let result = (|| {
387            let short_path = request.specifier().to_std_string_escaped();
388            let path = resolve_module_specifier(
389                Some(&self.root),
390                request.specifier(),
391                referrer.path(),
392                &mut context.borrow_mut(),
393            )?;
394
395            if let Some(module) = self.get_with_attributes(&path, request.attributes()) {
396                return Ok(module);
397            }
398
399            // Check for import attribute type
400            let mut module_type = None;
401            let type_key = js_string!("type");
402            for attr in request.attributes() {
403                if attr.key() == &type_key {
404                    module_type = Some(attr.value());
405                }
406            }
407
408            if path
409                .extension()
410                .is_some_and(|ext| ext.to_string_lossy() == "json")
411            {
412                let is_json_type = module_type.is_some_and(|t| t == &js_string!("json"));
413                if !is_json_type {
414                    return Err(JsNativeError::typ()
415                        .with_message(format!(
416                            "module `{short_path}` needs an import attribute of type \"json\""
417                        ))
418                        .into());
419                }
420            }
421            let module = if let Some(ty) = module_type {
422                // Handle different module types based on the type attribute
423                match ty.to_std_string_escaped().as_str() {
424                    "json" => {
425                        // Load and parse as JSON module
426                        let json_content = std::fs::read_to_string(&path).map_err(|err| {
427                            JsNativeError::typ()
428                                .with_message(format!("could not open file `{short_path}`"))
429                                .with_cause(JsError::from_rust(err))
430                        })?;
431                        let json_string = js_string!(json_content.as_str());
432                        Module::parse_json(json_string, &mut context.borrow_mut()).map_err(
433                            |err| {
434                                JsNativeError::syntax()
435                                    .with_message(format!(
436                                        "could not parse JSON module `{short_path}`"
437                                    ))
438                                    .with_cause(err)
439                            },
440                        )?
441                    }
442                    other => {
443                        // Unknown module type
444                        return Err(JsNativeError::typ()
445                            .with_message(format!(
446                                "unsupported module type `{other}` for module `{short_path}`"
447                            ))
448                            .into());
449                    }
450                }
451            } else {
452                // No type attribute, load as regular JavaScript module
453                let source = Source::from_filepath(&path).map_err(|err| {
454                    JsNativeError::typ()
455                        .with_message(format!("could not open file `{short_path}`"))
456                        .with_cause(JsError::from_rust(err))
457                })?;
458                Module::parse(source, None, &mut context.borrow_mut()).map_err(|err| {
459                    JsNativeError::syntax()
460                        .with_message(format!("could not parse module `{short_path}`"))
461                        .with_cause(err)
462                })?
463            };
464
465            self.insert_with_attributes(path, request.attributes(), module.clone());
466            Ok(module)
467        })();
468
469        async { result }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use std::path::PathBuf;
476
477    use test_case::test_case;
478
479    use super::*;
480
481    // Tests on Windows and Linux are different because of the path separator and the definition
482    // of absolute paths.
483    #[rustfmt::skip]
484    #[cfg(target_family = "unix")]
485    #[test_case(Some("/hello/ref.js"),      "a.js",             Ok("/base/a.js"))]
486    #[test_case(Some("/base/ref.js"),       "./b.js",           Ok("/base/b.js"))]
487    #[test_case(Some("/base/other/ref.js"), "./c.js",           Ok("/base/other/c.js"))]
488    #[test_case(Some("/base/other/ref.js"), "../d.js",          Ok("/base/d.js"))]
489    #[test_case(Some("/base/ref.js"),        "e.js",            Ok("/base/e.js"))]
490    #[test_case(Some("/base/ref.js"),        "./f.js",          Ok("/base/f.js"))]
491    #[test_case(Some("./ref.js"),           "./g.js",           Ok("/base/g.js"))]
492    #[test_case(Some("./other/ref.js"),     "./other/h.js",     Ok("/base/other/other/h.js"))]
493    #[test_case(Some("./other/ref.js"),     "./other/../h1.js", Ok("/base/other/h1.js"))]
494    #[test_case(Some("./other/ref.js"),     "./../h2.js",       Ok("/base/h2.js"))]
495    #[test_case(None,                       "./i.js",           Err(()))]
496    #[test_case(None,                       "j.js",             Ok("/base/j.js"))]
497    #[test_case(None,                       "other/k.js",       Ok("/base/other/k.js"))]
498    #[test_case(None,                       "other/../../l.js", Err(()))]
499    #[test_case(Some("/base/ref.js"),       "other/../../m.js", Err(()))]
500    #[test_case(None,                       "../n.js",          Err(()))]
501    fn resolve_test(ref_path: Option<&str>, spec: &str, expected: Result<&str, ()>) {
502        let base = PathBuf::from("/base");
503
504        let mut context = Context::default();
505        let spec = js_string!(spec);
506        let ref_path = ref_path.map(PathBuf::from);
507
508        let actual = resolve_module_specifier(
509            Some(&base),
510            &spec,
511            ref_path.as_deref(),
512            &mut context,
513        );
514        assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from));
515    }
516
517    // This tests the same cases as the previous test, but without a base path.
518    #[rustfmt::skip]
519    #[cfg(target_family = "unix")]
520    #[test_case(Some("hello/ref.js"),       "a.js",             Ok("a.js"))]
521    #[test_case(Some("base/ref.js"),        "./b.js",           Ok("base/b.js"))]
522    #[test_case(Some("base/other/ref.js"),  "./c.js",           Ok("base/other/c.js"))]
523    #[test_case(Some("base/other/ref.js"),  "../d.js",          Ok("base/d.js"))]
524    #[test_case(Some("base/ref.js"),        "e.js",             Ok("e.js"))]
525    #[test_case(Some("base/ref.js"),        "./f.js",           Ok("base/f.js"))]
526    #[test_case(Some("./ref.js"),           "./g.js",           Ok("g.js"))]
527    #[test_case(Some("./other/ref.js"),     "./other/h.js",     Ok("other/other/h.js"))]
528    #[test_case(Some("./other/ref.js"),     "./other/../h1.js", Ok("other/h1.js"))]
529    #[test_case(Some("./other/ref.js"),     "./../h2.js",       Ok("h2.js"))]
530    #[test_case(None,                       "./i.js",           Err(()))]
531    #[test_case(None,                       "j.js",             Ok("j.js"))]
532    #[test_case(None,                       "other/k.js",       Ok("other/k.js"))]
533    #[test_case(None,                       "other/../../l.js", Err(()))]
534    #[test_case(Some("/base/ref.js"),       "other/../../m.js", Err(()))]
535    #[test_case(None,                       "../n.js",          Err(()))]
536    fn resolve_test_no_base(ref_path: Option<&str>, spec: &str, expected: Result<&str, ()>) {
537        let mut context = Context::default();
538        let spec = js_string!(spec);
539        let ref_path = ref_path.map(PathBuf::from);
540
541        let actual = resolve_module_specifier(
542            None,
543            &spec,
544            ref_path.as_deref(),
545            &mut context,
546        );
547        assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from));
548    }
549
550    #[rustfmt::skip]
551    #[cfg(target_family = "windows")]
552    #[test_case(Some("a:\\hello\\ref.js"),       "a.js",                Ok("a:\\base\\a.js"))]
553    #[test_case(Some("a:\\base\\ref.js"),        "./b.js",              Ok("a:\\base\\b.js"))]
554    #[test_case(Some("a:\\base\\other\\ref.js"), "./c.js",              Ok("a:\\base\\other\\c.js"))]
555    #[test_case(Some("a:\\base\\other\\ref.js"), "../d.js",             Ok("a:\\base\\d.js"))]
556    #[test_case(Some("a:\\base\\ref.js"),        "e.js",                Ok("a:\\base\\e.js"))]
557    #[test_case(Some("a:\\base\\ref.js"),        "./f.js",              Ok("a:\\base\\f.js"))]
558    #[test_case(Some(".\\ref.js"),               "./g.js",              Ok("a:\\base\\g.js"))]
559    #[test_case(Some(".\\other\\ref.js"),        "./other/h.js",        Ok("a:\\base\\other\\other\\h.js"))]
560    #[test_case(Some(".\\other\\ref.js"),        "./other/../h1.js",    Ok("a:\\base\\other\\h1.js"))]
561    #[test_case(Some(".\\other\\ref.js"),        "./../h2.js",          Ok("a:\\base\\h2.js"))]
562    #[test_case(None,                            "./i.js",              Err(()))]
563    #[test_case(None,                            "j.js",                Ok("a:\\base\\j.js"))]
564    #[test_case(None,                            "other/k.js",          Ok("a:\\base\\other\\k.js"))]
565    #[test_case(None,                            "other/../../l.js",    Err(()))]
566    #[test_case(Some("\\base\\ref.js"),          "other/../../m.js",    Err(()))]
567    #[test_case(None,                            "../n.js",             Err(()))]
568    fn resolve_test(ref_path: Option<&str>, spec: &str, expected: Result<&str, ()>) {
569        let base = PathBuf::from("a:\\base");
570
571        let mut context = Context::default();
572        let spec = js_string!(spec);
573        let ref_path = ref_path.map(PathBuf::from);
574
575        let actual = resolve_module_specifier(
576            Some(&base),
577            &spec,
578            ref_path.as_deref(),
579            &mut context,
580        );
581        assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from));
582    }
583}