Skip to main content

mlua_pkg/
lib.rs

1//! # mlua-pkg
2//!
3//! Composable Lua module loader built in Rust.
4//!
5//! # Design philosophy
6//!
7//! Lua's `require("name")` is a `name -> value` transformation.
8//! This crate defines that transformation as a **composable abstraction**,
9//! allowing multiple sources (memory, filesystem, Rust functions, assets)
10//! to be handled uniformly.
11//!
12//! # Resolution model
13//!
14//! ## Abstractions
15//!
16//! | Concept | Type | Role |
17//! |---------|------|------|
18//! | Resolution unit | [`Resolver`] | `name -> Option<Result<Value>>` |
19//! | Composition (Chain) | [`Registry`] | Resolvers in priority order, first match wins |
20//! | Composition (Prefix) | [`resolvers::PrefixResolver`] | Strip prefix and delegate to inner Resolver |
21//!
22//! Resolvers come in two kinds: **leaf** (directly produce values) and
23//! **combinator** (compose other Resolvers). Both implement the same
24//! [`Resolver`] trait, enabling infinite composition.
25//!
26//! ## Leaf Resolvers
27//!
28//! | Resolver | Source | Match condition |
29//! |----------|--------|----------------|
30//! | [`resolvers::MemoryResolver`] | `HashMap<String, String>` | Name is registered |
31//! | [`resolvers::NativeResolver`] | `Fn(&Lua) -> Result<Value>` | Name is registered |
32//! | [`resolvers::FsResolver`] | Filesystem | File exists |
33//! | [`resolvers::AssetResolver`] | Filesystem | Known extension + file exists |
34//!
35//! ## Combinators
36//!
37//! | Combinator | Behavior |
38//! |------------|----------|
39//! | [`Registry`] (Chain) | Try `[R1, R2, ..., Rn]` in order, adopt first `Some` |
40//! | [`resolvers::PrefixResolver`] | `"prefix.rest"` -> strip prefix -> delegate `"rest"` to inner Resolver |
41//!
42//! ## Resolution flow
43//!
44//! ```text
45//! require("name")
46//!   |
47//!   v
48//! package.searchers[1]  <- Registry inserts its hook here
49//!   |
50//!   +- Resolver A: resolve(lua, "name") -> None (not responsible)
51//!   +- Resolver B: resolve(lua, "name") -> Some(Ok(Value)) (first match wins)
52//!   |
53//!   v
54//! package.loaded["name"] = Value  <- Lua standard require auto-caches
55//! ```
56//!
57//! # Return value protocol
58//!
59//! | Return value | Meaning | Next Resolver |
60//! |-------------|---------|---------------|
61//! | `None` | Not this Resolver's responsibility | Tried |
62//! | `Some(Ok(value))` | Resolution succeeded | Skipped |
63//! | `Some(Err(e))` | Responsible but load failed | **Skipped** |
64//!
65//! `Some(Err)` intentionally does not fall through to the next Resolver.
66//! If a module was "found but broken", having another Resolver return
67//! something different would be a source of bugs.
68//!
69//! One consequence is worth stating explicitly: a sandbox boundary
70//! rejection is also `Some(Err)`, so a single misconfigured root shadows
71//! later Resolvers **for the names it rejects**. If
72//! [`resolvers::FsResolver`] is rooted at a directory of symlinks and built
73//! with the default [`sandbox::FsSandbox`], every `require` through those
74//! symlinks fails with [`ResolveError::PathTraversal`] and never reaches a
75//! fallback [`resolvers::MemoryResolver`] behind it. Other names in the same
76//! chain are unaffected. The fix is to match the sandbox to the layout —
77//! [`resolvers::FsResolver::new_symlink_aware`] — not to reorder the chain.
78//!
79//! # Naming conventions
80//!
81//! | Name pattern | Example | Responsible Resolver |
82//! |-------------|---------|---------------------|
83//! | `@scope/name` | `@std/http` | [`resolvers::NativeResolver`] -- exact name match |
84//! | `prefix.name` | `game.engine` | [`resolvers::PrefixResolver`] -> delegates to inner Resolver |
85//! | `dot.separated` | `lib.helper` | [`resolvers::FsResolver`] -- `lib/helper.lua` |
86//! | `name.ext` | `config.json` | [`resolvers::AssetResolver`] -- auto-convert by extension |
87//!
88//! [`resolvers::FsResolver`] converts dot separators to path separators
89//! (`lib.helper` -> `lib/helper.lua`).
90//! [`resolvers::AssetResolver`] treats filenames literally
91//! (`config.json` -> `config.json`).
92//! The two naturally partition by the presence of a file extension.
93//!
94//! # Composition example
95//!
96//! ```text
97//! Registry (Chain)
98//! +- NativeResolver            @std/http  -> factory(lua)
99//! +- Prefix("sm", FsResolver)  sm.helper  -> strip -> helper.lua
100//! +- FsResolver(root/)         sm         -> sm/init.lua
101//! |                             lib.utils  -> lib/utils.lua
102//! +- AssetResolver              config.json -> parse -> Table
103//! ```
104//!
105//! [`resolvers::PrefixResolver`] acts as a namespace mount point.
106//! `require("sm")` (init.lua) is handled by the outer [`resolvers::FsResolver`],
107//! while `require("sm.helper")` is handled by [`resolvers::PrefixResolver`].
108//! Responsibilities are clearly separated.
109//!
110//! # Usage
111//!
112//! ```rust
113//! use mlua_pkg::{Registry, resolvers::*};
114//! use mlua::Lua;
115//!
116//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
117//! let lua = Lua::new();
118//! let mut reg = Registry::new();
119//!
120//! // 1st: Rust native modules (highest priority)
121//! reg.add(NativeResolver::new().add("@std/http", |lua| {
122//!     let t = lua.create_table()?;
123//!     t.set("version", 1)?;
124//!     Ok(mlua::Value::Table(t))
125//! }));
126//!
127//! // 2nd: Embedded Lua sources
128//! reg.add(MemoryResolver::new().add("utils", "return { pi = 3.14 }"));
129//!
130//! // 3rd: Filesystem (sandboxed)
131//! # let plugins = std::env::temp_dir().join("mlua_pkg_doctest_plugins");
132//! # std::fs::create_dir_all(&plugins)?;
133//! # let assets = std::env::temp_dir().join("mlua_pkg_doctest_assets");
134//! # std::fs::create_dir_all(&assets)?;
135//! reg.add(FsResolver::new(&plugins)?);
136//!
137//! // 4th: Assets (register parsers explicitly)
138//! reg.add(AssetResolver::new(&assets)?
139//!     .parser("json", json_parser())
140//!     .parser("sql", text_parser()));
141//! # std::fs::remove_dir_all(&plugins).ok();
142//! # std::fs::remove_dir_all(&assets).ok();
143//!
144//! reg.install(&lua)?;
145//!
146//! // Lua side: require("@std/http"), require("utils"), etc.
147//! # Ok(())
148//! # }
149//! ```
150//!
151//! # Package management (SDK)
152//!
153//! The `mlua-pkg` CLI (`install` / `add` / `update` / `clean`) is a thin
154//! shell over [`ops`].  An embedding application drives the same
155//! operations directly:
156//!
157//! ```rust,no_run
158//! use mlua_pkg::{manifest::Manifest, ops, resolvers::VendoredResolver};
159//! use mlua_pkg::{Config, PkgDir, Project, Registry};
160//!
161//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
162//! let root = "/srv/app";
163//! // One PkgDir decides where cache/ and vendored/ live.  The CLI resolves
164//! // --mlua-pkgs-dir / MLUA_PKG_DIR / target/ into this; the SDK caller
165//! // just passes a path.
166//! let project = Project::in_dir(root, PkgDir::default_in(root));
167//!
168//! // The manifest can come from mlua-pkg.toml (Config::new) or from a
169//! // value the application built / parsed itself:
170//! let manifest = Manifest::from_toml_str(
171//!     "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\
172//!      [deps]\nlshape = { git = \"https://github.com/ynishi/lshape\", tag = \"v0.1\" }\n",
173//! )?;
174//! let cfg = Config::with_manifest(project, manifest);
175//!
176//! let report = ops::install(&cfg)?;
177//! println!("installed {} package(s)", report.packages.len());
178//!
179//! let lua = mlua::Lua::new();
180//! let mut reg = Registry::new();
181//! reg.add(VendoredResolver::from_lockfile(
182//!     cfg.project().lock_path(),
183//!     cfg.project().pkg_dir().vendored(),
184//! )?);
185//! reg.install(&lua)?;
186//! # Ok(())
187//! # }
188//! ```
189//!
190//! Operations return report values and never print; errors are
191//! [`PkgError`].  See [`ops`] for the per-operation contract, [`config`]
192//! for file-backed vs in-memory manifests, and [`project`] for how paths
193//! are supplied.
194//!
195//! # Lua integration
196//!
197//! [`Registry::install()`] inserts a hook at the front of Lua's
198//! `package.searchers` table. It takes priority over the standard
199//! `package.preload`, so registered Resolvers are tried first.
200//!
201//! Caching is delegated to Lua's standard `package.loaded`.
202//! On the second and subsequent `require` calls for the same module,
203//! Lua's cache hits and the Resolver is not invoked.
204//!
205//! # Error design
206//!
207//! | Error type | When raised | Defined in |
208//! |-----------|-------------|-----------|
209//! | [`ResolveError`] | During `resolve()` execution | This module |
210//! | [`sandbox::InitError`] | During `FsSandbox::new()` construction | [`sandbox`] |
211//! | [`sandbox::ReadError`] | During `SandboxedFs::read()` | [`sandbox`] |
212//!
213//! By separating construction-time and runtime errors at the type level,
214//! callers can choose the appropriate recovery strategy.
215
216pub mod config;
217pub mod error;
218pub mod fetcher;
219pub mod lockfile;
220pub mod manifest;
221pub mod ops;
222pub mod project;
223pub mod resolvers;
224pub mod rockspec;
225pub mod sandbox;
226pub mod version;
227
228pub use config::{Config, ManifestSource};
229pub use error::PkgError;
230pub use project::{PkgDir, Project};
231
232use mlua::{Lua, Result, Value};
233use std::path::{Path, PathBuf};
234
235/// Resolve the Lua `require` entry point directory for a cached package.
236///
237/// Applies the entry fallback chain in order:
238///
239/// 1. If `override_entry` is `Some(p)`, check `cache_path.join(p)` only.
240///    If it is not a directory, return [`PkgError::EntryNotFound`] immediately
241///    (the override is explicit, so fallback would be surprising).
242/// 2. Otherwise, try the default candidates in order:
243///    - `cache_path/src/`
244///    - `cache_path/lua/`
245///    - `cache_path/` itself (`.`)
246///
247/// Returns the first candidate that is a directory, or
248/// [`PkgError::EntryNotFound`] if none exist.
249///
250/// # Notes
251///
252/// This function is used by the `install` CLI to determine the symlink
253/// target for each vendored package. [`resolvers::VendoredResolver`] itself does
254/// not call this function — the lockfile already carries the resolved `entry`
255/// field, and the CLI's symlink points `vendored/<name>` at
256/// `../cache/…/<sha>/<entry>` before the resolver is constructed.
257///
258/// # Errors
259///
260/// Returns [`PkgError::EntryNotFound`] when no candidate directory exists.
261///
262/// # Example
263///
264/// ```rust,no_run
265/// use mlua_pkg::resolve_entry;
266/// use std::path::Path;
267///
268/// let entry = resolve_entry(Path::new("/cache/mypkg/abc123"), None)?;
269/// // => /cache/mypkg/abc123/src  (if that directory exists)
270/// # Ok::<(), mlua_pkg::PkgError>(())
271/// ```
272pub fn resolve_entry(
273    cache_path: &Path,
274    override_entry: Option<&Path>,
275) -> std::result::Result<PathBuf, PkgError> {
276    let candidates: Vec<PathBuf> = match override_entry {
277        Some(e) => vec![cache_path.join(e)],
278        None => vec![
279            cache_path.join("src"),
280            cache_path.join("lua"),
281            cache_path.to_path_buf(),
282        ],
283    };
284
285    for c in &candidates {
286        if c.is_dir() {
287            return Ok(c.clone());
288        }
289    }
290
291    Err(PkgError::EntryNotFound {
292        name: cache_path.display().to_string(),
293        attempted: candidates,
294    })
295}
296
297/// Configuration bundle for Lua dialect naming conventions.
298///
299/// Apply to [`FsResolver`](resolvers::FsResolver) and
300/// [`PrefixResolver`](resolvers::PrefixResolver) via `with_convention()`
301/// to prevent convention settings from scattering.
302///
303/// Individual `with_extension()` / `with_init_name()` / `with_separator()`
304/// methods remain available. Calling them after `with_convention()` overrides
305/// the corresponding field.
306///
307/// # Predefined conventions
308///
309/// | Constant | Extension | Init name | Separator |
310/// |----------|-----------|-----------|-----------|
311/// | [`LUA54`](Self::LUA54) | `lua` | `init` | `.` |
312/// | [`LUAU`](Self::LUAU) | `luau` | `init` | `.` |
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub struct LuaConvention {
315    /// File extension (`"lua"`, `"luau"`, etc.).
316    pub extension: &'static str,
317    /// Package entry point name (`"init"`, `"mod"`, etc.).
318    pub init_name: &'static str,
319    /// Module name separator. The `.` in `require("a.b")`.
320    pub module_separator: char,
321}
322
323impl LuaConvention {
324    /// Lua 5.4 standard convention.
325    pub const LUA54: Self = Self {
326        extension: "lua",
327        init_name: "init",
328        module_separator: '.',
329    };
330
331    /// Luau (Roblox Lua) convention.
332    pub const LUAU: Self = Self {
333        extension: "luau",
334        init_name: "init",
335        module_separator: '.',
336    };
337}
338
339impl Default for LuaConvention {
340    fn default() -> Self {
341        Self::LUA54
342    }
343}
344
345/// Error type for module resolution.
346///
347/// Structurally represents domain-specific errors that occur during `resolve()`.
348/// Converted to a Lua error via [`mlua::Error::external()`] and can be
349/// recovered on the caller side with `err.downcast_ref::<ResolveError>()`.
350///
351/// Construction-time errors (e.g. root directory not found) are returned as
352/// [`sandbox::InitError`] and are not included here.
353#[derive(Debug, thiserror::Error)]
354pub enum ResolveError {
355    /// Path access outside the sandbox detected.
356    ///
357    /// Raised both by genuine escape attempts and by a legitimate symlink
358    /// whose target lies outside the root. For the latter, build the
359    /// resolver with [`resolvers::FsResolver::new_symlink_aware`] so the
360    /// symlink targets become part of the allowed set.
361    #[error("path traversal blocked: {name}")]
362    PathTraversal { name: String },
363
364    /// Asset parse failure.
365    ///
366    /// Generalized to hold different error types per parser.
367    /// [`resolvers::json_parser()`] stores `serde_json::Error`;
368    /// custom parsers can store any error type.
369    #[error("asset parse error: {source}")]
370    AssetParse {
371        #[source]
372        source: Box<dyn std::error::Error + Send + Sync>,
373    },
374
375    /// File I/O error.
376    ///
377    /// Raised when a file exists but cannot be read
378    /// (e.g. permission denied, is a directory).
379    #[error("I/O error on {}: {source}", path.display())]
380    Io {
381        path: PathBuf,
382        source: std::io::Error,
383    },
384}
385
386/// Minimal abstraction for module resolution.
387///
388/// Receives `require(name)` and returns `Some(Result<Value>)` if this
389/// Resolver is responsible. Returns `None` if not.
390///
391/// # Return value protocol
392///
393/// - `None` = "unknown name". The next Resolver gets a chance.
394/// - `Some(Ok(v))` = resolution complete. This value is returned to Lua.
395/// - `Some(Err(e))` = "responsible but failed". Propagated immediately as an error.
396///
397/// # Example
398///
399/// ```rust
400/// use mlua_pkg::Resolver;
401/// use mlua::{Lua, Result, Value};
402///
403/// struct VersionResolver;
404///
405/// impl Resolver for VersionResolver {
406///     fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
407///         if name == "version" {
408///             Some(lua.create_string("1.0.0").map(Value::String))
409///         } else {
410///             None
411///         }
412///     }
413/// }
414/// ```
415pub trait Resolver: Send + Sync {
416    fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>>;
417}
418
419/// Chain combinator for [`Resolver`]. Registration order = priority order. First match wins.
420///
421/// `install()` inserts a hook at the front (index 1) of Lua's `package.searchers`
422/// table, routing all `require` calls through the registered Resolver chain.
423/// Takes priority over Lua's standard `package.preload`.
424///
425/// Caching is delegated to Lua's standard `package.loaded`.
426/// Resolvers do not need to manage their own cache.
427/// On the second and subsequent `require` for the same module, the Resolver is not called.
428///
429/// # Lua searcher protocol
430///
431/// The hook conforms to the Lua 5.4 searcher protocol:
432/// - If the searcher returns a `function`, `require` calls it as a loader
433/// - If the searcher returns a `string`, it is collected as a "not found" reason in the error message
434///
435/// The loader receives `(name, loader_data)` (per Lua 5.4 spec).
436///
437/// # Thread safety
438///
439/// `Registry` itself is `Send + Sync` (all Resolvers must be `Send + Sync`).
440/// After [`install()`](Registry::install), the Registry is wrapped in `Arc` and
441/// shared via a Lua closure.
442///
443/// Thread safety of the installed hook depends on the `mlua` feature configuration:
444///
445/// | mlua feature | `Lua` bounds | Implication |
446/// |-------------|-------------|-------------|
447/// | (default) | `!Send` | `Lua` is confined to one thread. The hook is never called concurrently. |
448/// | `send` | `Send + Sync` | `Lua` can be shared across threads. `Resolver: Send + Sync` ensures safe concurrent access. |
449///
450/// The `Send + Sync` bound on [`Resolver`] is required for forward compatibility
451/// with mlua's `send` feature. Without the `send` feature, `Lua` is `!Send` and
452/// the hook is inherently single-threaded.
453pub struct Registry {
454    resolvers: Vec<Box<dyn Resolver>>,
455}
456
457impl Default for Registry {
458    fn default() -> Self {
459        Self::new()
460    }
461}
462
463impl Registry {
464    pub fn new() -> Self {
465        Self {
466            resolvers: Vec::new(),
467        }
468    }
469
470    /// Add a Resolver. Registration order = priority order.
471    pub fn add(&mut self, resolver: impl Resolver + 'static) -> &mut Self {
472        self.resolvers.push(Box::new(resolver));
473        self
474    }
475
476    /// Insert a hook at the front of `package.searchers`.
477    ///
478    /// Consumes `self` and shares it via `Arc`.
479    /// The Registry becomes immutable after install (Resolver priority is finalized).
480    ///
481    /// Returns an error if called more than once on the same Lua instance.
482    /// Multiple Registries coexisting in the same searchers table would make
483    /// priority order unpredictable, so this is intentionally prohibited.
484    pub fn install(self, lua: &Lua) -> Result<()> {
485        if lua.app_data_ref::<RegistryInstalled>().is_some() {
486            return Err(mlua::Error::runtime(
487                "Registry already installed on this Lua instance",
488            ));
489        }
490
491        let searchers: mlua::Table = lua
492            .globals()
493            .get::<mlua::Table>("package")?
494            .get("searchers")?;
495
496        let registry = std::sync::Arc::new(self);
497        let hook = lua.create_function(move |lua, name: String| {
498            for resolver in &registry.resolvers {
499                if let Some(result) = resolver.resolve(lua, &name) {
500                    let value = result?;
501                    let f = lua.create_function(move |_, (_name, _data): (String, Value)| {
502                        Ok(value.clone())
503                    })?;
504                    return Ok(Value::Function(f));
505                }
506            }
507            Ok(Value::String(
508                lua.create_string(format!("\n\tno resolver for '{name}'"))?,
509            ))
510        })?;
511
512        let len = searchers.raw_len();
513        for i in (1..=len).rev() {
514            let v: Value = searchers.raw_get(i)?;
515            searchers.raw_set(i + 1, v)?;
516        }
517        searchers.raw_set(1, hook)?;
518        lua.set_app_data(RegistryInstalled);
519
520        Ok(())
521    }
522}
523
524/// Marker for `install()` completion. Used to prevent double-install.
525struct RegistryInstalled;
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    struct Echo;
532
533    impl Resolver for Echo {
534        fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
535            if name == "echo" {
536                Some(lua.create_string("hello from echo").map(Value::String))
537            } else {
538                None
539            }
540        }
541    }
542
543    #[test]
544    fn require_hits_resolver() {
545        let lua = Lua::new();
546        let mut reg = Registry::new();
547        reg.add(Echo);
548        reg.install(&lua).unwrap();
549
550        let val: String = lua.load(r#"return require("echo")"#).eval().unwrap();
551        assert_eq!(val, "hello from echo");
552    }
553
554    #[test]
555    fn require_miss_falls_through() {
556        let lua = Lua::new();
557        let mut reg = Registry::new();
558        reg.add(Echo);
559        reg.install(&lua).unwrap();
560
561        let result: mlua::Result<Value> = lua.load(r#"return require("nope")"#).eval();
562        assert!(result.is_err());
563    }
564
565    #[test]
566    fn registry_default() {
567        let reg = Registry::default();
568        assert_eq!(reg.resolvers.len(), 0);
569    }
570
571    #[test]
572    fn double_install_rejected() {
573        let lua = Lua::new();
574
575        let reg1 = Registry::new();
576        reg1.install(&lua).unwrap();
577
578        let reg2 = Registry::new();
579        let err = reg2.install(&lua).unwrap_err();
580        assert!(
581            err.to_string().contains("already installed"),
582            "expected 'already installed' error, got: {err}"
583        );
584    }
585
586    // -- resolve_entry helper tests --
587
588    // TC 4: entry fallback — src/ takes priority when it exists as a dir
589    #[test]
590    fn vendored_resolve_entry_src_priority() {
591        let tmp = tempfile::tempdir().unwrap();
592        let cache = tmp.path();
593
594        // Create src/ and lua/ sub-directories; resolve_entry should pick src/ first.
595        std::fs::create_dir_all(cache.join("src")).unwrap();
596        std::fs::create_dir_all(cache.join("lua")).unwrap();
597
598        let result = resolve_entry(cache, None).unwrap();
599        assert_eq!(result, cache.join("src"));
600    }
601
602    // TC 4b: fallback to lua/ when src/ is absent
603    #[test]
604    fn vendored_resolve_entry_lua_fallback() {
605        let tmp = tempfile::tempdir().unwrap();
606        let cache = tmp.path();
607
608        std::fs::create_dir_all(cache.join("lua")).unwrap();
609
610        let result = resolve_entry(cache, None).unwrap();
611        assert_eq!(result, cache.join("lua"));
612    }
613
614    // TC 4c: override entry is respected and src/ is not tried
615    #[test]
616    fn vendored_resolve_entry_override_respected() {
617        let tmp = tempfile::tempdir().unwrap();
618        let cache = tmp.path();
619
620        // "src/" exists but we override to "lib/"
621        std::fs::create_dir_all(cache.join("src")).unwrap();
622        std::fs::create_dir_all(cache.join("lib")).unwrap();
623
624        let result = resolve_entry(cache, Some(Path::new("lib"))).unwrap();
625        assert_eq!(result, cache.join("lib"));
626    }
627
628    // TC 5: all candidates absent → EntryNotFound
629    #[test]
630    fn vendored_resolve_entry_all_absent_returns_entry_not_found() {
631        let tmp = tempfile::tempdir().unwrap();
632        // cache dir exists but has no src/, lua/, or meaningful root
633        // (the root itself is a dir, so the last fallback `cache_path` would succeed)
634        // To test EntryNotFound we need all three to fail. Root is the tmp dir itself.
635        // Make a sub-path that does NOT exist as a directory.
636        let non_dir = tmp.path().join("no_such_dir");
637        // non_dir does not exist at all, so c.is_dir() = false for all candidates
638        // candidates: non_dir/src, non_dir/lua, non_dir itself (non-existent)
639
640        let err = resolve_entry(&non_dir, None).unwrap_err();
641        assert!(
642            matches!(err, PkgError::EntryNotFound { .. }),
643            "expected EntryNotFound, got: {err}"
644        );
645    }
646
647    // TC 5b: override entry absent → EntryNotFound immediately (no fallback)
648    #[test]
649    fn vendored_resolve_entry_override_absent_no_fallback() {
650        let tmp = tempfile::tempdir().unwrap();
651        let cache = tmp.path();
652
653        // src/ exists but override points to nonexistent "custom/"
654        std::fs::create_dir_all(cache.join("src")).unwrap();
655
656        let err = resolve_entry(cache, Some(Path::new("custom"))).unwrap_err();
657        assert!(
658            matches!(err, PkgError::EntryNotFound { .. }),
659            "expected EntryNotFound when override is absent, got: {err}"
660        );
661    }
662}