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_core::files::normalize;
25use lanekeep_lang::Language;
26use rquickjs::loader::{ImportAttributes, Loader, Resolver};
27use rquickjs::module::{Declared, Module};
28use rquickjs::{Ctx, Error as JsError};
29use thiserror::Error;
30
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/// Resolves a built-in rule name to its embedded component.
54///
55/// The sibling of [`BuiltinSource`], and it lives here for one reason: **`lanekeep/<name>` has
56/// to mean one thing.** A built-in shipped as a component is not importable, and the resolver
57/// is what has to say so — a name it did not know about would come back "no built-in rule by
58/// that name", which is what a typo looks like and would send a reader hunting for a
59/// misspelling that is not there.
60///
61/// Nothing in this crate loads the bytes. `lanekeep-config` reads them through
62/// [`RuleRoot::builtin_component`] when a config names one, exactly as it reads a `.wasm` path.
63/// Keeping both lookups on one value is what stops a name resolving to a module in one place
64/// and a component in another.
65///
66/// **The index is part of the answer.** One artifact hosts several rules — the four TypeScript
67/// built-ins share one — so bytes alone name a program rather than a rule. A lookup returning
68/// only bytes would leave the caller to run whichever rule the component enumerates first, and
69/// a wrong rule reporting is indistinguishable from a right one reporting.
70pub type BuiltinComponent = fn(&str) -> Option<(&'static [u8], u32)>;
71
72/// Resolves a built-in rule name to its component's embedded source map.
73///
74/// A third hook rather than a third element of [`BuiltinComponent`], because it answers a
75/// different kind of question and almost every caller has no use for it: what a map buys is where
76/// a *thrown* rule error is reported, and nothing else. A violation's position never passes
77/// through JavaScript at all, so a build that wired this to nothing would produce identical
78/// violations and a worse stack.
79///
80/// That is also its risk, and the reason `crates/lanekeep-rules/tests/source_maps.rs` asserts the
81/// wiring end to end rather than trusting it: a caller that sets [`RuleRoot::with_builtin_components`]
82/// and forgets this one gets rules that work and diagnostics that name `entry.js`, with nothing
83/// anywhere going red.
84///
85/// `None` is the ordinary answer. Every component built from Rust is one — a panicking rule traps,
86/// and a trap reaches the host with no stack to remap.
87pub type BuiltinComponentMap = fn(&str) -> Option<&'static [u8]>;
88
89/// Resolves whether a built-in rule name is *declared* as a component, regardless of whether
90/// its host ships.
91///
92/// The sibling of [`BuiltinComponent`] that answers a different question. `BuiltinComponent`
93/// answers `None` for a name that is not a component *and* for one whose row names a host this
94/// build does not ship; this hook tells the two apart, so the resolver can refuse the broken
95/// row as a lanekeep bug rather than report a misspelling or serve a stale source.
96pub type BuiltinComponentDeclared = fn(&str) -> bool;
97
98/// The longest a component-hosted built-in's name may be before refusing it stops being
99/// actionable.
100///
101/// Not a limit on rule names in general, and nothing enforces it here — it is a budget derived
102/// from one number this crate does not control. [`ResolveError::NotAModule`] reaches a user
103/// through QuickJS, which truncates a thrown error at 255 bytes, behind rquickjs's
104/// `Error resolving module '<specifier>' from '<path>': ` framing. The name is spent twice in
105/// that — once in the specifier, once inside the message — so each character costs two bytes of
106/// whatever is left for the *project's own path*, and what gets cut is the end of the message,
107/// which is the half telling the user what to do.
108///
109/// The name is the *rule's*, as a config writes it, not the artifact's: `typescript-builtins`
110/// hosts four rules and appears in no message.
111///
112/// # It was 15, and 21 is what the TypeScript built-ins cost
113///
114/// The four rules compiled into `typescript-builtins` are 17 to 21 characters —
115/// `no-restricted-imports` is the longest thing that can appear here — and the arithmetic is
116/// `35 + (9 + name) + path + (61 + name) <= 255`, so `path <= 150 - 2 * name`. The whole
117/// message survived beside a **120**-byte config path at 15 and survives beside a **108**-byte
118/// one at 21.
119///
120/// **That is a smaller path budget, accepted deliberately and stated here**, which is one of
121/// the two honest ways this constant may move; the other is shortening the message, which costs
122/// a user the wording that tells them what to do. 108 bytes still clears
123/// `/Users/alice/projects/acme/packages/checkout/lanekeep.config.ts` — 63 — with 45 to spare,
124/// and it does not clear everything: at 27 characters `no-mutable-default-argument` would leave
125/// 96, so migrating *that* rule is a decision to take here rather than a table edit.
126///
127/// `the_refusal_survives_quickjs_beside_a_long_path` derives it and `lanekeep-rules`'
128/// `every_component_name_fits_the_refusal_message` enforces it against the names that actually
129/// ship, because this crate sits below that one and cannot see them.
130pub const MAX_COMPONENT_NAME: usize = 21;
131
132/// The default: no built-ins, so a bare `lanekeep-js` resolves only project modules.
133fn no_builtins(_name: &str) -> Option<&'static str> {
134 None
135}
136
137/// The default: no built-in components.
138fn no_builtin_components(_name: &str) -> Option<(&'static [u8], u32)> {
139 None
140}
141
142/// The default: no source maps, which is also the answer for every component that has none.
143fn no_builtin_component_maps(_name: &str) -> Option<&'static [u8]> {
144 None
145}
146
147/// The default: no name is declared as a component.
148fn no_builtin_component_declared(_name: &str) -> bool {
149 false
150}
151
152/// The prefix a built-in specifier carries, as in `lanekeep/no-default-export`.
153const BUILTIN_PREFIX: &str = "lanekeep/";
154
155/// Extensions tried for a specifier that does not name one, in order.
156const EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs"];
157
158/// Why a module specifier could not be resolved.
159#[derive(Debug, Clone, PartialEq, Eq, Error)]
160pub enum ResolveError {
161 /// A bare specifier, which would be an npm package.
162 #[error(
163 "cannot import `{specifier}`\n \
164 rule modules run in a sandbox with no package resolution, so only `lanekeep` and \
165 relative paths starting with `./` or `../` can be imported\n \
166 if this needs a package, inline what you need from it instead"
167 )]
168 BareSpecifier {
169 /// The specifier as written.
170 specifier: String,
171 },
172
173 /// The specifier resolves outside the rules root.
174 #[error(
175 "cannot import `{specifier}`\n \
176 it resolves outside the rules directory, and rule modules may only import from \
177 within it"
178 )]
179 EscapesRoot {
180 /// The specifier as written.
181 specifier: String,
182 },
183
184 /// Nothing exists at the specifier.
185 #[error("cannot find module `{specifier}`\n tried: {tried}")]
186 NotFound {
187 /// The specifier as written.
188 specifier: String,
189 /// The candidate paths that were tried.
190 tried: String,
191 },
192
193 /// The module exists but could not be read.
194 #[error("cannot read module `{path}`: {detail}")]
195 Unreadable {
196 /// The path that failed.
197 path: String,
198 /// The underlying reason.
199 detail: String,
200 },
201
202 /// The specifier names a built-in that ships as a component rather than as a module.
203 ///
204 /// Its own variant rather than a [`ResolveError::NotFound`] with a different string,
205 /// because the two are different facts and only one of them is the user's mistake. A
206 /// built-in that is not there is a typo; a built-in that is a component is spelled
207 /// correctly and simply cannot be imported, and telling a reader to check their spelling
208 /// would send them looking for something that is not wrong.
209 ///
210 /// **One line, carrying both the fact and the remedy, and that is not a style choice.**
211 /// QuickJS truncates a thrown error at 255 bytes, and a resolution failure reaches the user
212 /// as `Error resolving module '<specifier>' from '<absolute path>': <this>` — 35 bytes of
213 /// framing plus the specifier plus the *project's own path* before this message starts. So
214 /// a second line is not a place to put anything: it is the first thing a real path spends
215 /// the budget on.
216 ///
217 /// A two-line version of this shipped and was wrong in exactly that way. It front-loaded
218 /// the fact and put "name it in a `lanekeep.json`" second, which survived only for a config
219 /// path of 47 characters or fewer — so every user deep enough in a monorepo to have the
220 /// problem was told they had it and not what to do. There is no in-format escape either:
221 /// `packages/lanekeep/index.d.ts` types `rules` as `Rule[]`, so a `.config.ts` cannot name
222 /// a rule by string. Converting to JSON is the only route, and that was the sentence being
223 /// cut.
224 ///
225 /// The specifier is repeated here even though the framing already carries it, because this
226 /// is a public error type and a sibling variant read alone names its subject. It costs the
227 /// budget twice over, which is why the rest is as short as it is.
228 ///
229 /// `the_refusal_survives_quickjs_beside_a_long_path` is what holds the arithmetic. See
230 /// `AGENTS.md`.
231 #[error("`lanekeep/{name}` is a rule component; name it in a `lanekeep.json`")]
232 NotAModule {
233 /// The built-in's name, without the `lanekeep/` prefix.
234 name: String,
235 },
236
237 /// The specifier names a built-in that is declared as a component, but the component's
238 /// host is missing from the built-in table.
239 ///
240 /// Its own variant rather than a [`ResolveError::NotFound`] with a different string,
241 /// because the two are different facts and only one of them is the user's mistake. A
242 /// built-in that is not there is a typo; a built-in whose component row is broken is a
243 /// lanekeep bug, and telling a reader to check their spelling would send them looking for
244 /// something that is not wrong.
245 ///
246 /// **One line, carrying both the fact and the remedy, on the same terms as
247 /// [`ResolveError::NotAModule`].** QuickJS truncates a thrown error at 255 bytes, so the
248 /// message is budgeted the same way — see [`MAX_COMPONENT_NAME`] and
249 /// `the_broken_row_refusal_survives_quickjs_beside_a_long_path`.
250 #[error("`lanekeep/{name}` is a component missing its host — lanekeep bug")]
251 ComponentHostMissing {
252 /// The built-in's name, without the `lanekeep/` prefix.
253 name: String,
254 },
255}
256
257/// Where rule modules live, and what may be imported.
258#[derive(Debug, Clone)]
259pub struct RuleRoot {
260 root: PathBuf,
261 builtins: BuiltinSource,
262 builtin_components: BuiltinComponent,
263 builtin_component_maps: BuiltinComponentMap,
264 builtin_component_declared: BuiltinComponentDeclared,
265}
266
267impl RuleRoot {
268 /// Anchor resolution at a directory.
269 ///
270 /// # Errors
271 ///
272 /// Fails if the directory does not exist or cannot be canonicalized.
273 pub fn new(root: impl AsRef<Path>) -> Result<Self, ResolveError> {
274 let root = root.as_ref();
275 let canonical = root.canonicalize().map_err(|e| ResolveError::Unreadable {
276 path: root.display().to_string(),
277 detail: e.to_string(),
278 })?;
279 Ok(Self {
280 root: canonical,
281 builtins: no_builtins,
282 builtin_components: no_builtin_components,
283 builtin_component_maps: no_builtin_component_maps,
284 builtin_component_declared: no_builtin_component_declared,
285 })
286 }
287
288 /// Serve built-in rules from embedded sources.
289 ///
290 /// Built-ins resolve before anything on disk, so a project file cannot shadow one —
291 /// a rule whose behavior depended on whether a same-named file happened to exist
292 /// would be impossible to reason about.
293 #[must_use]
294 pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
295 self.builtins = builtins;
296 self
297 }
298
299 /// Serve built-in rules that ship as components from embedded bytes.
300 ///
301 /// Beside [`RuleRoot::with_builtins`] rather than replacing it: a built-in is one or the
302 /// other, and which one it is is not something a config writes or a user chooses. Both
303 /// resolve under the same `lanekeep/` prefix and both resolve before the filesystem.
304 #[must_use]
305 pub const fn with_builtin_components(mut self, components: BuiltinComponent) -> Self {
306 self.builtin_components = components;
307 self
308 }
309
310 /// Serve the source maps of the built-ins that ship as components.
311 ///
312 /// Separate from [`RuleRoot::with_builtin_components`] on the terms [`BuiltinComponentMap`]
313 /// gives: a map answers a diagnostics question, most components have none, and a caller that
314 /// wires one hook and not the other loses a stack rather than a rule.
315 #[must_use]
316 pub const fn with_builtin_component_maps(mut self, maps: BuiltinComponentMap) -> Self {
317 self.builtin_component_maps = maps;
318 self
319 }
320
321 /// Serve the "declared as a component" table, so a name whose component row is broken is
322 /// refused as a lanekeep bug rather than served from a stale source or reported as a typo.
323 ///
324 /// Beside [`RuleRoot::with_builtin_components`]: the component lookup answers `None` for a
325 /// name that is not a component *and* for one whose host is missing, and this hook is what
326 /// tells the two apart.
327 #[must_use]
328 pub const fn with_builtin_component_declared(
329 mut self,
330 declared: BuiltinComponentDeclared,
331 ) -> Self {
332 self.builtin_component_declared = declared;
333 self
334 }
335
336 /// The source map of the component behind a built-in's name, or `None`.
337 ///
338 /// Asked by `lanekeep-config` beside [`RuleRoot::builtin_component`], with the same name.
339 #[must_use]
340 pub fn builtin_component_map(&self, name: &str) -> Option<&'static [u8]> {
341 (self.builtin_component_maps)(name)
342 }
343
344 /// The component behind a built-in's name and the index it sits at, or `None`.
345 ///
346 /// `lanekeep-config` asks this when a config names `lanekeep/<name>`, because a component
347 /// is resolved in Rust and never crosses into the sandbox. Nothing in this crate loads it.
348 #[must_use]
349 pub fn builtin_component(&self, name: &str) -> Option<(&'static [u8], u32)> {
350 (self.builtin_components)(name)
351 }
352
353 /// The lookup itself, for a caller that classifies many names at once.
354 #[must_use]
355 pub const fn builtin_components(&self) -> BuiltinComponent {
356 self.builtin_components
357 }
358
359 /// The canonical root.
360 #[must_use]
361 pub fn path(&self) -> &Path {
362 &self.root
363 }
364
365 /// Resolve a specifier against the module that imported it.
366 ///
367 /// # Errors
368 ///
369 /// Returns [`ResolveError`] for a bare specifier, an escape from the root, or a
370 /// specifier matching no file.
371 pub fn resolve(&self, base: &str, specifier: &str) -> Result<PathBuf, ResolveError> {
372 if specifier == HOST_MODULE {
373 return Ok(PathBuf::from(HOST_MODULE));
374 }
375
376 // Built-ins resolve before the filesystem is consulted at all.
377 if let Some(name) = specifier.strip_prefix(BUILTIN_PREFIX) {
378 // A built-in that ships as a component is spelled correctly and is still not
379 // importable, so it is refused on its own terms.
380 //
381 // **Asked before the source lookup, and the order is the whole of the guarantee.**
382 // A name can be both: the four TypeScript rules compiled into one component keep
383 // their sources, because that is what the component was built from and what their
384 // tests run through this engine. Asking the source first would answer an `import`
385 // with the QuickJS copy while a `lanekeep.json` ran the component — one id, two
386 // programs, and nothing in the output to say which one reported. The component is
387 // what ships, so the component is the answer, and the other spelling is refused
388 // rather than quietly served.
389 if (self.builtin_components)(name).is_some() {
390 return Err(ResolveError::NotAModule {
391 name: name.to_owned(),
392 });
393 }
394 // A name declared as a component but whose host is missing is a broken table, not
395 // a misspelling and not a module. Refused here — before the source lookup — so a
396 // stale TypeScript copy is never substituted for the component that should have
397 // shipped.
398 if (self.builtin_component_declared)(name) {
399 return Err(ResolveError::ComponentHostMissing {
400 name: name.to_owned(),
401 });
402 }
403 if (self.builtins)(name).is_some() {
404 return Ok(PathBuf::from(specifier));
405 }
406 return Err(ResolveError::NotFound {
407 specifier: specifier.to_owned(),
408 tried: "no built-in rule by that name".to_owned(),
409 });
410 }
411
412 // The entry module arrives as an already-resolved absolute path, because that is
413 // what the caller hands the engine to import. Accepting one is therefore necessary,
414 // but only for the entry: an empty base means nothing imported this.
415 //
416 // A rule writing `import '/etc/passwd'` always has a base — the importing module's
417 // own path — so it falls through to the bare-specifier rejection below rather than
418 // through this door. Containment is still checked either way.
419 if Path::new(specifier).is_absolute() {
420 if !base.is_empty() {
421 return Err(ResolveError::BareSpecifier {
422 specifier: specifier.to_owned(),
423 });
424 }
425 return self.resolve_within(specifier, &normalize(Path::new(specifier)));
426 }
427
428 if !specifier.starts_with('.') {
429 return Err(ResolveError::BareSpecifier {
430 specifier: specifier.to_owned(),
431 });
432 }
433
434 let base_dir = if base == HOST_MODULE || base.is_empty() {
435 self.root.clone()
436 } else {
437 Path::new(base)
438 .parent()
439 .map_or_else(|| self.root.clone(), Path::to_path_buf)
440 };
441
442 self.resolve_within(specifier, &normalize(&base_dir.join(specifier)))
443 }
444
445 /// Confine an already-joined path to the root, and hand back its canonical form.
446 ///
447 /// **The containment rules, in one place, for callers that resolved a path some other
448 /// way.** [`RuleRoot::resolve`] uses it for the candidate it found; `lanekeep-config` uses
449 /// it for a `.wasm` rule reference, which is joined against the root by
450 /// `json::classify` and never goes near module resolution. Two sets of confinement rules
451 /// would be two things to keep right, and the second one is always the one that is wrong:
452 /// a lexical check alone looks complete and does not see a symlink.
453 ///
454 /// Both checks, in this order, and the order is the point. The lexical one fires whatever
455 /// is on disk, so `../../secrets` is refused identically whether or not it is there — an
456 /// error that depended on that would tell a reader something about the filesystem instead
457 /// of about their config. The canonical one is what sees through a symlink, and it can
458 /// only be made after the file is known to exist.
459 ///
460 /// # Errors
461 ///
462 /// Returns [`ResolveError::EscapesRoot`] when the path is outside the root either
463 /// lexically or after canonicalization, and [`ResolveError::Unreadable`] when it cannot be
464 /// canonicalized — which for a path that is simply not there is what "not found" looks
465 /// like at this level.
466 pub fn confine(&self, specifier: &str, joined: &Path) -> Result<PathBuf, ResolveError> {
467 if !joined.starts_with(&self.root) {
468 return Err(ResolveError::EscapesRoot {
469 specifier: specifier.to_owned(),
470 });
471 }
472
473 // Canonicalize the file that was actually found. This is the check that holds against
474 // symlinks — the lexical test above cannot see through one.
475 let canonical = joined
476 .canonicalize()
477 .map_err(|e| ResolveError::Unreadable {
478 path: joined.display().to_string(),
479 detail: e.to_string(),
480 })?;
481 if !canonical.starts_with(&self.root) {
482 return Err(ResolveError::EscapesRoot {
483 specifier: specifier.to_owned(),
484 });
485 }
486 Ok(canonical)
487 }
488
489 /// Find a file for an already-joined path, enforcing containment.
490 fn resolve_within(&self, specifier: &str, joined: &Path) -> Result<PathBuf, ResolveError> {
491 // Ahead of the candidate loop as well as inside [`RuleRoot::confine`], because a
492 // traversal that matches no file at all must still be reported as an escape rather
493 // than as "nothing found".
494 if !joined.starts_with(&self.root) {
495 return Err(ResolveError::EscapesRoot {
496 specifier: specifier.to_owned(),
497 });
498 }
499
500 let mut tried = Vec::new();
501 for candidate in candidates(joined) {
502 tried.push(candidate.display().to_string());
503 if !candidate.is_file() {
504 continue;
505 }
506 return self.confine(specifier, &candidate);
507 }
508
509 Err(ResolveError::NotFound {
510 specifier: specifier.to_owned(),
511 tried: tried.join(", "),
512 })
513 }
514
515 /// Read a resolved module, stripping types when it is TypeScript.
516 ///
517 /// Containment is re-checked here rather than trusted from [`RuleRoot::resolve`].
518 /// Reading is the operation that actually touches a file, so it should be the thing
519 /// that enforces the boundary — otherwise the guarantee depends on every caller having
520 /// gone through the resolver first, which is exactly the sort of assumption that holds
521 /// until someone adds a second caller.
522 ///
523 /// # Errors
524 ///
525 /// Returns [`ResolveError::EscapesRoot`] if the path is outside the root, or
526 /// [`ResolveError::Unreadable`] if the file cannot be read or stripping rejects it.
527 pub fn read(
528 &self,
529 path: &Path,
530 typescript: &dyn Language,
531 javascript: &dyn Language,
532 ) -> Result<String, ResolveError> {
533 if path == Path::new(HOST_MODULE) {
534 return Ok(HOST_MODULE_SOURCE.to_owned());
535 }
536
537 if let Some(name) = path.to_str().and_then(|p| p.strip_prefix(BUILTIN_PREFIX))
538 && let Some(source) = (self.builtins)(name)
539 {
540 // Built-ins are TypeScript like any other rule, so they go through the same
541 // stripping — including its verification step. A built-in that failed to strip
542 // would be a build-time bug in this repository, and should look like one.
543 return strip_types(typescript, javascript, source).map_err(|e| {
544 ResolveError::Unreadable {
545 path: path.display().to_string(),
546 detail: e.to_string(),
547 }
548 });
549 }
550
551 let canonical = path.canonicalize().map_err(|e| ResolveError::Unreadable {
552 path: path.display().to_string(),
553 detail: e.to_string(),
554 })?;
555 if !canonical.starts_with(&self.root) {
556 return Err(ResolveError::EscapesRoot {
557 specifier: path.display().to_string(),
558 });
559 }
560
561 let source = std::fs::read_to_string(path).map_err(|e| ResolveError::Unreadable {
562 path: path.display().to_string(),
563 detail: e.to_string(),
564 })?;
565
566 // Plain JavaScript is passed through untouched rather than run through the
567 // stripper, which would only be able to fail on it.
568 let is_typescript = path
569 .extension()
570 .and_then(|e| e.to_str())
571 .is_some_and(|e| matches!(e, "ts" | "tsx" | "mts" | "cts"));
572 if !is_typescript {
573 return Ok(source);
574 }
575
576 strip_types(typescript, javascript, &source).map_err(|e| ResolveError::Unreadable {
577 path: path.display().to_string(),
578 detail: e.to_string(),
579 })
580 }
581}
582
583/// Candidate files for a specifier, in resolution order.
584fn candidates(base: &Path) -> Vec<PathBuf> {
585 let mut out = Vec::new();
586
587 // An explicit extension is taken at face value.
588 if base.extension().is_some() {
589 out.push(base.to_path_buf());
590 }
591
592 for extension in EXTENSIONS {
593 out.push(base.with_extension(extension));
594 }
595 for extension in EXTENSIONS {
596 out.push(base.join(format!("index.{extension}")));
597 }
598
599 out
600}
601
602/// Adapts [`RuleRoot`] to the engine's resolver interface.
603#[derive(Debug, Clone)]
604pub struct RuleResolver {
605 root: RuleRoot,
606}
607
608impl RuleResolver {
609 /// Build a resolver for a rules root.
610 #[must_use]
611 pub const fn new(root: RuleRoot) -> Self {
612 Self { root }
613 }
614}
615
616impl Resolver for RuleResolver {
617 fn resolve(
618 &mut self,
619 _ctx: &Ctx<'_>,
620 base: &str,
621 name: &str,
622 _attributes: Option<ImportAttributes<'_>>,
623 ) -> rquickjs::Result<String> {
624 match self.root.resolve(base, name) {
625 Ok(path) => Ok(path.display().to_string()),
626 // The engine's error channel carries only a message, so the diagnostic is
627 // rendered here rather than lost.
628 Err(err) => Err(JsError::new_resolving_message(
629 base.to_owned(),
630 name.to_owned(),
631 err.to_string(),
632 )),
633 }
634 }
635}
636
637/// Every module the loader read, with the source it read.
638///
639/// This is what makes `ruleset_hash` cover the whole import graph rather than only the
640/// entry files. A rule that imports a shared helper has to invalidate when that helper
641/// changes, and the only component that knows the helper was involved is the loader.
642///
643/// Ordered, so the hash derived from it does not depend on load order — which varies with
644/// import structure and is not something a user changed.
645pub type LoadedModules = Rc<RefCell<BTreeMap<PathBuf, String>>>;
646
647/// Adapts [`RuleRoot`] to the engine's loader interface.
648///
649/// `Debug` is hand-written because `Arc<dyn Language>` is not `Debug`, and requiring it on
650/// the trait would burden every language implementation for one impl here.
651#[derive(Clone)]
652pub struct RuleLoader {
653 root: RuleRoot,
654 typescript: Arc<dyn Language>,
655 javascript: Arc<dyn Language>,
656 loaded: LoadedModules,
657}
658
659impl std::fmt::Debug for RuleLoader {
660 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
661 f.debug_struct("RuleLoader")
662 .field("root", &self.root)
663 .field("typescript", &self.typescript.id())
664 .field("javascript", &self.javascript.id())
665 .field("loaded", &self.loaded.borrow().len())
666 .finish()
667 }
668}
669
670impl RuleLoader {
671 /// Build a loader for a rules root.
672 ///
673 /// The languages are supplied rather than assumed so this crate does not have to know
674 /// which grammars exist.
675 #[must_use]
676 pub fn new(
677 root: RuleRoot,
678 typescript: Arc<dyn Language>,
679 javascript: Arc<dyn Language>,
680 ) -> Self {
681 Self {
682 root,
683 typescript,
684 javascript,
685 loaded: Rc::new(RefCell::new(BTreeMap::new())),
686 }
687 }
688
689 /// A handle on what this loader has read, for hashing the rule graph.
690 #[must_use]
691 pub fn loaded(&self) -> LoadedModules {
692 Rc::clone(&self.loaded)
693 }
694}
695
696impl Loader for RuleLoader {
697 fn load<'js>(
698 &mut self,
699 ctx: &Ctx<'js>,
700 name: &str,
701 _attributes: Option<ImportAttributes<'js>>,
702 ) -> rquickjs::Result<Module<'js, Declared>> {
703 let source = self
704 .root
705 .read(
706 Path::new(name),
707 self.typescript.as_ref(),
708 self.javascript.as_ref(),
709 )
710 .map_err(|err| JsError::new_loading_message(name.to_owned(), err.to_string()))?;
711
712 // Recorded before declaring, so a module that fails to compile still counts as
713 // part of the graph. Otherwise fixing the compile error would not invalidate.
714 self.loaded
715 .borrow_mut()
716 .insert(PathBuf::from(name), source.clone());
717
718 Module::declare(ctx.clone(), name, source)
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use std::fs;
725
726 use lanekeep_lang_js::{JavaScript, TypeScript};
727
728 use super::*;
729
730 /// A rules directory laid out for a test, cleaned up on drop.
731 struct Fixture {
732 dir: PathBuf,
733 }
734
735 impl Fixture {
736 fn new(name: &str, files: &[(&str, &str)]) -> Self {
737 let dir = std::env::temp_dir().join(format!("lanekeep-loader-{name}"));
738 let _ = fs::remove_dir_all(&dir);
739 fs::create_dir_all(&dir).expect("creates fixture dir");
740 for (path, contents) in files {
741 let full = dir.join(path);
742 if let Some(parent) = full.parent() {
743 fs::create_dir_all(parent).expect("creates parent");
744 }
745 fs::write(&full, contents).expect("writes fixture file");
746 }
747 Self { dir }
748 }
749
750 fn root(&self) -> RuleRoot {
751 RuleRoot::new(&self.dir).expect("canonicalizes")
752 }
753
754 fn entry(&self, name: &str) -> String {
755 self.dir
756 .join(name)
757 .canonicalize()
758 .expect("exists")
759 .display()
760 .to_string()
761 }
762 }
763
764 impl Drop for Fixture {
765 fn drop(&mut self) {
766 let _ = fs::remove_dir_all(&self.dir);
767 }
768 }
769
770 /// Stands in for the real built-in table, so these tests do not depend on which rules
771 /// happen to ship.
772 fn stub_builtins(name: &str) -> Option<&'static str> {
773 match name {
774 "always" => Some("export default { id: 'lanekeep/always' } satisfies unknown;"),
775 // A rule with a source *and* a component: the ordinary shape of a TypeScript rule
776 // compiled ahead of time, where the source is what the artifact was built from and
777 // is kept for the tests that run it through this engine.
778 "compiled-from-source" => {
779 Some("export default { id: 'lanekeep/compiled-from-source' } satisfies unknown;")
780 }
781 // A name that is *declared* as a component but whose host is missing, and that
782 // still has a source — the exact shape the broken-table refusal must not serve.
783 "broken-row" => Some("export default { id: 'lanekeep/broken-row' } satisfies unknown;"),
784 _ => None,
785 }
786 }
787
788 #[test]
789 fn resolves_a_built_in_by_specifier() {
790 let fixture = Fixture::new("builtin-resolve", &[("a.ts", "export const a = 1;")]);
791 let root = fixture.root().with_builtins(stub_builtins);
792 assert_eq!(
793 root.resolve("", "lanekeep/always").expect("resolves"),
794 Path::new("lanekeep/always")
795 );
796 }
797
798 #[test]
799 fn an_unknown_built_in_is_not_found() {
800 // Not "bare specifier": the `lanekeep/` prefix says what the author meant, and an
801 // error about npm resolution would send them somewhere useless.
802 let fixture = Fixture::new("builtin-unknown", &[("a.ts", "export const a = 1;")]);
803 let root = fixture.root().with_builtins(stub_builtins);
804 let error = root
805 .resolve("", "lanekeep/no-such-rule")
806 .expect_err("does not resolve");
807 assert!(
808 matches!(error, ResolveError::NotFound { .. }),
809 "expected NotFound, got {error:?}"
810 );
811 assert!(error.to_string().contains("built-in"), "{error}");
812 }
813
814 /// Stands in for the real component table, on the same terms as [`stub_builtins`].
815 fn stub_builtin_components(name: &str) -> Option<(&'static [u8], u32)> {
816 match name {
817 "compiled" => Some((b"\0asm\x01\x00\x00\x00", 0)),
818 // Also served by `stub_builtins`, and at a non-zero index so that a caller
819 // discarding the index cannot pass by accident.
820 "compiled-from-source" => Some((b"\0asm\x01\x00\x00\x00", 3)),
821 _ => None,
822 }
823 }
824
825 /// Stands in for the real "declared as a component" table, on the same terms as
826 /// [`stub_builtin_components`]. `broken-row` is declared but absent from the component
827 /// table, which is the broken-table state the resolver must refuse rather than serve.
828 fn stub_builtin_component_declared(name: &str) -> bool {
829 matches!(name, "compiled" | "compiled-from-source" | "broken-row")
830 }
831
832 #[test]
833 fn a_built_in_that_is_a_component_is_refused_as_a_module() {
834 // And refused *as itself*. A component-backed built-in is spelled correctly, so the
835 // "no built-in rule by that name" message would send its author looking for a
836 // misspelling that is not there. The two facts are different and only one is a typo.
837 let fixture = Fixture::new("builtin-component", &[("a.ts", "export const a = 1;")]);
838 let root = fixture
839 .root()
840 .with_builtins(stub_builtins)
841 .with_builtin_components(stub_builtin_components);
842
843 let error = root
844 .resolve("", "lanekeep/compiled")
845 .expect_err("a component is not importable");
846
847 assert!(
848 matches!(&error, ResolveError::NotAModule { name } if name == "compiled"),
849 "expected NotAModule, got {error:?}"
850 );
851 let rendered = error.to_string();
852 assert!(rendered.contains("lanekeep/compiled"), "{rendered}");
853 assert!(rendered.contains("component"), "{rendered}");
854 assert!(
855 rendered.contains("lanekeep.json"),
856 "the message has to name the format that can reach it: {rendered}"
857 );
858 }
859
860 /// The whole message reaches a user whose project is nested as deeply as a real one.
861 ///
862 /// **The constraint is not "the message is short", and a test asserting that passed against
863 /// the bug it was written for.** QuickJS copies a thrown error into a 255-byte buffer, and
864 /// rquickjs hands it `Error resolving module '<specifier>' from '<path>': <message>` — so
865 /// what has to hold is `35 + specifier + path + message <= 255`, and the *project's path* is
866 /// two of those four terms' worth of budget that this crate does not control. A previous
867 /// version of this assertion pinned the first line at 80 bytes, which is a quantity nothing
868 /// depends on: the message it was guarding lost its remedy at any config path beyond 47
869 /// characters, and the assertion was green throughout.
870 ///
871 /// **The rule's name is spent twice** — once in rquickjs's framing, once inside the message
872 /// — so every character of it costs two of the path budget. A name of 21 characters clears a
873 /// 108-byte path; at 15 it cleared 120, and `no-mutable-default-argument`, at 27, would
874 /// clear only 96. So this is stated as a *name-length* budget rather than measured against
875 /// whichever names happen to ship, which this crate cannot see anyway: it sits below
876 /// `lanekeep-rules` deliberately.
877 ///
878 /// `PATH` moved with `MAX_COMPONENT_NAME` rather than staying put, and that is the point of
879 /// the pair: the constraint is one inequality with two knobs, so raising the name budget
880 /// without lowering the path budget would be asserting something false. See
881 /// [`MAX_COMPONENT_NAME`] for why 108 was judged enough.
882 ///
883 /// `lanekeep-rules`' `every_component_name_fits_the_refusal_message` is the other half, and
884 /// it is what makes this test's premise true rather than assumed. Neither is any use alone:
885 /// this one would pass while a longer name silently ate a user's remedy, and that one would
886 /// be enforcing a number with no derivation behind it.
887 #[test]
888 fn the_refusal_survives_quickjs_beside_a_long_path() {
889 /// What QuickJS will keep, terminator excluded.
890 const BUDGET: usize = 255;
891 /// A project path this message must not be truncated beside. Roughly
892 /// `/Users/<name>/work/<org>-monorepo/apps/<app>/packages/<pkg>/lanekeep.config.ts`.
893 const PATH: usize = 108;
894
895 // Not a literal: this is rquickjs's framing with both holes empty, so the constant
896 // cannot drift from the string it is measuring.
897 let framing = "Error resolving module '' from '': ".len();
898
899 let name = "x".repeat(MAX_COMPONENT_NAME);
900 let specifier = format!("lanekeep/{name}").len();
901 let message = ResolveError::NotAModule { name }.to_string();
902
903 let total = framing + specifier + PATH + message.len();
904 assert!(
905 total <= BUDGET,
906 "QuickJS keeps {BUDGET} bytes and this needs {total} beside a {PATH}-byte path \
907 ({} of them the message): the remedy is what gets cut\n {message}",
908 message.len(),
909 );
910 }
911
912 #[test]
913 fn a_component_wins_over_a_source_of_the_same_name() {
914 // The one that decides what `lanekeep/<name>` means when both lookups answer, which is
915 // the ordinary shape of a rule authored in TypeScript and compiled ahead of time.
916 //
917 // **The failure this is written against is silent.** With the lookups asked the other
918 // way round the import succeeds, the sandbox evaluates the author's source, and the
919 // run reports — correctly, plausibly, and from a different program than the one a
920 // `lanekeep.json` would have run for the same name. Nothing in the output distinguishes
921 // them, so the only place this can be caught is here.
922 let fixture = Fixture::new("builtin-both", &[("a.ts", "export const a = 1;")]);
923 let root = fixture
924 .root()
925 .with_builtins(stub_builtins)
926 .with_builtin_components(stub_builtin_components);
927
928 let error = root
929 .resolve("", "lanekeep/compiled-from-source")
930 .expect_err("the component is what ships, so the import is refused");
931 assert!(
932 matches!(&error, ResolveError::NotAModule { name } if name == "compiled-from-source"),
933 "expected NotAModule, got {error:?}"
934 );
935
936 // And the source is still there to be read by whatever built the component, which is
937 // why the two lookups can disagree at all.
938 assert!(
939 stub_builtins("compiled-from-source").is_some(),
940 "the fixture must have both, or this test asserts nothing"
941 );
942 }
943
944 #[test]
945 fn a_declared_component_whose_host_is_missing_is_refused_not_served() {
946 // The broken-table state, and the decision it forces. `broken-row` is declared as a
947 // component (the declared hook answers) but its host is missing (the component hook
948 // does not), and it *also* has a source — so without the declared check this name
949 // would be silently served from its stale TypeScript. The refusal is what makes the
950 // check load-bearing rather than a reworded NotFound.
951 let fixture = Fixture::new("builtin-broken-row", &[("a.ts", "export const a = 1;")]);
952 let root = fixture
953 .root()
954 .with_builtins(stub_builtins)
955 .with_builtin_components(stub_builtin_components)
956 .with_builtin_component_declared(stub_builtin_component_declared);
957
958 let error = root
959 .resolve("", "lanekeep/broken-row")
960 .expect_err("a broken component row is refused, not served");
961
962 assert!(
963 matches!(&error, ResolveError::ComponentHostMissing { name } if name == "broken-row"),
964 "expected ComponentHostMissing, got {error:?}"
965 );
966 assert!(
967 stub_builtins("broken-row").is_some(),
968 "the fixture must have a source, or this test asserts nothing"
969 );
970 }
971
972 #[test]
973 fn the_broken_row_refusal_survives_quickjs_beside_a_long_path() {
974 // The same budget as `the_refusal_survives_quickjs_beside_a_long_path`, held for the
975 // broken-row refusal too: it reaches a user through the same QuickJS framing, so a
976 // message that does not fit beside a real path loses its remedy to truncation.
977 const BUDGET: usize = 255;
978 const PATH: usize = 108;
979
980 let framing = "Error resolving module '' from '': ".len();
981 let name = "x".repeat(MAX_COMPONENT_NAME);
982 let specifier = format!("lanekeep/{name}").len();
983 let message = ResolveError::ComponentHostMissing { name }.to_string();
984
985 let total = framing + specifier + PATH + message.len();
986 assert!(
987 total <= BUDGET,
988 "QuickJS keeps {BUDGET} bytes and this needs {total} beside a {PATH}-byte path \
989 ({} of them the message): the remedy is what gets cut\n {message}",
990 message.len(),
991 );
992 }
993
994 #[test]
995 fn an_unknown_name_is_still_not_found_when_components_ship() {
996 // The component lookup must not turn every miss into "it is a component".
997 let fixture = Fixture::new("builtin-component-miss", &[("a.ts", "export const a = 1;")]);
998 let root = fixture
999 .root()
1000 .with_builtins(stub_builtins)
1001 .with_builtin_components(stub_builtin_components);
1002
1003 let error = root
1004 .resolve("", "lanekeep/no-such-rule")
1005 .expect_err("does not resolve");
1006
1007 assert!(
1008 matches!(error, ResolveError::NotFound { .. }),
1009 "expected NotFound, got {error:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn a_component_is_reachable_by_name_without_being_importable() {
1015 let fixture = Fixture::new(
1016 "builtin-component-bytes",
1017 &[("a.ts", "export const a = 1;")],
1018 );
1019 let root = fixture
1020 .root()
1021 .with_builtin_components(stub_builtin_components);
1022
1023 assert_eq!(
1024 root.builtin_component("compiled"),
1025 Some((b"\0asm\x01\x00\x00\x00".as_slice(), 0))
1026 );
1027 // The index travels with the bytes, so a rule of a shared component is reachable as
1028 // itself rather than as whichever rule that artifact enumerates first.
1029 assert_eq!(
1030 root.builtin_component("compiled-from-source"),
1031 Some((b"\0asm\x01\x00\x00\x00".as_slice(), 3))
1032 );
1033 assert_eq!(root.builtin_component("always"), None);
1034 }
1035
1036 #[test]
1037 fn a_file_cannot_shadow_a_built_in() {
1038 // A rules directory containing `lanekeep/always.ts` must not change what the
1039 // specifier means. A rule whose behavior depended on whether a same-named file
1040 // happened to exist would be unreasonable to debug.
1041 let fixture = Fixture::new(
1042 "builtin-shadow",
1043 &[("lanekeep/always.ts", "export default 'the wrong one';")],
1044 );
1045 let root = fixture.root().with_builtins(stub_builtins);
1046 let resolved = root.resolve("", "lanekeep/always").expect("resolves");
1047 assert_eq!(resolved, Path::new("lanekeep/always"));
1048
1049 let source = root
1050 .read(&resolved, &TypeScript, &JavaScript)
1051 .expect("reads");
1052 assert!(
1053 !source.contains("the wrong one"),
1054 "a project file shadowed a built-in: {source}"
1055 );
1056 }
1057
1058 #[test]
1059 fn a_built_in_is_stripped_of_its_types() {
1060 let fixture = Fixture::new("builtin-strip", &[("a.ts", "export const a = 1;")]);
1061 let root = fixture.root().with_builtins(stub_builtins);
1062 let source = root
1063 .read(Path::new("lanekeep/always"), &TypeScript, &JavaScript)
1064 .expect("reads");
1065 assert!(
1066 !source.contains("satisfies"),
1067 "type syntax survived stripping: {source}"
1068 );
1069 }
1070
1071 #[test]
1072 fn built_ins_are_absent_unless_provided() {
1073 // The default. A crate embedding `lanekeep-js` without the rules crate resolves
1074 // project modules only, rather than silently resolving names to nothing.
1075 let fixture = Fixture::new("builtin-default", &[("a.ts", "export const a = 1;")]);
1076 let root = fixture.root();
1077 assert!(root.resolve("", "lanekeep/always").is_err());
1078 }
1079
1080 #[test]
1081 fn resolves_the_host_module() {
1082 let fixture = Fixture::new("host", &[("a.ts", "export const a = 1;")]);
1083 let root = fixture.root();
1084 assert_eq!(
1085 root.resolve("", HOST_MODULE).expect("resolves"),
1086 Path::new(HOST_MODULE)
1087 );
1088 }
1089
1090 #[test]
1091 fn the_host_module_exports_the_authoring_helpers() {
1092 let fixture = Fixture::new("host-src", &[]);
1093 let source = fixture
1094 .root()
1095 .read(Path::new(HOST_MODULE), &TypeScript, &JavaScript)
1096 .expect("reads");
1097 assert!(source.contains("defineRule"), "{source}");
1098 assert!(source.contains("defineConfig"), "{source}");
1099 }
1100
1101 #[test]
1102 fn resolves_a_relative_import() {
1103 let fixture = Fixture::new(
1104 "relative",
1105 &[
1106 ("main.ts", "import './helper';"),
1107 ("helper.ts", "export const h = 1;"),
1108 ],
1109 );
1110 let root = fixture.root();
1111
1112 let resolved = root
1113 .resolve(&fixture.entry("main.ts"), "./helper")
1114 .expect("resolves");
1115 assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
1116 }
1117
1118 #[test]
1119 fn tries_extensions_in_order() {
1120 // A `.ts` file wins over a `.js` file of the same name, because a rule directory
1121 // containing both is almost always a stale build artifact next to its source.
1122 let fixture = Fixture::new(
1123 "extensions",
1124 &[
1125 ("main.ts", ""),
1126 ("dup.ts", "export const from = 'ts';"),
1127 ("dup.js", "export const from = 'js';"),
1128 ],
1129 );
1130 let resolved = fixture
1131 .root()
1132 .resolve(&fixture.entry("main.ts"), "./dup")
1133 .expect("resolves");
1134 assert!(
1135 resolved.ends_with("dup.ts"),
1136 "expected the TypeScript file: {resolved:?}"
1137 );
1138 }
1139
1140 #[test]
1141 fn resolves_a_directory_index() {
1142 let fixture = Fixture::new(
1143 "index",
1144 &[("main.ts", ""), ("rules/index.ts", "export const r = 1;")],
1145 );
1146 let resolved = fixture
1147 .root()
1148 .resolve(&fixture.entry("main.ts"), "./rules")
1149 .expect("resolves");
1150 assert!(resolved.ends_with("index.ts"), "{resolved:?}");
1151 }
1152
1153 #[test]
1154 fn a_file_beats_a_same_named_directory_index() {
1155 // `candidates` puts every extension ahead of every `index.<extension>`, and until this
1156 // test nothing held it there — `resolves_a_directory_index` only proves an index wins
1157 // when no file competes, which it would do under either order.
1158 //
1159 // The rule is the one every bundler and TypeScript itself follows, so getting it wrong
1160 // would not look like a bug from inside a rule: `./rules` would simply mean the other
1161 // file, and both readings are individually plausible. It is also enforced twice now —
1162 // `packages/lanekeep/runtime/resolve.js` resolves a rule's imports at build time — so
1163 // leaving it unpinned here would let the port diverge from the specification with
1164 // nothing to notice, which is most of what makes this file the specification.
1165 let fixture = Fixture::new(
1166 "file-over-index",
1167 &[
1168 ("main.ts", ""),
1169 ("rules.ts", "export const from = 'file';"),
1170 ("rules/index.ts", "export const from = 'index';"),
1171 ],
1172 );
1173 let resolved = fixture
1174 .root()
1175 .resolve(&fixture.entry("main.ts"), "./rules")
1176 .expect("resolves");
1177 assert!(
1178 resolved.ends_with("rules.ts"),
1179 "expected the file, not the directory: {resolved:?}"
1180 );
1181 }
1182
1183 #[test]
1184 fn resolves_an_explicit_extension() {
1185 let fixture = Fixture::new(
1186 "explicit",
1187 &[("main.ts", ""), ("helper.ts", "export const h = 1;")],
1188 );
1189 let resolved = fixture
1190 .root()
1191 .resolve(&fixture.entry("main.ts"), "./helper.ts")
1192 .expect("resolves");
1193 assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
1194 }
1195
1196 // --- what must not resolve --------------------------------------------------------
1197
1198 #[test]
1199 fn rejects_bare_specifiers() {
1200 let fixture = Fixture::new("bare", &[("main.ts", "")]);
1201 let root = fixture.root();
1202
1203 for specifier in [
1204 "lodash",
1205 "react",
1206 "node:fs",
1207 "fs",
1208 "@scope/pkg",
1209 "typescript",
1210 ] {
1211 let err = root
1212 .resolve(&fixture.entry("main.ts"), specifier)
1213 .expect_err("bare specifiers must not resolve");
1214 assert!(
1215 matches!(err, ResolveError::BareSpecifier { .. }),
1216 "{specifier} gave {err:?}"
1217 );
1218 }
1219 }
1220
1221 #[test]
1222 fn a_bare_specifier_explains_why() {
1223 let fixture = Fixture::new("bare-msg", &[("main.ts", "")]);
1224 let err = fixture
1225 .root()
1226 .resolve(&fixture.entry("main.ts"), "lodash")
1227 .expect_err("bare specifiers do not resolve");
1228
1229 assert!(matches!(err, ResolveError::BareSpecifier { .. }), "{err:?}");
1230 let rendered = err.to_string();
1231 assert!(rendered.contains("no package resolution"), "{rendered}");
1232 assert!(rendered.contains("lanekeep"), "{rendered}");
1233 }
1234
1235 #[test]
1236 fn rejects_traversal_out_of_the_root() {
1237 let fixture = Fixture::new("traversal", &[("main.ts", "")]);
1238 let root = fixture.root();
1239 let base = fixture.entry("main.ts");
1240
1241 for specifier in ["../outside", "../../etc/passwd", "./../../secrets", "../"] {
1242 let err = root
1243 .resolve(&base, specifier)
1244 .expect_err("traversal must not resolve");
1245 assert!(
1246 matches!(err, ResolveError::EscapesRoot { .. }),
1247 "{specifier} gave {err:?}"
1248 );
1249 }
1250 }
1251
1252 #[test]
1253 fn traversal_is_rejected_even_when_the_target_exists() {
1254 // The lexical check has to fire regardless of what is on disk, or the error a
1255 // reader sees depends on whether the file they tried to reach happened to be there.
1256 let fixture = Fixture::new(
1257 "traversal-real",
1258 &[("nested/main.ts", ""), ("secret.ts", "export const s = 1;")],
1259 );
1260 let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
1261
1262 let err = root
1263 .resolve(&fixture.entry("nested/main.ts"), "../secret")
1264 .expect_err("must not escape");
1265 assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1266 }
1267
1268 #[cfg(unix)]
1269 #[test]
1270 fn rejects_a_symlink_pointing_outside_the_root() {
1271 // The case a lexical check cannot see. `./link` looks entirely innocent; only
1272 // canonicalizing the file that was found reveals where it goes.
1273 let fixture = Fixture::new(
1274 "symlink",
1275 &[
1276 ("nested/main.ts", ""),
1277 ("outside.ts", "export const o = 1;"),
1278 ],
1279 );
1280 let root_dir = fixture.dir.join("nested");
1281 let link = root_dir.join("link.ts");
1282 std::os::unix::fs::symlink(fixture.dir.join("outside.ts"), &link).expect("creates symlink");
1283
1284 let root = RuleRoot::new(&root_dir).expect("canonicalizes");
1285 let err = root
1286 .resolve(&fixture.entry("nested/main.ts"), "./link")
1287 .expect_err("a symlink out of the root must be rejected");
1288 assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1289 }
1290
1291 #[test]
1292 fn a_rule_may_not_import_an_absolute_path() {
1293 // The entry module legitimately arrives as an absolute path, so the resolver has
1294 // to accept one. This checks that door is only open for the entry — a rule with a
1295 // base of its own is refused.
1296 //
1297 // The absolute path is built from `temp_dir` rather than written literally,
1298 // because `Path::is_absolute` is platform-specific: `/etc/passwd` is absolute on
1299 // Unix and merely rooted on Windows, while `C:\...` is the reverse. A literal
1300 // would take a different branch on each platform and assert a different error.
1301 let fixture = Fixture::new("absolute", &[("main.ts", "")]);
1302 let root = fixture.root();
1303 let base = fixture.entry("main.ts");
1304
1305 let outside = std::env::temp_dir().join("lanekeep-absolute-probe.ts");
1306 let outside = outside.display().to_string();
1307
1308 // Written by a rule: refused, whichever way the platform classifies it.
1309 for specifier in [outside.as_str(), "/etc/passwd", "C:\\Windows\\System32\\x"] {
1310 assert!(
1311 root.resolve(&base, specifier).is_err(),
1312 "a rule must not import `{specifier}`"
1313 );
1314 }
1315
1316 // As an entry point: still refused, because it is outside the root.
1317 let err = root
1318 .resolve("", &outside)
1319 .expect_err("an entry outside the root must be refused");
1320 assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1321 }
1322
1323 #[test]
1324 fn reports_what_it_tried_when_nothing_matches() {
1325 let fixture = Fixture::new("missing", &[("main.ts", "")]);
1326 let err = fixture
1327 .root()
1328 .resolve(&fixture.entry("main.ts"), "./nope")
1329 .expect_err("nothing to find");
1330
1331 match err {
1332 ResolveError::NotFound { tried, .. } => {
1333 assert!(tried.contains("nope.ts"), "should list candidates: {tried}");
1334 assert!(
1335 tried.contains("index.ts"),
1336 "should list index candidates: {tried}"
1337 );
1338 }
1339 other => panic!("wrong error: {other:?}"),
1340 }
1341 }
1342
1343 // --- reading ------------------------------------------------------------------------
1344
1345 #[test]
1346 fn strips_types_when_reading_typescript() {
1347 let fixture = Fixture::new(
1348 "read-ts",
1349 &[("a.ts", "export const a: number = 1;\ninterface B {}\n")],
1350 );
1351 let root = fixture.root();
1352 let path = root.resolve("", "./a").expect("resolves");
1353 let source = root.read(&path, &TypeScript, &JavaScript).expect("reads");
1354
1355 assert!(!source.contains(": number"), "{source}");
1356 assert!(!source.contains("interface"), "{source}");
1357 assert!(source.contains("export const a"), "{source}");
1358 }
1359
1360 #[test]
1361 fn reading_refuses_a_path_outside_the_root_even_if_resolution_was_skipped() {
1362 // Defense in depth. `resolve` already enforces this, but a future caller that
1363 // builds a path some other way must not be able to read past the boundary.
1364 let fixture = Fixture::new(
1365 "read-escape",
1366 &[
1367 ("nested/main.ts", ""),
1368 ("outside.ts", "export const o = 1;"),
1369 ],
1370 );
1371 let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
1372
1373 let err = root
1374 .read(&fixture.dir.join("outside.ts"), &TypeScript, &JavaScript)
1375 .expect_err("reading outside the root must be refused");
1376 assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1377 }
1378
1379 #[test]
1380 fn passes_javascript_through_untouched() {
1381 let contents = "export const a = 1;\n";
1382 let fixture = Fixture::new("read-js", &[("a.js", contents)]);
1383 let root = fixture.root();
1384 let path = root.resolve("", "./a.js").expect("resolves");
1385 assert_eq!(
1386 root.read(&path, &TypeScript, &JavaScript).expect("reads"),
1387 contents
1388 );
1389 }
1390
1391 // --- end to end, through the engine ---------------------------------------------
1392 //
1393 // Everything above tests the resolution logic directly. These go through the engine's
1394 // Resolver and Loader adapters, which is the part that is actually wired up at runtime
1395 // and could be correct in isolation while being connected wrongly.
1396
1397 fn sandbox_for(fixture: &Fixture) -> crate::Sandbox {
1398 crate::Sandbox::with_modules(
1399 crate::Limits::default(),
1400 crate::RunClock::start(std::time::Duration::from_secs(30)),
1401 fixture.root(),
1402 Arc::new(TypeScript),
1403 Arc::new(JavaScript),
1404 )
1405 .expect("sandbox builds")
1406 }
1407
1408 #[test]
1409 fn loads_a_rule_module_that_imports_the_host_module() {
1410 let fixture = Fixture::new(
1411 "e2e-host",
1412 &[(
1413 "rule.ts",
1414 "import { defineRule } from 'lanekeep';\n\
1415 export default defineRule({ id: 'local/example' });\n",
1416 )],
1417 );
1418 let sandbox = sandbox_for(&fixture);
1419 let path = fixture.root().resolve("", "./rule").expect("resolves");
1420
1421 let module: std::collections::HashMap<String, String> =
1422 sandbox.import_default(&path).expect("module evaluates");
1423 assert_eq!(module.get("id").map(String::as_str), Some("local/example"));
1424 }
1425
1426 #[test]
1427 fn loads_a_module_that_imports_a_sibling_and_strips_its_types() {
1428 let fixture = Fixture::new(
1429 "e2e-sibling",
1430 &[
1431 (
1432 "rule.ts",
1433 "import { defineRule } from 'lanekeep';\n\
1434 import { NAME } from './shared';\n\
1435 export default defineRule({ id: NAME });\n",
1436 ),
1437 (
1438 "shared.ts",
1439 "interface Unused { a: number }\n\
1440 export const NAME: string = 'local/from-sibling';\n",
1441 ),
1442 ],
1443 );
1444 let sandbox = sandbox_for(&fixture);
1445 let path = fixture.root().resolve("", "./rule").expect("resolves");
1446
1447 let module: std::collections::HashMap<String, String> =
1448 sandbox.import_default(&path).expect("module evaluates");
1449 assert_eq!(
1450 module.get("id").map(String::as_str),
1451 Some("local/from-sibling")
1452 );
1453 }
1454
1455 #[test]
1456 fn a_bare_import_fails_at_load_with_the_explanation() {
1457 let fixture = Fixture::new(
1458 "e2e-bare",
1459 &[(
1460 "rule.ts",
1461 "import lodash from 'lodash';\nexport default lodash;\n",
1462 )],
1463 );
1464 let sandbox = sandbox_for(&fixture);
1465 let path = fixture.root().resolve("", "./rule").expect("resolves");
1466
1467 let err = sandbox
1468 .import_default::<std::collections::HashMap<String, String>>(&path)
1469 .expect_err("lodash cannot resolve");
1470 let rendered = err.to_string();
1471 assert!(rendered.contains("lodash"), "{rendered}");
1472 }
1473
1474 #[test]
1475 fn a_traversing_import_fails_at_load() {
1476 let fixture = Fixture::new(
1477 "e2e-traversal",
1478 &[
1479 (
1480 "nested/rule.ts",
1481 "import x from '../outside';\nexport default x;\n",
1482 ),
1483 ("outside.ts", "export default 1;\n"),
1484 ],
1485 );
1486 let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
1487 let sandbox = crate::Sandbox::with_modules(
1488 crate::Limits::default(),
1489 crate::RunClock::start(std::time::Duration::from_secs(30)),
1490 root.clone(),
1491 Arc::new(TypeScript),
1492 Arc::new(JavaScript),
1493 )
1494 .expect("sandbox builds");
1495
1496 let path = root.resolve("", "./rule").expect("resolves");
1497 assert!(
1498 sandbox
1499 .import_default::<std::collections::HashMap<String, String>>(&path)
1500 .is_err(),
1501 "an import escaping the root must not load"
1502 );
1503 }
1504
1505 #[test]
1506 fn a_module_that_fails_to_strip_reports_the_reason() {
1507 let fixture = Fixture::new("read-bad", &[("a.ts", "enum E { A }\n")]);
1508 let root = fixture.root();
1509 let path = root.resolve("", "./a").expect("resolves");
1510 let err = root
1511 .read(&path, &TypeScript, &JavaScript)
1512 .expect_err("enums are rejected");
1513
1514 let rendered = err.to_string();
1515 assert!(
1516 rendered.contains("enum"),
1517 "should name the construct: {rendered}"
1518 );
1519 }
1520}