use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
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::files::normalize;
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>;
fn no_builtins(_name: &str) -> Option<&'static str> {
None
}
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,
},
}
#[derive(Debug, Clone)]
pub struct RuleRoot {
root: PathBuf,
builtins: BuiltinSource,
}
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,
})
}
#[must_use]
pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
self.builtins = builtins;
self
}
#[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) {
return if (self.builtins)(name).is_some() {
Ok(PathBuf::from(specifier))
} else {
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)))
}
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;
}
let canonical = candidate
.canonicalize()
.map_err(|e| ResolveError::Unreadable {
path: candidate.display().to_string(),
detail: e.to_string(),
})?;
if !canonical.starts_with(&self.root) {
return Err(ResolveError::EscapesRoot {
specifier: specifier.to_owned(),
});
}
return Ok(canonical);
}
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;"),
_ => 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}");
}
#[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 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}"
);
}
}