Skip to main content

lanekeep_js/
loader.rs

1//! Module resolution and loading for rule files.
2//!
3//! Rules are ES modules. They may import from `lanekeep` and from each other; nothing else
4//! resolves. There is no `node_modules` lookup, no bare-specifier resolution, and no way to
5//! reach a file outside the rules root.
6//!
7//! # Confinement
8//!
9//! The rules root is canonicalized once at construction, and every resolved module is
10//! canonicalized and checked against it. Canonicalizing rather than comparing strings is
11//! what makes the check hold against symlinks: a link inside the root pointing at
12//! `/etc/passwd` resolves to a path outside the root and is rejected, where a lexical
13//! comparison would see an innocent-looking relative path and allow it.
14//!
15//! Traversal is also rejected lexically, before touching the filesystem, so `../../secrets`
16//! produces a message about escaping the root rather than a confusing "not found".
17
18use std::cell::RefCell;
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::rc::Rc;
22use std::sync::Arc;
23
24use lanekeep_lang::Language;
25use rquickjs::loader::{ImportAttributes, Loader, Resolver};
26use rquickjs::module::{Declared, Module};
27use rquickjs::{Ctx, Error as JsError};
28use thiserror::Error;
29
30use crate::files::normalize;
31use crate::typescript::strip_types;
32
33/// The specifier that resolves to lanekeep's own module.
34pub const HOST_MODULE: &str = "lanekeep";
35
36/// The host module.
37///
38/// `defineRule` and `defineConfig` are identity functions, and that is not a placeholder —
39/// it is what they are. Their entire purpose is to give the TypeScript compiler something
40/// to infer against in the author's editor, which costs nothing at runtime.
41const HOST_MODULE_SOURCE: &str = r"
42    export function defineRule(rule) { return rule; }
43    export function defineConfig(config) { return config; }
44";
45
46/// Resolves a built-in rule name to its embedded source.
47///
48/// A function rather than a dependency, so this crate stays unaware of which rules ship —
49/// `lanekeep-js` sits below `lanekeep-rules`, and reaching upward for them would invert the
50/// layering for no gain.
51pub type BuiltinSource = fn(&str) -> Option<&'static str>;
52
53/// The default: no built-ins, so a bare `lanekeep-js` resolves only project modules.
54fn no_builtins(_name: &str) -> Option<&'static str> {
55    None
56}
57
58/// The prefix a built-in specifier carries, as in `lanekeep/no-default-export`.
59const BUILTIN_PREFIX: &str = "lanekeep/";
60
61/// Extensions tried for a specifier that does not name one, in order.
62const EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs"];
63
64/// Why a module specifier could not be resolved.
65#[derive(Debug, Clone, PartialEq, Eq, Error)]
66pub enum ResolveError {
67    /// A bare specifier, which would be an npm package.
68    #[error(
69        "cannot import `{specifier}`\n  \
70         rule modules run in a sandbox with no package resolution, so only `lanekeep` and \
71         relative paths starting with `./` or `../` can be imported\n  \
72         if this needs a package, inline what you need from it instead"
73    )]
74    BareSpecifier {
75        /// The specifier as written.
76        specifier: String,
77    },
78
79    /// The specifier resolves outside the rules root.
80    #[error(
81        "cannot import `{specifier}`\n  \
82         it resolves outside the rules directory, and rule modules may only import from \
83         within it"
84    )]
85    EscapesRoot {
86        /// The specifier as written.
87        specifier: String,
88    },
89
90    /// Nothing exists at the specifier.
91    #[error("cannot find module `{specifier}`\n  tried: {tried}")]
92    NotFound {
93        /// The specifier as written.
94        specifier: String,
95        /// The candidate paths that were tried.
96        tried: String,
97    },
98
99    /// The module exists but could not be read.
100    #[error("cannot read module `{path}`: {detail}")]
101    Unreadable {
102        /// The path that failed.
103        path: String,
104        /// The underlying reason.
105        detail: String,
106    },
107}
108
109/// Where rule modules live, and what may be imported.
110#[derive(Debug, Clone)]
111pub struct RuleRoot {
112    root: PathBuf,
113    builtins: BuiltinSource,
114}
115
116impl RuleRoot {
117    /// Anchor resolution at a directory.
118    ///
119    /// # Errors
120    ///
121    /// Fails if the directory does not exist or cannot be canonicalized.
122    pub fn new(root: impl AsRef<Path>) -> Result<Self, ResolveError> {
123        let root = root.as_ref();
124        let canonical = root.canonicalize().map_err(|e| ResolveError::Unreadable {
125            path: root.display().to_string(),
126            detail: e.to_string(),
127        })?;
128        Ok(Self {
129            root: canonical,
130            builtins: no_builtins,
131        })
132    }
133
134    /// Serve built-in rules from embedded sources.
135    ///
136    /// Built-ins resolve before anything on disk, so a project file cannot shadow one —
137    /// a rule whose behavior depended on whether a same-named file happened to exist
138    /// would be impossible to reason about.
139    #[must_use]
140    pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
141        self.builtins = builtins;
142        self
143    }
144
145    /// The canonical root.
146    #[must_use]
147    pub fn path(&self) -> &Path {
148        &self.root
149    }
150
151    /// Resolve a specifier against the module that imported it.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`ResolveError`] for a bare specifier, an escape from the root, or a
156    /// specifier matching no file.
157    pub fn resolve(&self, base: &str, specifier: &str) -> Result<PathBuf, ResolveError> {
158        if specifier == HOST_MODULE {
159            return Ok(PathBuf::from(HOST_MODULE));
160        }
161
162        // Built-ins resolve before the filesystem is consulted at all.
163        if let Some(name) = specifier.strip_prefix(BUILTIN_PREFIX) {
164            return if (self.builtins)(name).is_some() {
165                Ok(PathBuf::from(specifier))
166            } else {
167                Err(ResolveError::NotFound {
168                    specifier: specifier.to_owned(),
169                    tried: "no built-in rule by that name".to_owned(),
170                })
171            };
172        }
173
174        // The entry module arrives as an already-resolved absolute path, because that is
175        // what the caller hands the engine to import. Accepting one is therefore necessary,
176        // but only for the entry: an empty base means nothing imported this.
177        //
178        // A rule writing `import '/etc/passwd'` always has a base — the importing module's
179        // own path — so it falls through to the bare-specifier rejection below rather than
180        // through this door. Containment is still checked either way.
181        if Path::new(specifier).is_absolute() {
182            if !base.is_empty() {
183                return Err(ResolveError::BareSpecifier {
184                    specifier: specifier.to_owned(),
185                });
186            }
187            return self.resolve_within(specifier, &normalize(Path::new(specifier)));
188        }
189
190        if !specifier.starts_with('.') {
191            return Err(ResolveError::BareSpecifier {
192                specifier: specifier.to_owned(),
193            });
194        }
195
196        let base_dir = if base == HOST_MODULE || base.is_empty() {
197            self.root.clone()
198        } else {
199            Path::new(base)
200                .parent()
201                .map_or_else(|| self.root.clone(), Path::to_path_buf)
202        };
203
204        self.resolve_within(specifier, &normalize(&base_dir.join(specifier)))
205    }
206
207    /// Find a file for an already-joined path, enforcing containment.
208    fn resolve_within(&self, specifier: &str, joined: &Path) -> Result<PathBuf, ResolveError> {
209        if !joined.starts_with(&self.root) {
210            return Err(ResolveError::EscapesRoot {
211                specifier: specifier.to_owned(),
212            });
213        }
214
215        let mut tried = Vec::new();
216        for candidate in candidates(joined) {
217            tried.push(candidate.display().to_string());
218            if !candidate.is_file() {
219                continue;
220            }
221
222            // Canonicalize the file that was actually found. This is the check that holds
223            // against symlinks — the lexical test above cannot see through one.
224            let canonical = candidate
225                .canonicalize()
226                .map_err(|e| ResolveError::Unreadable {
227                    path: candidate.display().to_string(),
228                    detail: e.to_string(),
229                })?;
230            if !canonical.starts_with(&self.root) {
231                return Err(ResolveError::EscapesRoot {
232                    specifier: specifier.to_owned(),
233                });
234            }
235            return Ok(canonical);
236        }
237
238        Err(ResolveError::NotFound {
239            specifier: specifier.to_owned(),
240            tried: tried.join(", "),
241        })
242    }
243
244    /// Read a resolved module, stripping types when it is TypeScript.
245    ///
246    /// Containment is re-checked here rather than trusted from [`RuleRoot::resolve`].
247    /// Reading is the operation that actually touches a file, so it should be the thing
248    /// that enforces the boundary — otherwise the guarantee depends on every caller having
249    /// gone through the resolver first, which is exactly the sort of assumption that holds
250    /// until someone adds a second caller.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`ResolveError::EscapesRoot`] if the path is outside the root, or
255    /// [`ResolveError::Unreadable`] if the file cannot be read or stripping rejects it.
256    pub fn read(
257        &self,
258        path: &Path,
259        typescript: &dyn Language,
260        javascript: &dyn Language,
261    ) -> Result<String, ResolveError> {
262        if path == Path::new(HOST_MODULE) {
263            return Ok(HOST_MODULE_SOURCE.to_owned());
264        }
265
266        if let Some(name) = path.to_str().and_then(|p| p.strip_prefix(BUILTIN_PREFIX))
267            && let Some(source) = (self.builtins)(name)
268        {
269            // Built-ins are TypeScript like any other rule, so they go through the same
270            // stripping — including its verification step. A built-in that failed to strip
271            // would be a build-time bug in this repository, and should look like one.
272            return strip_types(typescript, javascript, source).map_err(|e| {
273                ResolveError::Unreadable {
274                    path: path.display().to_string(),
275                    detail: e.to_string(),
276                }
277            });
278        }
279
280        let canonical = path.canonicalize().map_err(|e| ResolveError::Unreadable {
281            path: path.display().to_string(),
282            detail: e.to_string(),
283        })?;
284        if !canonical.starts_with(&self.root) {
285            return Err(ResolveError::EscapesRoot {
286                specifier: path.display().to_string(),
287            });
288        }
289
290        let source = std::fs::read_to_string(path).map_err(|e| ResolveError::Unreadable {
291            path: path.display().to_string(),
292            detail: e.to_string(),
293        })?;
294
295        // Plain JavaScript is passed through untouched rather than run through the
296        // stripper, which would only be able to fail on it.
297        let is_typescript = path
298            .extension()
299            .and_then(|e| e.to_str())
300            .is_some_and(|e| matches!(e, "ts" | "tsx" | "mts" | "cts"));
301        if !is_typescript {
302            return Ok(source);
303        }
304
305        strip_types(typescript, javascript, &source).map_err(|e| ResolveError::Unreadable {
306            path: path.display().to_string(),
307            detail: e.to_string(),
308        })
309    }
310}
311
312/// Candidate files for a specifier, in resolution order.
313fn candidates(base: &Path) -> Vec<PathBuf> {
314    let mut out = Vec::new();
315
316    // An explicit extension is taken at face value.
317    if base.extension().is_some() {
318        out.push(base.to_path_buf());
319    }
320
321    for extension in EXTENSIONS {
322        out.push(base.with_extension(extension));
323    }
324    for extension in EXTENSIONS {
325        out.push(base.join(format!("index.{extension}")));
326    }
327
328    out
329}
330
331/// Adapts [`RuleRoot`] to the engine's resolver interface.
332#[derive(Debug, Clone)]
333pub struct RuleResolver {
334    root: RuleRoot,
335}
336
337impl RuleResolver {
338    /// Build a resolver for a rules root.
339    #[must_use]
340    pub const fn new(root: RuleRoot) -> Self {
341        Self { root }
342    }
343}
344
345impl Resolver for RuleResolver {
346    fn resolve(
347        &mut self,
348        _ctx: &Ctx<'_>,
349        base: &str,
350        name: &str,
351        _attributes: Option<ImportAttributes<'_>>,
352    ) -> rquickjs::Result<String> {
353        match self.root.resolve(base, name) {
354            Ok(path) => Ok(path.display().to_string()),
355            // The engine's error channel carries only a message, so the diagnostic is
356            // rendered here rather than lost.
357            Err(err) => Err(JsError::new_resolving_message(
358                base.to_owned(),
359                name.to_owned(),
360                err.to_string(),
361            )),
362        }
363    }
364}
365
366/// Every module the loader read, with the source it read.
367///
368/// This is what makes `ruleset_hash` cover the whole import graph rather than only the
369/// entry files. A rule that imports a shared helper has to invalidate when that helper
370/// changes, and the only component that knows the helper was involved is the loader.
371///
372/// Ordered, so the hash derived from it does not depend on load order — which varies with
373/// import structure and is not something a user changed.
374pub type LoadedModules = Rc<RefCell<BTreeMap<PathBuf, String>>>;
375
376/// Adapts [`RuleRoot`] to the engine's loader interface.
377///
378/// `Debug` is hand-written because `Arc<dyn Language>` is not `Debug`, and requiring it on
379/// the trait would burden every language implementation for one impl here.
380#[derive(Clone)]
381pub struct RuleLoader {
382    root: RuleRoot,
383    typescript: Arc<dyn Language>,
384    javascript: Arc<dyn Language>,
385    loaded: LoadedModules,
386}
387
388impl std::fmt::Debug for RuleLoader {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        f.debug_struct("RuleLoader")
391            .field("root", &self.root)
392            .field("typescript", &self.typescript.id())
393            .field("javascript", &self.javascript.id())
394            .field("loaded", &self.loaded.borrow().len())
395            .finish()
396    }
397}
398
399impl RuleLoader {
400    /// Build a loader for a rules root.
401    ///
402    /// The languages are supplied rather than assumed so this crate does not have to know
403    /// which grammars exist.
404    #[must_use]
405    pub fn new(
406        root: RuleRoot,
407        typescript: Arc<dyn Language>,
408        javascript: Arc<dyn Language>,
409    ) -> Self {
410        Self {
411            root,
412            typescript,
413            javascript,
414            loaded: Rc::new(RefCell::new(BTreeMap::new())),
415        }
416    }
417
418    /// A handle on what this loader has read, for hashing the rule graph.
419    #[must_use]
420    pub fn loaded(&self) -> LoadedModules {
421        Rc::clone(&self.loaded)
422    }
423}
424
425impl Loader for RuleLoader {
426    fn load<'js>(
427        &mut self,
428        ctx: &Ctx<'js>,
429        name: &str,
430        _attributes: Option<ImportAttributes<'js>>,
431    ) -> rquickjs::Result<Module<'js, Declared>> {
432        let source = self
433            .root
434            .read(
435                Path::new(name),
436                self.typescript.as_ref(),
437                self.javascript.as_ref(),
438            )
439            .map_err(|err| JsError::new_loading_message(name.to_owned(), err.to_string()))?;
440
441        // Recorded before declaring, so a module that fails to compile still counts as
442        // part of the graph. Otherwise fixing the compile error would not invalidate.
443        self.loaded
444            .borrow_mut()
445            .insert(PathBuf::from(name), source.clone());
446
447        Module::declare(ctx.clone(), name, source)
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use std::fs;
454
455    use lanekeep_lang_js::{JavaScript, TypeScript};
456
457    use super::*;
458
459    /// A rules directory laid out for a test, cleaned up on drop.
460    struct Fixture {
461        dir: PathBuf,
462    }
463
464    impl Fixture {
465        fn new(name: &str, files: &[(&str, &str)]) -> Self {
466            let dir = std::env::temp_dir().join(format!("lanekeep-loader-{name}"));
467            let _ = fs::remove_dir_all(&dir);
468            fs::create_dir_all(&dir).expect("creates fixture dir");
469            for (path, contents) in files {
470                let full = dir.join(path);
471                if let Some(parent) = full.parent() {
472                    fs::create_dir_all(parent).expect("creates parent");
473                }
474                fs::write(&full, contents).expect("writes fixture file");
475            }
476            Self { dir }
477        }
478
479        fn root(&self) -> RuleRoot {
480            RuleRoot::new(&self.dir).expect("canonicalizes")
481        }
482
483        fn entry(&self, name: &str) -> String {
484            self.dir
485                .join(name)
486                .canonicalize()
487                .expect("exists")
488                .display()
489                .to_string()
490        }
491    }
492
493    impl Drop for Fixture {
494        fn drop(&mut self) {
495            let _ = fs::remove_dir_all(&self.dir);
496        }
497    }
498
499    /// Stands in for the real built-in table, so these tests do not depend on which rules
500    /// happen to ship.
501    fn stub_builtins(name: &str) -> Option<&'static str> {
502        match name {
503            "always" => Some("export default { id: 'lanekeep/always' } satisfies unknown;"),
504            _ => None,
505        }
506    }
507
508    #[test]
509    fn resolves_a_built_in_by_specifier() {
510        let fixture = Fixture::new("builtin-resolve", &[("a.ts", "export const a = 1;")]);
511        let root = fixture.root().with_builtins(stub_builtins);
512        assert_eq!(
513            root.resolve("", "lanekeep/always").expect("resolves"),
514            Path::new("lanekeep/always")
515        );
516    }
517
518    #[test]
519    fn an_unknown_built_in_is_not_found() {
520        // Not "bare specifier": the `lanekeep/` prefix says what the author meant, and an
521        // error about npm resolution would send them somewhere useless.
522        let fixture = Fixture::new("builtin-unknown", &[("a.ts", "export const a = 1;")]);
523        let root = fixture.root().with_builtins(stub_builtins);
524        let error = root
525            .resolve("", "lanekeep/no-such-rule")
526            .expect_err("does not resolve");
527        assert!(
528            matches!(error, ResolveError::NotFound { .. }),
529            "expected NotFound, got {error:?}"
530        );
531        assert!(error.to_string().contains("built-in"), "{error}");
532    }
533
534    #[test]
535    fn a_file_cannot_shadow_a_built_in() {
536        // A rules directory containing `lanekeep/always.ts` must not change what the
537        // specifier means. A rule whose behavior depended on whether a same-named file
538        // happened to exist would be unreasonable to debug.
539        let fixture = Fixture::new(
540            "builtin-shadow",
541            &[("lanekeep/always.ts", "export default 'the wrong one';")],
542        );
543        let root = fixture.root().with_builtins(stub_builtins);
544        let resolved = root.resolve("", "lanekeep/always").expect("resolves");
545        assert_eq!(resolved, Path::new("lanekeep/always"));
546
547        let source = root
548            .read(&resolved, &TypeScript, &JavaScript)
549            .expect("reads");
550        assert!(
551            !source.contains("the wrong one"),
552            "a project file shadowed a built-in: {source}"
553        );
554    }
555
556    #[test]
557    fn a_built_in_is_stripped_of_its_types() {
558        let fixture = Fixture::new("builtin-strip", &[("a.ts", "export const a = 1;")]);
559        let root = fixture.root().with_builtins(stub_builtins);
560        let source = root
561            .read(Path::new("lanekeep/always"), &TypeScript, &JavaScript)
562            .expect("reads");
563        assert!(
564            !source.contains("satisfies"),
565            "type syntax survived stripping: {source}"
566        );
567    }
568
569    #[test]
570    fn built_ins_are_absent_unless_provided() {
571        // The default. A crate embedding `lanekeep-js` without the rules crate resolves
572        // project modules only, rather than silently resolving names to nothing.
573        let fixture = Fixture::new("builtin-default", &[("a.ts", "export const a = 1;")]);
574        let root = fixture.root();
575        assert!(root.resolve("", "lanekeep/always").is_err());
576    }
577
578    #[test]
579    fn resolves_the_host_module() {
580        let fixture = Fixture::new("host", &[("a.ts", "export const a = 1;")]);
581        let root = fixture.root();
582        assert_eq!(
583            root.resolve("", HOST_MODULE).expect("resolves"),
584            Path::new(HOST_MODULE)
585        );
586    }
587
588    #[test]
589    fn the_host_module_exports_the_authoring_helpers() {
590        let fixture = Fixture::new("host-src", &[]);
591        let source = fixture
592            .root()
593            .read(Path::new(HOST_MODULE), &TypeScript, &JavaScript)
594            .expect("reads");
595        assert!(source.contains("defineRule"), "{source}");
596        assert!(source.contains("defineConfig"), "{source}");
597    }
598
599    #[test]
600    fn resolves_a_relative_import() {
601        let fixture = Fixture::new(
602            "relative",
603            &[
604                ("main.ts", "import './helper';"),
605                ("helper.ts", "export const h = 1;"),
606            ],
607        );
608        let root = fixture.root();
609
610        let resolved = root
611            .resolve(&fixture.entry("main.ts"), "./helper")
612            .expect("resolves");
613        assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
614    }
615
616    #[test]
617    fn tries_extensions_in_order() {
618        // A `.ts` file wins over a `.js` file of the same name, because a rule directory
619        // containing both is almost always a stale build artifact next to its source.
620        let fixture = Fixture::new(
621            "extensions",
622            &[
623                ("main.ts", ""),
624                ("dup.ts", "export const from = 'ts';"),
625                ("dup.js", "export const from = 'js';"),
626            ],
627        );
628        let resolved = fixture
629            .root()
630            .resolve(&fixture.entry("main.ts"), "./dup")
631            .expect("resolves");
632        assert!(
633            resolved.ends_with("dup.ts"),
634            "expected the TypeScript file: {resolved:?}"
635        );
636    }
637
638    #[test]
639    fn resolves_a_directory_index() {
640        let fixture = Fixture::new(
641            "index",
642            &[("main.ts", ""), ("rules/index.ts", "export const r = 1;")],
643        );
644        let resolved = fixture
645            .root()
646            .resolve(&fixture.entry("main.ts"), "./rules")
647            .expect("resolves");
648        assert!(resolved.ends_with("index.ts"), "{resolved:?}");
649    }
650
651    #[test]
652    fn resolves_an_explicit_extension() {
653        let fixture = Fixture::new(
654            "explicit",
655            &[("main.ts", ""), ("helper.ts", "export const h = 1;")],
656        );
657        let resolved = fixture
658            .root()
659            .resolve(&fixture.entry("main.ts"), "./helper.ts")
660            .expect("resolves");
661        assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
662    }
663
664    // --- what must not resolve --------------------------------------------------------
665
666    #[test]
667    fn rejects_bare_specifiers() {
668        let fixture = Fixture::new("bare", &[("main.ts", "")]);
669        let root = fixture.root();
670
671        for specifier in [
672            "lodash",
673            "react",
674            "node:fs",
675            "fs",
676            "@scope/pkg",
677            "typescript",
678        ] {
679            let err = root
680                .resolve(&fixture.entry("main.ts"), specifier)
681                .expect_err("bare specifiers must not resolve");
682            assert!(
683                matches!(err, ResolveError::BareSpecifier { .. }),
684                "{specifier} gave {err:?}"
685            );
686        }
687    }
688
689    #[test]
690    fn a_bare_specifier_explains_why() {
691        let fixture = Fixture::new("bare-msg", &[("main.ts", "")]);
692        let err = fixture
693            .root()
694            .resolve(&fixture.entry("main.ts"), "lodash")
695            .expect_err("bare specifiers do not resolve");
696
697        assert!(matches!(err, ResolveError::BareSpecifier { .. }), "{err:?}");
698        let rendered = err.to_string();
699        assert!(rendered.contains("no package resolution"), "{rendered}");
700        assert!(rendered.contains("lanekeep"), "{rendered}");
701    }
702
703    #[test]
704    fn rejects_traversal_out_of_the_root() {
705        let fixture = Fixture::new("traversal", &[("main.ts", "")]);
706        let root = fixture.root();
707        let base = fixture.entry("main.ts");
708
709        for specifier in ["../outside", "../../etc/passwd", "./../../secrets", "../"] {
710            let err = root
711                .resolve(&base, specifier)
712                .expect_err("traversal must not resolve");
713            assert!(
714                matches!(err, ResolveError::EscapesRoot { .. }),
715                "{specifier} gave {err:?}"
716            );
717        }
718    }
719
720    #[test]
721    fn traversal_is_rejected_even_when_the_target_exists() {
722        // The lexical check has to fire regardless of what is on disk, or the error a
723        // reader sees depends on whether the file they tried to reach happened to be there.
724        let fixture = Fixture::new(
725            "traversal-real",
726            &[("nested/main.ts", ""), ("secret.ts", "export const s = 1;")],
727        );
728        let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
729
730        let err = root
731            .resolve(&fixture.entry("nested/main.ts"), "../secret")
732            .expect_err("must not escape");
733        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
734    }
735
736    #[cfg(unix)]
737    #[test]
738    fn rejects_a_symlink_pointing_outside_the_root() {
739        // The case a lexical check cannot see. `./link` looks entirely innocent; only
740        // canonicalizing the file that was found reveals where it goes.
741        let fixture = Fixture::new(
742            "symlink",
743            &[
744                ("nested/main.ts", ""),
745                ("outside.ts", "export const o = 1;"),
746            ],
747        );
748        let root_dir = fixture.dir.join("nested");
749        let link = root_dir.join("link.ts");
750        std::os::unix::fs::symlink(fixture.dir.join("outside.ts"), &link).expect("creates symlink");
751
752        let root = RuleRoot::new(&root_dir).expect("canonicalizes");
753        let err = root
754            .resolve(&fixture.entry("nested/main.ts"), "./link")
755            .expect_err("a symlink out of the root must be rejected");
756        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
757    }
758
759    #[test]
760    fn a_rule_may_not_import_an_absolute_path() {
761        // The entry module legitimately arrives as an absolute path, so the resolver has
762        // to accept one. This checks that door is only open for the entry — a rule with a
763        // base of its own is refused.
764        //
765        // The absolute path is built from `temp_dir` rather than written literally,
766        // because `Path::is_absolute` is platform-specific: `/etc/passwd` is absolute on
767        // Unix and merely rooted on Windows, while `C:\...` is the reverse. A literal
768        // would take a different branch on each platform and assert a different error.
769        let fixture = Fixture::new("absolute", &[("main.ts", "")]);
770        let root = fixture.root();
771        let base = fixture.entry("main.ts");
772
773        let outside = std::env::temp_dir().join("lanekeep-absolute-probe.ts");
774        let outside = outside.display().to_string();
775
776        // Written by a rule: refused, whichever way the platform classifies it.
777        for specifier in [outside.as_str(), "/etc/passwd", "C:\\Windows\\System32\\x"] {
778            assert!(
779                root.resolve(&base, specifier).is_err(),
780                "a rule must not import `{specifier}`"
781            );
782        }
783
784        // As an entry point: still refused, because it is outside the root.
785        let err = root
786            .resolve("", &outside)
787            .expect_err("an entry outside the root must be refused");
788        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
789    }
790
791    #[test]
792    fn reports_what_it_tried_when_nothing_matches() {
793        let fixture = Fixture::new("missing", &[("main.ts", "")]);
794        let err = fixture
795            .root()
796            .resolve(&fixture.entry("main.ts"), "./nope")
797            .expect_err("nothing to find");
798
799        match err {
800            ResolveError::NotFound { tried, .. } => {
801                assert!(tried.contains("nope.ts"), "should list candidates: {tried}");
802                assert!(
803                    tried.contains("index.ts"),
804                    "should list index candidates: {tried}"
805                );
806            }
807            other => panic!("wrong error: {other:?}"),
808        }
809    }
810
811    // --- reading ------------------------------------------------------------------------
812
813    #[test]
814    fn strips_types_when_reading_typescript() {
815        let fixture = Fixture::new(
816            "read-ts",
817            &[("a.ts", "export const a: number = 1;\ninterface B {}\n")],
818        );
819        let root = fixture.root();
820        let path = root.resolve("", "./a").expect("resolves");
821        let source = root.read(&path, &TypeScript, &JavaScript).expect("reads");
822
823        assert!(!source.contains(": number"), "{source}");
824        assert!(!source.contains("interface"), "{source}");
825        assert!(source.contains("export const a"), "{source}");
826    }
827
828    #[test]
829    fn reading_refuses_a_path_outside_the_root_even_if_resolution_was_skipped() {
830        // Defense in depth. `resolve` already enforces this, but a future caller that
831        // builds a path some other way must not be able to read past the boundary.
832        let fixture = Fixture::new(
833            "read-escape",
834            &[
835                ("nested/main.ts", ""),
836                ("outside.ts", "export const o = 1;"),
837            ],
838        );
839        let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
840
841        let err = root
842            .read(&fixture.dir.join("outside.ts"), &TypeScript, &JavaScript)
843            .expect_err("reading outside the root must be refused");
844        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
845    }
846
847    #[test]
848    fn passes_javascript_through_untouched() {
849        let contents = "export const a = 1;\n";
850        let fixture = Fixture::new("read-js", &[("a.js", contents)]);
851        let root = fixture.root();
852        let path = root.resolve("", "./a.js").expect("resolves");
853        assert_eq!(
854            root.read(&path, &TypeScript, &JavaScript).expect("reads"),
855            contents
856        );
857    }
858
859    // --- end to end, through the engine ---------------------------------------------
860    //
861    // Everything above tests the resolution logic directly. These go through the engine's
862    // Resolver and Loader adapters, which is the part that is actually wired up at runtime
863    // and could be correct in isolation while being connected wrongly.
864
865    fn sandbox_for(fixture: &Fixture) -> crate::Sandbox {
866        crate::Sandbox::with_modules(
867            crate::Limits::default(),
868            crate::RunClock::start(std::time::Duration::from_secs(30)),
869            fixture.root(),
870            Arc::new(TypeScript),
871            Arc::new(JavaScript),
872        )
873        .expect("sandbox builds")
874    }
875
876    #[test]
877    fn loads_a_rule_module_that_imports_the_host_module() {
878        let fixture = Fixture::new(
879            "e2e-host",
880            &[(
881                "rule.ts",
882                "import { defineRule } from 'lanekeep';\n\
883                 export default defineRule({ id: 'local/example' });\n",
884            )],
885        );
886        let sandbox = sandbox_for(&fixture);
887        let path = fixture.root().resolve("", "./rule").expect("resolves");
888
889        let module: std::collections::HashMap<String, String> =
890            sandbox.import_default(&path).expect("module evaluates");
891        assert_eq!(module.get("id").map(String::as_str), Some("local/example"));
892    }
893
894    #[test]
895    fn loads_a_module_that_imports_a_sibling_and_strips_its_types() {
896        let fixture = Fixture::new(
897            "e2e-sibling",
898            &[
899                (
900                    "rule.ts",
901                    "import { defineRule } from 'lanekeep';\n\
902                     import { NAME } from './shared';\n\
903                     export default defineRule({ id: NAME });\n",
904                ),
905                (
906                    "shared.ts",
907                    "interface Unused { a: number }\n\
908                     export const NAME: string = 'local/from-sibling';\n",
909                ),
910            ],
911        );
912        let sandbox = sandbox_for(&fixture);
913        let path = fixture.root().resolve("", "./rule").expect("resolves");
914
915        let module: std::collections::HashMap<String, String> =
916            sandbox.import_default(&path).expect("module evaluates");
917        assert_eq!(
918            module.get("id").map(String::as_str),
919            Some("local/from-sibling")
920        );
921    }
922
923    #[test]
924    fn a_bare_import_fails_at_load_with_the_explanation() {
925        let fixture = Fixture::new(
926            "e2e-bare",
927            &[(
928                "rule.ts",
929                "import lodash from 'lodash';\nexport default lodash;\n",
930            )],
931        );
932        let sandbox = sandbox_for(&fixture);
933        let path = fixture.root().resolve("", "./rule").expect("resolves");
934
935        let err = sandbox
936            .import_default::<std::collections::HashMap<String, String>>(&path)
937            .expect_err("lodash cannot resolve");
938        let rendered = err.to_string();
939        assert!(rendered.contains("lodash"), "{rendered}");
940    }
941
942    #[test]
943    fn a_traversing_import_fails_at_load() {
944        let fixture = Fixture::new(
945            "e2e-traversal",
946            &[
947                (
948                    "nested/rule.ts",
949                    "import x from '../outside';\nexport default x;\n",
950                ),
951                ("outside.ts", "export default 1;\n"),
952            ],
953        );
954        let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
955        let sandbox = crate::Sandbox::with_modules(
956            crate::Limits::default(),
957            crate::RunClock::start(std::time::Duration::from_secs(30)),
958            root.clone(),
959            Arc::new(TypeScript),
960            Arc::new(JavaScript),
961        )
962        .expect("sandbox builds");
963
964        let path = root.resolve("", "./rule").expect("resolves");
965        assert!(
966            sandbox
967                .import_default::<std::collections::HashMap<String, String>>(&path)
968                .is_err(),
969            "an import escaping the root must not load"
970        );
971    }
972
973    #[test]
974    fn a_module_that_fails_to_strip_reports_the_reason() {
975        let fixture = Fixture::new("read-bad", &[("a.ts", "enum E { A }\n")]);
976        let root = fixture.root();
977        let path = root.resolve("", "./a").expect("resolves");
978        let err = root
979            .read(&path, &TypeScript, &JavaScript)
980            .expect_err("enums are rejected");
981
982        let rendered = err.to_string();
983        assert!(
984            rendered.contains("enum"),
985            "should name the construct: {rendered}"
986        );
987    }
988}