From 85b9fea5193e56cb8f11adcbd6307e3f17bf2081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 03:44:11 +0200 Subject: [PATCH] fix(module): keep extensions out of builtin inventory --- changelog.d/9934-module-builtin-inventory.md | 4 ++ crates/perry-runtime/src/module_require.rs | 6 +-- crates/perry-runtime/src/process.rs | 55 +++++++++++++------- 3 files changed, 44 insertions(+), 21 deletions(-) create mode 100644 changelog.d/9934-module-builtin-inventory.md diff --git a/changelog.d/9934-module-builtin-inventory.md b/changelog.d/9934-module-builtin-inventory.md new file mode 100644 index 0000000000..16cc937547 --- /dev/null +++ b/changelog.d/9934-module-builtin-inventory.md @@ -0,0 +1,4 @@ +### Fixed + +- Keep Perry's `node:ffi` extension resolvable without exposing it through + Node's `module.builtinModules` and `Module.isBuiltin()` inventory. diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 59ba05ac50..f7424c02c7 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -126,9 +126,9 @@ fn validate_create_require_base(filename_or_url: f64) { /// from the static-import tables — `v8` (and `sea`, `fs/promises`, /// `stream/consumers`, `stream/web`, `trace_events`, `test/reporters`) were /// implemented and statically importable but rejected here as "package/file". -/// Both resolvers now share one source of truth (`MODULE_BUILTIN_MODULES`, -/// i.e. `module.builtinModules`), including the `node:` normalization and the -/// scheme-only / `_`-internal carve-outs. +/// Both resolvers now share the Node inventory plus Perry's explicit builtin +/// extensions, including the `node:` normalization and the scheme-only / +/// `_`-internal carve-outs. fn supported_require_builtin(specifier: &str) -> Option<&str> { crate::process::supported_builtin_module_name(specifier) } diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index 0c77d5c427..7c7ac675a3 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -114,21 +114,24 @@ pub(crate) fn is_function_value(value: f64) -> bool { /// #6651: single source of truth for the RUNTIME dynamic builtin resolvers. /// `process.getBuiltinModule(id)` and the `require` returned by -/// `module.createRequire(...)` accept exactly the module set of -/// `module.builtinModules` (`MODULE_BUILTIN_MODULES`), so the three surfaces -/// can never drift apart again — pi walls #3 (#6644, `diagnostics_channel`) -/// and #5 (#6651, `v8`) were both a module implemented and statically -/// importable but missing from one hand-copied allowlist. Two carve-outs: +/// `module.createRequire(...)` derive their Node surface from +/// `module.builtinModules` (`MODULE_BUILTIN_MODULES`). Pi walls #3 (#6644, +/// `diagnostics_channel`) and #5 (#6651, `v8`) were both a module implemented +/// and statically importable but missing from one hand-copied allowlist. Perry +/// extensions are listed separately so resolving one cannot add it to Node's +/// public inventory. Two carve-outs apply: /// /// - `_`-prefixed legacy internals (`_http_agent`, …): Node still serves /// them, Perry has no implementation — they must keep failing with an /// error that names the module, not resolve to a method-dead namespace. -/// - Scheme-only builtins (`node:ffi`, `node:sea`, `node:sqlite`, `node:test`, +/// - Scheme-only builtins (`node:sea`, `node:sqlite`, `node:test`, and /// `node:test/reporters` — stored WITH the prefix, exactly as Node spells -/// them in `module.builtinModules`): resolve only when the caller wrote -/// the `node:` prefix. The bare spelling is an ordinary npm package name -/// in Node (`require('sqlite')` is `MODULE_NOT_FOUND`, -/// `getBuiltinModule('sqlite')` is `undefined`). +/// them in `module.builtinModules`): resolve only when the caller wrote the +/// `node:` prefix. The bare spelling is an ordinary npm package name in +/// Node (`require('sqlite')` is `MODULE_NOT_FOUND`, +/// `getBuiltinModule('sqlite')` is `undefined`). Perry's scheme-only +/// extensions use the same resolver rule but live outside the public Node +/// inventory. /// /// Takes the RAW specifier (either spelling); returns the prefixless name. pub(crate) fn supported_builtin_module_name(specifier: &str) -> Option<&str> { @@ -148,7 +151,9 @@ pub(crate) fn supported_builtin_module_name(specifier: &str) -> Option<&str> { return None; } if MODULE_BUILTIN_MODULES.contains(&name) - || (had_node_prefix && MODULE_BUILTIN_MODULES.contains(&specifier)) + || (had_node_prefix + && (MODULE_BUILTIN_MODULES.contains(&specifier) + || PERRY_BUILTIN_MODULE_EXTENSIONS.contains(&specifier))) { return Some(name); } @@ -189,6 +194,11 @@ pub(crate) fn builtin_module_value(module_name: &str) -> f64 { pub(crate) const MODULE_CJS_CLASS_ID: u32 = 0xC0_00_4D; +/// Perry-provided scheme-only modules which resolve like builtins but are not +/// part of Node 26's public `module.builtinModules` / `Module.isBuiltin()` +/// inventory. +pub(crate) const PERRY_BUILTIN_MODULE_EXTENSIONS: &[&str] = &["node:ffi"]; + pub(crate) const MODULE_BUILTIN_MODULES: &[&str] = &[ "_http_agent", "_http_client", @@ -252,7 +262,6 @@ pub(crate) const MODULE_BUILTIN_MODULES: &[&str] = &[ "wasi", "worker_threads", "zlib", - "node:ffi", "node:sea", "node:sqlite", "node:test", @@ -818,10 +827,10 @@ thread_local! { } /// #6651 family regression guard: the dynamic builtin resolvers -/// (`createRequire(...)`'s `require` + `process.getBuiltinModule`) derive from -/// `MODULE_BUILTIN_MODULES`, so every module Perry lists in +/// (`createRequire(...)`'s `require` + `process.getBuiltinModule`) derive their +/// Node surface from `MODULE_BUILTIN_MODULES`, so every module Perry lists in /// `module.builtinModules` must resolve through them — and only through the -/// spellings Node itself accepts. +/// spellings Node itself accepts. Perry extensions are checked separately. #[cfg(test)] mod builtin_module_list_tests { use super::*; @@ -836,9 +845,9 @@ mod builtin_module_list_tests { let prefixed = format!("node:{entry}"); assert_eq!(supported_builtin_module_name(&prefixed), None, "{prefixed}"); } else if let Some(bare) = entry.strip_prefix("node:") { - // Scheme-only builtins (node:ffi, node:sea, node:sqlite, - // node:test, node:test/reporters): the prefixed spelling resolves, the - // bare spelling is an ordinary npm name (Node parity). + // Scheme-only builtins (node:sea, node:sqlite, node:test, + // node:test/reporters): the prefixed spelling resolves, the bare + // spelling is an ordinary npm name (Node parity). assert_eq!(supported_builtin_module_name(entry), Some(bare), "{entry}"); assert_eq!(supported_builtin_module_name(bare), None, "{bare}"); } else { @@ -854,6 +863,16 @@ mod builtin_module_list_tests { } } + #[test] + fn perry_extensions_resolve_without_polluting_the_node_inventory() { + for &entry in PERRY_BUILTIN_MODULE_EXTENSIONS { + let bare = entry.strip_prefix("node:").expect("scheme-only extension"); + assert!(!MODULE_BUILTIN_MODULES.contains(&entry), "{entry}"); + assert_eq!(supported_builtin_module_name(entry), Some(bare), "{entry}"); + assert_eq!(supported_builtin_module_name(bare), None, "{bare}"); + } + } + #[test] fn non_builtins_are_rejected() { for specifier in [