use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use lanekeep_core::files::normalize;
use lanekeep_lang::Language;
use rquickjs::loader::{ImportAttributes, Loader, Resolver};
use rquickjs::module::{Declared, Module};
use rquickjs::{Ctx, Error as JsError};
use thiserror::Error;
use crate::typescript::strip_types;
pub const HOST_MODULE: &str = "lanekeep";
const HOST_MODULE_SOURCE: &str = r"
export function defineRule(rule) { return rule; }
export function defineConfig(config) { return config; }
";
pub type BuiltinSource = fn(&str) -> Option<&'static str>;
pub type BuiltinComponent = fn(&str) -> Option<(&'static [u8], u32)>;
pub type BuiltinComponentMap = fn(&str) -> Option<&'static [u8]>;
pub type BuiltinComponentDeclared = fn(&str) -> bool;
pub const MAX_COMPONENT_NAME: usize = 21;
fn no_builtins(_name: &str) -> Option<&'static str> {
None
}
fn no_builtin_components(_name: &str) -> Option<(&'static [u8], u32)> {
None
}
fn no_builtin_component_maps(_name: &str) -> Option<&'static [u8]> {
None
}
fn no_builtin_component_declared(_name: &str) -> bool {
false
}
const BUILTIN_PREFIX: &str = "lanekeep/";
const EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs"];
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ResolveError {
#[error(
"cannot import `{specifier}`\n \
rule modules run in a sandbox with no package resolution, so only `lanekeep` and \
relative paths starting with `./` or `../` can be imported\n \
if this needs a package, inline what you need from it instead"
)]
BareSpecifier {
specifier: String,
},
#[error(
"cannot import `{specifier}`\n \
it resolves outside the rules directory, and rule modules may only import from \
within it"
)]
EscapesRoot {
specifier: String,
},
#[error("cannot find module `{specifier}`\n tried: {tried}")]
NotFound {
specifier: String,
tried: String,
},
#[error("cannot read module `{path}`: {detail}")]
Unreadable {
path: String,
detail: String,
},
#[error("`lanekeep/{name}` is a rule component; name it in a `lanekeep.json`")]
NotAModule {
name: String,
},
#[error("`lanekeep/{name}` is a component missing its host — lanekeep bug")]
ComponentHostMissing {
name: String,
},
}
#[derive(Debug, Clone)]
pub struct RuleRoot {
root: PathBuf,
builtins: BuiltinSource,
builtin_components: BuiltinComponent,
builtin_component_maps: BuiltinComponentMap,
builtin_component_declared: BuiltinComponentDeclared,
}
impl RuleRoot {
pub fn new(root: impl AsRef<Path>) -> Result<Self, ResolveError> {
let root = root.as_ref();
let canonical = root.canonicalize().map_err(|e| ResolveError::Unreadable {
path: root.display().to_string(),
detail: e.to_string(),
})?;
Ok(Self {
root: canonical,
builtins: no_builtins,
builtin_components: no_builtin_components,
builtin_component_maps: no_builtin_component_maps,
builtin_component_declared: no_builtin_component_declared,
})
}
#[must_use]
pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
self.builtins = builtins;
self
}
#[must_use]
pub const fn with_builtin_components(mut self, components: BuiltinComponent) -> Self {
self.builtin_components = components;
self
}
#[must_use]
pub const fn with_builtin_component_maps(mut self, maps: BuiltinComponentMap) -> Self {
self.builtin_component_maps = maps;
self
}
#[must_use]
pub const fn with_builtin_component_declared(
mut self,
declared: BuiltinComponentDeclared,
) -> Self {
self.builtin_component_declared = declared;
self
}
#[must_use]
pub fn builtin_component_map(&self, name: &str) -> Option<&'static [u8]> {
(self.builtin_component_maps)(name)
}
#[must_use]
pub fn builtin_component(&self, name: &str) -> Option<(&'static [u8], u32)> {
(self.builtin_components)(name)
}
#[must_use]
pub const fn builtin_components(&self) -> BuiltinComponent {
self.builtin_components
}
#[must_use]
pub fn path(&self) -> &Path {
&self.root
}
pub fn resolve(&self, base: &str, specifier: &str) -> Result<PathBuf, ResolveError> {
if specifier == HOST_MODULE {
return Ok(PathBuf::from(HOST_MODULE));
}
if let Some(name) = specifier.strip_prefix(BUILTIN_PREFIX) {
if (self.builtin_components)(name).is_some() {
return Err(ResolveError::NotAModule {
name: name.to_owned(),
});
}
if (self.builtin_component_declared)(name) {
return Err(ResolveError::ComponentHostMissing {
name: name.to_owned(),
});
}
if (self.builtins)(name).is_some() {
return Ok(PathBuf::from(specifier));
}
return Err(ResolveError::NotFound {
specifier: specifier.to_owned(),
tried: "no built-in rule by that name".to_owned(),
});
}
if Path::new(specifier).is_absolute() {
if !base.is_empty() {
return Err(ResolveError::BareSpecifier {
specifier: specifier.to_owned(),
});
}
return self.resolve_within(specifier, &normalize(Path::new(specifier)));
}
if !specifier.starts_with('.') {
return Err(ResolveError::BareSpecifier {
specifier: specifier.to_owned(),
});
}
let base_dir = if base == HOST_MODULE || base.is_empty() {
self.root.clone()
} else {
Path::new(base)
.parent()
.map_or_else(|| self.root.clone(), Path::to_path_buf)
};
self.resolve_within(specifier, &normalize(&base_dir.join(specifier)))
}
pub fn confine(&self, specifier: &str, joined: &Path) -> Result<PathBuf, ResolveError> {
if !joined.starts_with(&self.root) {
return Err(ResolveError::EscapesRoot {
specifier: specifier.to_owned(),
});
}
let canonical = joined
.canonicalize()
.map_err(|e| ResolveError::Unreadable {
path: joined.display().to_string(),
detail: e.to_string(),
})?;
if !canonical.starts_with(&self.root) {
return Err(ResolveError::EscapesRoot {
specifier: specifier.to_owned(),
});
}
Ok(canonical)
}
fn resolve_within(&self, specifier: &str, joined: &Path) -> Result<PathBuf, ResolveError> {
if !joined.starts_with(&self.root) {
return Err(ResolveError::EscapesRoot {
specifier: specifier.to_owned(),
});
}
let mut tried = Vec::new();
for candidate in candidates(joined) {
tried.push(candidate.display().to_string());
if !candidate.is_file() {
continue;
}
return self.confine(specifier, &candidate);
}
Err(ResolveError::NotFound {
specifier: specifier.to_owned(),
tried: tried.join(", "),
})
}
pub fn read(
&self,
path: &Path,
typescript: &dyn Language,
javascript: &dyn Language,
) -> Result<String, ResolveError> {
if path == Path::new(HOST_MODULE) {
return Ok(HOST_MODULE_SOURCE.to_owned());
}
if let Some(name) = path.to_str().and_then(|p| p.strip_prefix(BUILTIN_PREFIX))
&& let Some(source) = (self.builtins)(name)
{
return strip_types(typescript, javascript, source).map_err(|e| {
ResolveError::Unreadable {
path: path.display().to_string(),
detail: e.to_string(),
}
});
}
let canonical = path.canonicalize().map_err(|e| ResolveError::Unreadable {
path: path.display().to_string(),
detail: e.to_string(),
})?;
if !canonical.starts_with(&self.root) {
return Err(ResolveError::EscapesRoot {
specifier: path.display().to_string(),
});
}
let source = std::fs::read_to_string(path).map_err(|e| ResolveError::Unreadable {
path: path.display().to_string(),
detail: e.to_string(),
})?;
let is_typescript = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| matches!(e, "ts" | "tsx" | "mts" | "cts"));
if !is_typescript {
return Ok(source);
}
strip_types(typescript, javascript, &source).map_err(|e| ResolveError::Unreadable {
path: path.display().to_string(),
detail: e.to_string(),
})
}
}
fn candidates(base: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if base.extension().is_some() {
out.push(base.to_path_buf());
}
for extension in EXTENSIONS {
out.push(base.with_extension(extension));
}
for extension in EXTENSIONS {
out.push(base.join(format!("index.{extension}")));
}
out
}
#[derive(Debug, Clone)]
pub struct RuleResolver {
root: RuleRoot,
}
impl RuleResolver {
#[must_use]
pub const fn new(root: RuleRoot) -> Self {
Self { root }
}
}
impl Resolver for RuleResolver {
fn resolve(
&mut self,
_ctx: &Ctx<'_>,
base: &str,
name: &str,
_attributes: Option<ImportAttributes<'_>>,
) -> rquickjs::Result<String> {
match self.root.resolve(base, name) {
Ok(path) => Ok(path.display().to_string()),
Err(err) => Err(JsError::new_resolving_message(
base.to_owned(),
name.to_owned(),
err.to_string(),
)),
}
}
}
pub type LoadedModules = Rc<RefCell<BTreeMap<PathBuf, String>>>;
#[derive(Clone)]
pub struct RuleLoader {
root: RuleRoot,
typescript: Arc<dyn Language>,
javascript: Arc<dyn Language>,
loaded: LoadedModules,
}
impl std::fmt::Debug for RuleLoader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuleLoader")
.field("root", &self.root)
.field("typescript", &self.typescript.id())
.field("javascript", &self.javascript.id())
.field("loaded", &self.loaded.borrow().len())
.finish()
}
}
impl RuleLoader {
#[must_use]
pub fn new(
root: RuleRoot,
typescript: Arc<dyn Language>,
javascript: Arc<dyn Language>,
) -> Self {
Self {
root,
typescript,
javascript,
loaded: Rc::new(RefCell::new(BTreeMap::new())),
}
}
#[must_use]
pub fn loaded(&self) -> LoadedModules {
Rc::clone(&self.loaded)
}
}
impl Loader for RuleLoader {
fn load<'js>(
&mut self,
ctx: &Ctx<'js>,
name: &str,
_attributes: Option<ImportAttributes<'js>>,
) -> rquickjs::Result<Module<'js, Declared>> {
let source = self
.root
.read(
Path::new(name),
self.typescript.as_ref(),
self.javascript.as_ref(),
)
.map_err(|err| JsError::new_loading_message(name.to_owned(), err.to_string()))?;
self.loaded
.borrow_mut()
.insert(PathBuf::from(name), source.clone());
Module::declare(ctx.clone(), name, source)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use lanekeep_lang_js::{JavaScript, TypeScript};
use super::*;
struct Fixture {
dir: PathBuf,
}
impl Fixture {
fn new(name: &str, files: &[(&str, &str)]) -> Self {
let dir = std::env::temp_dir().join(format!("lanekeep-loader-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("creates fixture dir");
for (path, contents) in files {
let full = dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("creates parent");
}
fs::write(&full, contents).expect("writes fixture file");
}
Self { dir }
}
fn root(&self) -> RuleRoot {
RuleRoot::new(&self.dir).expect("canonicalizes")
}
fn entry(&self, name: &str) -> String {
self.dir
.join(name)
.canonicalize()
.expect("exists")
.display()
.to_string()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn stub_builtins(name: &str) -> Option<&'static str> {
match name {
"always" => Some("export default { id: 'lanekeep/always' } satisfies unknown;"),
"compiled-from-source" => {
Some("export default { id: 'lanekeep/compiled-from-source' } satisfies unknown;")
}
"broken-row" => Some("export default { id: 'lanekeep/broken-row' } satisfies unknown;"),
_ => None,
}
}
#[test]
fn resolves_a_built_in_by_specifier() {
let fixture = Fixture::new("builtin-resolve", &[("a.ts", "export const a = 1;")]);
let root = fixture.root().with_builtins(stub_builtins);
assert_eq!(
root.resolve("", "lanekeep/always").expect("resolves"),
Path::new("lanekeep/always")
);
}
#[test]
fn an_unknown_built_in_is_not_found() {
let fixture = Fixture::new("builtin-unknown", &[("a.ts", "export const a = 1;")]);
let root = fixture.root().with_builtins(stub_builtins);
let error = root
.resolve("", "lanekeep/no-such-rule")
.expect_err("does not resolve");
assert!(
matches!(error, ResolveError::NotFound { .. }),
"expected NotFound, got {error:?}"
);
assert!(error.to_string().contains("built-in"), "{error}");
}
fn stub_builtin_components(name: &str) -> Option<(&'static [u8], u32)> {
match name {
"compiled" => Some((b"\0asm\x01\x00\x00\x00", 0)),
"compiled-from-source" => Some((b"\0asm\x01\x00\x00\x00", 3)),
_ => None,
}
}
fn stub_builtin_component_declared(name: &str) -> bool {
matches!(name, "compiled" | "compiled-from-source" | "broken-row")
}
#[test]
fn a_built_in_that_is_a_component_is_refused_as_a_module() {
let fixture = Fixture::new("builtin-component", &[("a.ts", "export const a = 1;")]);
let root = fixture
.root()
.with_builtins(stub_builtins)
.with_builtin_components(stub_builtin_components);
let error = root
.resolve("", "lanekeep/compiled")
.expect_err("a component is not importable");
assert!(
matches!(&error, ResolveError::NotAModule { name } if name == "compiled"),
"expected NotAModule, got {error:?}"
);
let rendered = error.to_string();
assert!(rendered.contains("lanekeep/compiled"), "{rendered}");
assert!(rendered.contains("component"), "{rendered}");
assert!(
rendered.contains("lanekeep.json"),
"the message has to name the format that can reach it: {rendered}"
);
}
#[test]
fn the_refusal_survives_quickjs_beside_a_long_path() {
const BUDGET: usize = 255;
const PATH: usize = 108;
let framing = "Error resolving module '' from '': ".len();
let name = "x".repeat(MAX_COMPONENT_NAME);
let specifier = format!("lanekeep/{name}").len();
let message = ResolveError::NotAModule { name }.to_string();
let total = framing + specifier + PATH + message.len();
assert!(
total <= BUDGET,
"QuickJS keeps {BUDGET} bytes and this needs {total} beside a {PATH}-byte path \
({} of them the message): the remedy is what gets cut\n {message}",
message.len(),
);
}
#[test]
fn a_component_wins_over_a_source_of_the_same_name() {
let fixture = Fixture::new("builtin-both", &[("a.ts", "export const a = 1;")]);
let root = fixture
.root()
.with_builtins(stub_builtins)
.with_builtin_components(stub_builtin_components);
let error = root
.resolve("", "lanekeep/compiled-from-source")
.expect_err("the component is what ships, so the import is refused");
assert!(
matches!(&error, ResolveError::NotAModule { name } if name == "compiled-from-source"),
"expected NotAModule, got {error:?}"
);
assert!(
stub_builtins("compiled-from-source").is_some(),
"the fixture must have both, or this test asserts nothing"
);
}
#[test]
fn a_declared_component_whose_host_is_missing_is_refused_not_served() {
let fixture = Fixture::new("builtin-broken-row", &[("a.ts", "export const a = 1;")]);
let root = fixture
.root()
.with_builtins(stub_builtins)
.with_builtin_components(stub_builtin_components)
.with_builtin_component_declared(stub_builtin_component_declared);
let error = root
.resolve("", "lanekeep/broken-row")
.expect_err("a broken component row is refused, not served");
assert!(
matches!(&error, ResolveError::ComponentHostMissing { name } if name == "broken-row"),
"expected ComponentHostMissing, got {error:?}"
);
assert!(
stub_builtins("broken-row").is_some(),
"the fixture must have a source, or this test asserts nothing"
);
}
#[test]
fn the_broken_row_refusal_survives_quickjs_beside_a_long_path() {
const BUDGET: usize = 255;
const PATH: usize = 108;
let framing = "Error resolving module '' from '': ".len();
let name = "x".repeat(MAX_COMPONENT_NAME);
let specifier = format!("lanekeep/{name}").len();
let message = ResolveError::ComponentHostMissing { name }.to_string();
let total = framing + specifier + PATH + message.len();
assert!(
total <= BUDGET,
"QuickJS keeps {BUDGET} bytes and this needs {total} beside a {PATH}-byte path \
({} of them the message): the remedy is what gets cut\n {message}",
message.len(),
);
}
#[test]
fn an_unknown_name_is_still_not_found_when_components_ship() {
let fixture = Fixture::new("builtin-component-miss", &[("a.ts", "export const a = 1;")]);
let root = fixture
.root()
.with_builtins(stub_builtins)
.with_builtin_components(stub_builtin_components);
let error = root
.resolve("", "lanekeep/no-such-rule")
.expect_err("does not resolve");
assert!(
matches!(error, ResolveError::NotFound { .. }),
"expected NotFound, got {error:?}"
);
}
#[test]
fn a_component_is_reachable_by_name_without_being_importable() {
let fixture = Fixture::new(
"builtin-component-bytes",
&[("a.ts", "export const a = 1;")],
);
let root = fixture
.root()
.with_builtin_components(stub_builtin_components);
assert_eq!(
root.builtin_component("compiled"),
Some((b"\0asm\x01\x00\x00\x00".as_slice(), 0))
);
assert_eq!(
root.builtin_component("compiled-from-source"),
Some((b"\0asm\x01\x00\x00\x00".as_slice(), 3))
);
assert_eq!(root.builtin_component("always"), None);
}
#[test]
fn a_file_cannot_shadow_a_built_in() {
let fixture = Fixture::new(
"builtin-shadow",
&[("lanekeep/always.ts", "export default 'the wrong one';")],
);
let root = fixture.root().with_builtins(stub_builtins);
let resolved = root.resolve("", "lanekeep/always").expect("resolves");
assert_eq!(resolved, Path::new("lanekeep/always"));
let source = root
.read(&resolved, &TypeScript, &JavaScript)
.expect("reads");
assert!(
!source.contains("the wrong one"),
"a project file shadowed a built-in: {source}"
);
}
#[test]
fn a_built_in_is_stripped_of_its_types() {
let fixture = Fixture::new("builtin-strip", &[("a.ts", "export const a = 1;")]);
let root = fixture.root().with_builtins(stub_builtins);
let source = root
.read(Path::new("lanekeep/always"), &TypeScript, &JavaScript)
.expect("reads");
assert!(
!source.contains("satisfies"),
"type syntax survived stripping: {source}"
);
}
#[test]
fn built_ins_are_absent_unless_provided() {
let fixture = Fixture::new("builtin-default", &[("a.ts", "export const a = 1;")]);
let root = fixture.root();
assert!(root.resolve("", "lanekeep/always").is_err());
}
#[test]
fn resolves_the_host_module() {
let fixture = Fixture::new("host", &[("a.ts", "export const a = 1;")]);
let root = fixture.root();
assert_eq!(
root.resolve("", HOST_MODULE).expect("resolves"),
Path::new(HOST_MODULE)
);
}
#[test]
fn the_host_module_exports_the_authoring_helpers() {
let fixture = Fixture::new("host-src", &[]);
let source = fixture
.root()
.read(Path::new(HOST_MODULE), &TypeScript, &JavaScript)
.expect("reads");
assert!(source.contains("defineRule"), "{source}");
assert!(source.contains("defineConfig"), "{source}");
}
#[test]
fn resolves_a_relative_import() {
let fixture = Fixture::new(
"relative",
&[
("main.ts", "import './helper';"),
("helper.ts", "export const h = 1;"),
],
);
let root = fixture.root();
let resolved = root
.resolve(&fixture.entry("main.ts"), "./helper")
.expect("resolves");
assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
}
#[test]
fn tries_extensions_in_order() {
let fixture = Fixture::new(
"extensions",
&[
("main.ts", ""),
("dup.ts", "export const from = 'ts';"),
("dup.js", "export const from = 'js';"),
],
);
let resolved = fixture
.root()
.resolve(&fixture.entry("main.ts"), "./dup")
.expect("resolves");
assert!(
resolved.ends_with("dup.ts"),
"expected the TypeScript file: {resolved:?}"
);
}
#[test]
fn resolves_a_directory_index() {
let fixture = Fixture::new(
"index",
&[("main.ts", ""), ("rules/index.ts", "export const r = 1;")],
);
let resolved = fixture
.root()
.resolve(&fixture.entry("main.ts"), "./rules")
.expect("resolves");
assert!(resolved.ends_with("index.ts"), "{resolved:?}");
}
#[test]
fn a_file_beats_a_same_named_directory_index() {
let fixture = Fixture::new(
"file-over-index",
&[
("main.ts", ""),
("rules.ts", "export const from = 'file';"),
("rules/index.ts", "export const from = 'index';"),
],
);
let resolved = fixture
.root()
.resolve(&fixture.entry("main.ts"), "./rules")
.expect("resolves");
assert!(
resolved.ends_with("rules.ts"),
"expected the file, not the directory: {resolved:?}"
);
}
#[test]
fn resolves_an_explicit_extension() {
let fixture = Fixture::new(
"explicit",
&[("main.ts", ""), ("helper.ts", "export const h = 1;")],
);
let resolved = fixture
.root()
.resolve(&fixture.entry("main.ts"), "./helper.ts")
.expect("resolves");
assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
}
#[test]
fn rejects_bare_specifiers() {
let fixture = Fixture::new("bare", &[("main.ts", "")]);
let root = fixture.root();
for specifier in [
"lodash",
"react",
"node:fs",
"fs",
"@scope/pkg",
"typescript",
] {
let err = root
.resolve(&fixture.entry("main.ts"), specifier)
.expect_err("bare specifiers must not resolve");
assert!(
matches!(err, ResolveError::BareSpecifier { .. }),
"{specifier} gave {err:?}"
);
}
}
#[test]
fn a_bare_specifier_explains_why() {
let fixture = Fixture::new("bare-msg", &[("main.ts", "")]);
let err = fixture
.root()
.resolve(&fixture.entry("main.ts"), "lodash")
.expect_err("bare specifiers do not resolve");
assert!(matches!(err, ResolveError::BareSpecifier { .. }), "{err:?}");
let rendered = err.to_string();
assert!(rendered.contains("no package resolution"), "{rendered}");
assert!(rendered.contains("lanekeep"), "{rendered}");
}
#[test]
fn rejects_traversal_out_of_the_root() {
let fixture = Fixture::new("traversal", &[("main.ts", "")]);
let root = fixture.root();
let base = fixture.entry("main.ts");
for specifier in ["../outside", "../../etc/passwd", "./../../secrets", "../"] {
let err = root
.resolve(&base, specifier)
.expect_err("traversal must not resolve");
assert!(
matches!(err, ResolveError::EscapesRoot { .. }),
"{specifier} gave {err:?}"
);
}
}
#[test]
fn traversal_is_rejected_even_when_the_target_exists() {
let fixture = Fixture::new(
"traversal-real",
&[("nested/main.ts", ""), ("secret.ts", "export const s = 1;")],
);
let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
let err = root
.resolve(&fixture.entry("nested/main.ts"), "../secret")
.expect_err("must not escape");
assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
}
#[cfg(unix)]
#[test]
fn rejects_a_symlink_pointing_outside_the_root() {
let fixture = Fixture::new(
"symlink",
&[
("nested/main.ts", ""),
("outside.ts", "export const o = 1;"),
],
);
let root_dir = fixture.dir.join("nested");
let link = root_dir.join("link.ts");
std::os::unix::fs::symlink(fixture.dir.join("outside.ts"), &link).expect("creates symlink");
let root = RuleRoot::new(&root_dir).expect("canonicalizes");
let err = root
.resolve(&fixture.entry("nested/main.ts"), "./link")
.expect_err("a symlink out of the root must be rejected");
assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
}
#[test]
fn a_rule_may_not_import_an_absolute_path() {
let fixture = Fixture::new("absolute", &[("main.ts", "")]);
let root = fixture.root();
let base = fixture.entry("main.ts");
let outside = std::env::temp_dir().join("lanekeep-absolute-probe.ts");
let outside = outside.display().to_string();
for specifier in [outside.as_str(), "/etc/passwd", "C:\\Windows\\System32\\x"] {
assert!(
root.resolve(&base, specifier).is_err(),
"a rule must not import `{specifier}`"
);
}
let err = root
.resolve("", &outside)
.expect_err("an entry outside the root must be refused");
assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
}
#[test]
fn reports_what_it_tried_when_nothing_matches() {
let fixture = Fixture::new("missing", &[("main.ts", "")]);
let err = fixture
.root()
.resolve(&fixture.entry("main.ts"), "./nope")
.expect_err("nothing to find");
match err {
ResolveError::NotFound { tried, .. } => {
assert!(tried.contains("nope.ts"), "should list candidates: {tried}");
assert!(
tried.contains("index.ts"),
"should list index candidates: {tried}"
);
}
other => panic!("wrong error: {other:?}"),
}
}
#[test]
fn strips_types_when_reading_typescript() {
let fixture = Fixture::new(
"read-ts",
&[("a.ts", "export const a: number = 1;\ninterface B {}\n")],
);
let root = fixture.root();
let path = root.resolve("", "./a").expect("resolves");
let source = root.read(&path, &TypeScript, &JavaScript).expect("reads");
assert!(!source.contains(": number"), "{source}");
assert!(!source.contains("interface"), "{source}");
assert!(source.contains("export const a"), "{source}");
}
#[test]
fn reading_refuses_a_path_outside_the_root_even_if_resolution_was_skipped() {
let fixture = Fixture::new(
"read-escape",
&[
("nested/main.ts", ""),
("outside.ts", "export const o = 1;"),
],
);
let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
let err = root
.read(&fixture.dir.join("outside.ts"), &TypeScript, &JavaScript)
.expect_err("reading outside the root must be refused");
assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
}
#[test]
fn passes_javascript_through_untouched() {
let contents = "export const a = 1;\n";
let fixture = Fixture::new("read-js", &[("a.js", contents)]);
let root = fixture.root();
let path = root.resolve("", "./a.js").expect("resolves");
assert_eq!(
root.read(&path, &TypeScript, &JavaScript).expect("reads"),
contents
);
}
fn sandbox_for(fixture: &Fixture) -> crate::Sandbox {
crate::Sandbox::with_modules(
crate::Limits::default(),
crate::RunClock::start(std::time::Duration::from_secs(30)),
fixture.root(),
Arc::new(TypeScript),
Arc::new(JavaScript),
)
.expect("sandbox builds")
}
#[test]
fn loads_a_rule_module_that_imports_the_host_module() {
let fixture = Fixture::new(
"e2e-host",
&[(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
export default defineRule({ id: 'local/example' });\n",
)],
);
let sandbox = sandbox_for(&fixture);
let path = fixture.root().resolve("", "./rule").expect("resolves");
let module: std::collections::HashMap<String, String> =
sandbox.import_default(&path).expect("module evaluates");
assert_eq!(module.get("id").map(String::as_str), Some("local/example"));
}
#[test]
fn loads_a_module_that_imports_a_sibling_and_strips_its_types() {
let fixture = Fixture::new(
"e2e-sibling",
&[
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
import { NAME } from './shared';\n\
export default defineRule({ id: NAME });\n",
),
(
"shared.ts",
"interface Unused { a: number }\n\
export const NAME: string = 'local/from-sibling';\n",
),
],
);
let sandbox = sandbox_for(&fixture);
let path = fixture.root().resolve("", "./rule").expect("resolves");
let module: std::collections::HashMap<String, String> =
sandbox.import_default(&path).expect("module evaluates");
assert_eq!(
module.get("id").map(String::as_str),
Some("local/from-sibling")
);
}
#[test]
fn a_bare_import_fails_at_load_with_the_explanation() {
let fixture = Fixture::new(
"e2e-bare",
&[(
"rule.ts",
"import lodash from 'lodash';\nexport default lodash;\n",
)],
);
let sandbox = sandbox_for(&fixture);
let path = fixture.root().resolve("", "./rule").expect("resolves");
let err = sandbox
.import_default::<std::collections::HashMap<String, String>>(&path)
.expect_err("lodash cannot resolve");
let rendered = err.to_string();
assert!(rendered.contains("lodash"), "{rendered}");
}
#[test]
fn a_traversing_import_fails_at_load() {
let fixture = Fixture::new(
"e2e-traversal",
&[
(
"nested/rule.ts",
"import x from '../outside';\nexport default x;\n",
),
("outside.ts", "export default 1;\n"),
],
);
let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
let sandbox = crate::Sandbox::with_modules(
crate::Limits::default(),
crate::RunClock::start(std::time::Duration::from_secs(30)),
root.clone(),
Arc::new(TypeScript),
Arc::new(JavaScript),
)
.expect("sandbox builds");
let path = root.resolve("", "./rule").expect("resolves");
assert!(
sandbox
.import_default::<std::collections::HashMap<String, String>>(&path)
.is_err(),
"an import escaping the root must not load"
);
}
#[test]
fn a_module_that_fails_to_strip_reports_the_reason() {
let fixture = Fixture::new("read-bad", &[("a.ts", "enum E { A }\n")]);
let root = fixture.root();
let path = root.resolve("", "./a").expect("resolves");
let err = root
.read(&path, &TypeScript, &JavaScript)
.expect_err("enums are rejected");
let rendered = err.to_string();
assert!(
rendered.contains("enum"),
"should name the construct: {rendered}"
);
}
}