1use 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
33pub const HOST_MODULE: &str = "lanekeep";
35
36const HOST_MODULE_SOURCE: &str = r"
42 export function defineRule(rule) { return rule; }
43 export function defineConfig(config) { return config; }
44";
45
46pub type BuiltinSource = fn(&str) -> Option<&'static str>;
52
53fn no_builtins(_name: &str) -> Option<&'static str> {
55 None
56}
57
58const BUILTIN_PREFIX: &str = "lanekeep/";
60
61const EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs"];
63
64#[derive(Debug, Clone, PartialEq, Eq, Error)]
66pub enum ResolveError {
67 #[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 specifier: String,
77 },
78
79 #[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 specifier: String,
88 },
89
90 #[error("cannot find module `{specifier}`\n tried: {tried}")]
92 NotFound {
93 specifier: String,
95 tried: String,
97 },
98
99 #[error("cannot read module `{path}`: {detail}")]
101 Unreadable {
102 path: String,
104 detail: String,
106 },
107}
108
109#[derive(Debug, Clone)]
111pub struct RuleRoot {
112 root: PathBuf,
113 builtins: BuiltinSource,
114}
115
116impl RuleRoot {
117 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 #[must_use]
140 pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
141 self.builtins = builtins;
142 self
143 }
144
145 #[must_use]
147 pub fn path(&self) -> &Path {
148 &self.root
149 }
150
151 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 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 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 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 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 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 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 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
312fn candidates(base: &Path) -> Vec<PathBuf> {
314 let mut out = Vec::new();
315
316 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#[derive(Debug, Clone)]
333pub struct RuleResolver {
334 root: RuleRoot,
335}
336
337impl RuleResolver {
338 #[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 Err(err) => Err(JsError::new_resolving_message(
358 base.to_owned(),
359 name.to_owned(),
360 err.to_string(),
361 )),
362 }
363 }
364}
365
366pub type LoadedModules = Rc<RefCell<BTreeMap<PathBuf, String>>>;
375
376#[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 #[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 #[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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 #[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 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 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}