From 6e363c2d1ff3a40cf0b667c7726d93766c5967d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Oct 2026 21:14:44 -0700 Subject: [PATCH 1/5] fix(resolve): match Node 24 package configuration validation Retain selected package metadata failures and preserve Node's shallow reader, lazy map materialization, conditional target fallback, and error identity and diagnostics. Target Node 24.21 for unreadable metadata. Adapts oven-sh/bun#33890 and oven-sh/bun#35711, using the resolved-key loader handling already present from oven-sh/bun#44473. Co-authored-by: Ciro Spaciari MacBook Co-authored-by: Dylan Conway --- LICENSE.md | 111 ++-- docs/runtime/nodejs-compat.mdx | 8 + src/jsc/ResolveMessage.rs | 56 +- src/jsc/VirtualMachine.rs | 22 + src/jsc/bindings/ErrorCode.ts | 4 + src/jsc/bindings/ImportMetaObject.cpp | 48 +- src/jsc/bindings/ImportMetaObject.h | 5 +- src/jsc/modules/NodeModuleModule.cpp | 5 +- src/parsers/json_index.rs | 12 +- src/parsers/lib.rs | 2 + src/parsers/node_json_diagnostic.LICENSE | 26 + src/parsers/node_json_diagnostic.rs | 279 +++++++++ src/parsers/node_package_json.rs | 182 ++++++ src/resolver/dir_info.rs | 20 + src/resolver/lib.rs | 3 + src/resolver/node_module_error.rs | 319 +++++++++++ src/resolver/package_json.rs | 369 ++++++++++-- src/resolver/resolver.rs | 480 ++++++++++++++-- src/runtime/api/BunObject.rs | 64 ++- src/runtime/cli/filter_arg.rs | 3 +- src/runtime/jsc_hooks.rs | 27 +- test/js/bun/resolve/jsonc.test.ts | 4 +- test/js/bun/resolve/resolve-error.test.ts | 657 ++++++++++++++++++++++ 23 files changed, 2554 insertions(+), 152 deletions(-) create mode 100644 src/parsers/node_json_diagnostic.LICENSE create mode 100644 src/parsers/node_json_diagnostic.rs create mode 100644 src/parsers/node_package_json.rs create mode 100644 src/resolver/node_module_error.rs diff --git a/LICENSE.md b/LICENSE.md index 3ae7d08305e1..cdfde1448372 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -18,67 +18,68 @@ This compiles JavaScriptCore, compiles Bun’s `.cpp` bindings for JavaScriptCor Bun statically links these libraries: -| Library | License | -|---------|---------| -| [`boringssl`](https://boringssl.googlesource.com/boringssl/) | [several licenses](https://boringssl.googlesource.com/boringssl/+/refs/heads/master/LICENSE) | -| [`brotli`](https://github.com/google/brotli) | MIT | -| [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) | -| [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause | -| [`ls-hpack`](https://github.com/litespeedtech/ls-hpack) | MIT | -| [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT | -| [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) | -| [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT | -| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License | -| [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license | -| [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 | -| [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 | -| [`uSockets`](https://github.com/uNetworking/uSockets) | Apache 2.0 | -| [`zlib-ng`](https://github.com/zlib-ng/zlib-ng) | zlib | -| [`c-ares`](https://github.com/c-ares/c-ares) | MIT licensed | -| [`libicu`](https://github.com/unicode-org/icu) 78 | [license here](https://github.com/unicode-org/icu/blob/main/icu4c/LICENSE) | -| [`libbase64`](https://github.com/aklomp/base64/blob/master/LICENSE) | BSD 2-Clause | -| [`libuv`](https://github.com/libuv/libuv) (on Windows) | MIT | -| [`libdeflate`](https://github.com/ebiggers/libdeflate) | MIT | -| [`libjpeg-turbo`](https://github.com/libjpeg-turbo/libjpeg-turbo) | [BSD 3-Clause / IJG / zlib](https://github.com/libjpeg-turbo/libjpeg-turbo/blob/main/LICENSE.md) | -| [`libspng`](https://github.com/randy408/libspng) | BSD 2-Clause | -| [`libwebp`](https://github.com/webmproject/libwebp) | BSD 3-Clause | -| [`highway`](https://github.com/google/highway) | Apache 2.0 | -| [`uucode`](https://github.com/jacobsandlund/uucode) | MIT | -| A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed | -| Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed | -| `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception | +| Library | License | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| [`boringssl`](https://boringssl.googlesource.com/boringssl/) | [several licenses](https://boringssl.googlesource.com/boringssl/+/refs/heads/master/LICENSE) | +| [`brotli`](https://github.com/google/brotli) | MIT | +| [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) | +| [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause | +| [`ls-hpack`](https://github.com/litespeedtech/ls-hpack) | MIT | +| [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT | +| [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) | +| [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT | +| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License | +| [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license | +| [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 | +| [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 | +| [`uSockets`](https://github.com/uNetworking/uSockets) | Apache 2.0 | +| [`zlib-ng`](https://github.com/zlib-ng/zlib-ng) | zlib | +| [`c-ares`](https://github.com/c-ares/c-ares) | MIT licensed | +| [`libicu`](https://github.com/unicode-org/icu) 78 | [license here](https://github.com/unicode-org/icu/blob/main/icu4c/LICENSE) | +| [`libbase64`](https://github.com/aklomp/base64/blob/master/LICENSE) | BSD 2-Clause | +| [`libuv`](https://github.com/libuv/libuv) (on Windows) | MIT | +| [`libdeflate`](https://github.com/ebiggers/libdeflate) | MIT | +| [`libjpeg-turbo`](https://github.com/libjpeg-turbo/libjpeg-turbo) | [BSD 3-Clause / IJG / zlib](https://github.com/libjpeg-turbo/libjpeg-turbo/blob/main/LICENSE.md) | +| [`libspng`](https://github.com/randy408/libspng) | BSD 2-Clause | +| [`libwebp`](https://github.com/webmproject/libwebp) | BSD 3-Clause | +| [`highway`](https://github.com/google/highway) | Apache 2.0 | +| [`uucode`](https://github.com/jacobsandlund/uucode) | MIT | +| A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed | +| Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed | +| `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception | ## Polyfills For compatibility reasons, the following packages are embedded into Bun's binary and injected if imported. -| Package | License | -|---------|---------| -| [`acorn`](https://github.com/acornjs/acorn) | MIT | -| [`acorn-walk`](https://github.com/acornjs/acorn) | MIT | -| [`assert`](https://npmjs.com/package/assert) | MIT | -| [`browserify-zlib`](https://npmjs.com/package/browserify-zlib) | MIT | -| [`buffer`](https://npmjs.com/package/buffer) | MIT | -| [`constants-browserify`](https://npmjs.com/package/constants-browserify) | MIT | -| [`crypto-browserify`](https://npmjs.com/package/crypto-browserify) | MIT | -| [`domain-browser`](https://npmjs.com/package/domain-browser) | MIT | -| [`events`](https://npmjs.com/package/events) | MIT | -| [`https-browserify`](https://npmjs.com/package/https-browserify) | MIT | -| [`os-browserify`](https://npmjs.com/package/os-browserify) | MIT | -| [`path-browserify`](https://npmjs.com/package/path-browserify) | MIT | -| [`process`](https://npmjs.com/package/process) | MIT | -| [`punycode`](https://npmjs.com/package/punycode) | MIT | -| [`querystring-es3`](https://npmjs.com/package/querystring-es3) | MIT | -| [`stream-browserify`](https://npmjs.com/package/stream-browserify) | MIT | -| [`stream-http`](https://npmjs.com/package/stream-http) | MIT | -| [`string_decoder`](https://npmjs.com/package/string_decoder) | MIT | -| [`timers-browserify`](https://npmjs.com/package/timers-browserify) | MIT | -| [`tty-browserify`](https://npmjs.com/package/tty-browserify) | MIT | -| [`url`](https://npmjs.com/package/url) | MIT | -| [`util`](https://npmjs.com/package/util) | MIT | -| [`vm-browserify`](https://npmjs.com/package/vm-browserify) | MIT | +| Package | License | +| ------------------------------------------------------------------------ | ------- | +| [`acorn`](https://github.com/acornjs/acorn) | MIT | +| [`acorn-walk`](https://github.com/acornjs/acorn) | MIT | +| [`assert`](https://npmjs.com/package/assert) | MIT | +| [`browserify-zlib`](https://npmjs.com/package/browserify-zlib) | MIT | +| [`buffer`](https://npmjs.com/package/buffer) | MIT | +| [`constants-browserify`](https://npmjs.com/package/constants-browserify) | MIT | +| [`crypto-browserify`](https://npmjs.com/package/crypto-browserify) | MIT | +| [`domain-browser`](https://npmjs.com/package/domain-browser) | MIT | +| [`events`](https://npmjs.com/package/events) | MIT | +| [`https-browserify`](https://npmjs.com/package/https-browserify) | MIT | +| [`os-browserify`](https://npmjs.com/package/os-browserify) | MIT | +| [`path-browserify`](https://npmjs.com/package/path-browserify) | MIT | +| [`process`](https://npmjs.com/package/process) | MIT | +| [`punycode`](https://npmjs.com/package/punycode) | MIT | +| [`querystring-es3`](https://npmjs.com/package/querystring-es3) | MIT | +| [`stream-browserify`](https://npmjs.com/package/stream-browserify) | MIT | +| [`stream-http`](https://npmjs.com/package/stream-http) | MIT | +| [`string_decoder`](https://npmjs.com/package/string_decoder) | MIT | +| [`timers-browserify`](https://npmjs.com/package/timers-browserify) | MIT | +| [`tty-browserify`](https://npmjs.com/package/tty-browserify) | MIT | +| [`url`](https://npmjs.com/package/url) | MIT | +| [`util`](https://npmjs.com/package/util) | MIT | +| [`vm-browserify`](https://npmjs.com/package/vm-browserify) | MIT | ## Additional credits - Bun's JS transpiler, CSS lexer, and Node.js module resolver source code is a port of [@evanw](https://github.com/evanw)’s [esbuild](https://github.com/evanw/esbuild) project. -- Credit to [@kipply](https://github.com/kipply) for the name "Bun"! \ No newline at end of file +- Credit to [@kipply](https://github.com/kipply) for the name "Bun"! +- Package-map JSON diagnostics use [V8](https://v8.dev/) diagnostic templates and formatting rules under the [BSD-3-Clause license](src/parsers/node_json_diagnostic.LICENSE). diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 2331b3a5a030..986df3933a29 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -121,6 +121,14 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:module`](https://nodejs.org/api/module.html) +Runtime package resolution follows Node 24.21's `package.json` validation. Failures in the metadata reader, non-string `name` or `type` fields, and invalid exports condition objects throw `ERR_INVALID_PACKAGE_CONFIG` with the package path and Node's resolution context. Unselected metadata and explicit `.mjs`/`.cjs` formats remain deferred, and values Node ignores are not rejected. + +Reading a selected dependency package materializes both `exports` and `imports`. Invalid JSON in either map throws `SyntaxError` with Node's JSON diagnostic. CommonJS self-reference lookup reads only `exports`; CommonJS `#imports` reads `imports` before entering ESM scope resolution. Format-only scope lookups defer map errors. String fields beginning with `{` or `[` are parsed as JSON maps, matching Node's package reader. + +Unreadable selected package metadata also throws `ERR_INVALID_PACKAGE_CONFIG`, rather than falling through to an index file. This deliberately follows Node 24.21; Node 24.19 treated these read failures as absent metadata. Missing files, non-directory path components, and a `package.json` directory remain absence cases. + +`import.meta.resolve()` validates the package scope of existing `.js`, `.ts`, and extensionless files. Missing-file URLs and `require.resolve()` defer that scope validation. Loading an already-resolved CommonJS file through the ESM loader preserves the original resolution mode. + 🟡 Missing `Module#load()`, `registerHooks`, `findPackageJSON`, `stripTypeScriptTypes`, `getSourceMapsSupport`/`setSourceMapsSupport`. Overriding `require.cache`, `require.extensions` and `module._resolveFilename` is supported. `syncBuiltinESMExports`, `module._load`, `module._pathCache` and `module.register` are no-ops (we recommend [`Bun.plugin`](/runtime/plugins) instead). `findSourceMap` always returns `undefined`. ### [`node:net`](https://nodejs.org/api/net.html) diff --git a/src/jsc/ResolveMessage.rs b/src/jsc/ResolveMessage.rs index 562d09c5c34b..f4f957063711 100644 --- a/src/jsc/ResolveMessage.rs +++ b/src/jsc/ResolveMessage.rs @@ -1,4 +1,4 @@ -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::io::Write as _; use bun_ast::ImportKind; @@ -6,7 +6,9 @@ use bun_core::strings; use crate::build_message::LogKindJsc as _; use crate::bun_string_jsc; -use crate::{CallFrame, JSGlobalObject, JSValue, JsClass, JsResult, StringJsc as _}; +use crate::{ + CallFrame, EncodedSliceJsc as _, JSGlobalObject, JSValue, JsClass, JsResult, StringJsc as _, +}; // R-2 (host-fn re-entrancy): every JS-exposed method takes `&self`. `msg` and // `referrer` are read-only after construction; only `logged` is mutated @@ -22,6 +24,7 @@ pub struct ResolveMessage { // (which is lifetime-parameterised over its backing buffer). pub(crate) referrer: Option>, pub(crate) logged: Cell, + require_parents: RefCell>>, } /// `ImportKind.label()` — the canonical table lives in @@ -76,6 +79,31 @@ fn esm_package_name(specifier: &[u8]) -> &[u8] { } impl ResolveMessage { + pub fn from_node_module_error( + global: &JSGlobalObject, + error: &bun_resolver::NodeModuleError, + is_esm: bool, + specifier: &[u8], + referrer: &[u8], + ) -> JSValue { + use bun_resolver::NodeModuleErrorKind as K; + let code = match error.kind { + K::InvalidPackageJson => { + return bun_core::EncodedSlice::utf16(&error.json_message) + .to_syntax_error_instance(global); + } + K::InvalidPackageConfig | K::InvalidPackageConfigStructure => { + crate::ErrCode::ERR_INVALID_PACKAGE_CONFIG + } + K::PackagePathNotExported => crate::ErrCode::ERR_PACKAGE_PATH_NOT_EXPORTED, + K::PackageImportNotDefined => crate::ErrCode::ERR_PACKAGE_IMPORT_NOT_DEFINED, + K::InvalidPackageTarget => crate::ErrCode::ERR_INVALID_PACKAGE_TARGET, + }; + let text = error.message(is_esm, specifier, referrer); + let message = bstr::BStr::new(&text); + global.err(code, format_args!("{message}")).to_js() + } + // `#[JsClass]` emits `ResolveMessageClass__construct` calling this. pub fn constructor( global: &JSGlobalObject, @@ -307,6 +335,7 @@ impl ResolveMessage { msg: msg.clone(), referrer: Some(Box::<[u8]>::from(referrer)), logged: Cell::new(false), + require_parents: RefCell::new(Vec::new()), }; Ok(resolve_error.to_js(global)) } @@ -363,6 +392,9 @@ impl ResolveMessage { let _ = write!(&mut out, "Cannot find module '{}'", BStr::new(specifier)); if let Some(referrer) = referrer { let _ = write!(&mut out, "\nRequire stack:\n- {}", BStr::new(referrer)); + for parent in self.require_parents.borrow().iter() { + let _ = write!(&mut out, "\n- {}", BStr::new(parent)); + } } } ImportKind::Stmt | ImportKind::Dynamic => { @@ -396,8 +428,7 @@ impl ResolveMessage { bun_string_jsc::create_utf8_for_js(global, &this.msg.data.text) } - // Node: MODULE_NOT_FOUND errors carry `requireStack` (the chain of - // requiring files; Bun tracks only the direct referrer). CJS kinds only. + // Node: MODULE_NOT_FOUND errors carry the chain of requiring files. #[crate::host_fn(getter)] pub fn get_require_stack(this: &Self, global: &JSGlobalObject) -> JsResult { let Some((kind, _, referrer)) = this.node_error_shape() else { @@ -410,6 +441,8 @@ impl ResolveMessage { if let Some(r) = referrer { entries.push(r); } + let parents = this.require_parents.borrow(); + entries.extend(parents.iter().map(Box::as_ref)); JSValue::create_array_from_iter(global, entries.iter().copied(), |r| { bun_string_jsc::create_utf8_for_js(global, r) }) @@ -465,3 +498,18 @@ impl ResolveMessage { }) } } + +#[unsafe(no_mangle)] +pub extern "C" fn ResolveMessage__appendRequireParent(error: JSValue, path: &bun_core::String) { + if let Some(error) = error.as_class_ref::() { + if matches!( + error.node_error_shape(), + Some((ImportKind::Require | ImportKind::RequireResolve, _, _)) + ) { + error + .require_parents + .borrow_mut() + .push(Box::from(path.to_utf8().slice())); + } + } +} diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d25302ab6368..55af0fb73dc0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5395,6 +5395,7 @@ impl VirtualMachine { // SAFETY: per-thread VM is live for this synchronous call. let jsc_vm = unsafe { &mut *jsc_vm_ptr }; + jsc_vm.transpiler.resolver.node_module_error = None; let resolve_result = jsc_vm._resolve( &mut result, specifier_utf8.slice(), @@ -5402,6 +5403,27 @@ impl VirtualMachine { mode.is_esm(), IS_A_FILE_PATH, ); + if resolve_result.is_ok() + && mode.is_esm() + && jsc_vm.transpiler.resolver.node_module_error.is_none() + && bun_paths::is_absolute(result.path) + { + jsc_vm.transpiler.resolver.node_module_error = jsc_vm + .transpiler + .resolver + .node_package_scope_error(result.path); + } + if let Some(error) = jsc_vm.transpiler.resolver.node_module_error.take() { + if resolve_result.is_err() || error.is_fatal() { + return Ok(Err(crate::ResolveMessage::from_node_module_error( + global, + &error, + mode.is_esm(), + specifier_utf8.slice(), + source_utf8.slice(), + ))); + } + } if let Err(err_) = resolve_result { let err = err_; let import_kind = mode.import_kind(); diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 2d6c59a241eb..28d296b78930 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -13,6 +13,10 @@ type ErrorCodeMapping = Array< >; const errors: ErrorCodeMapping = [ + ["ERR_INVALID_PACKAGE_CONFIG", Error], + ["ERR_PACKAGE_PATH_NOT_EXPORTED", Error], + ["ERR_PACKAGE_IMPORT_NOT_DEFINED", TypeError], + ["ERR_INVALID_PACKAGE_TARGET", Error], ["ABORT_ERR", Error, "AbortError"], ["ERR_ACCESS_DENIED", Error], ["ERR_AMBIGUOUS_ARGUMENT", TypeError], diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index 5eee041298e5..2a4d477e34a1 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -51,11 +51,45 @@ #include "isBuiltinModule.h" #include "WebCoreJSBuiltins.h" +#include namespace Zig { using namespace JSC; using namespace WebCore; +extern "C" void ResolveMessage__appendRequireParent(JSC::EncodedJSValue, const BunString*); + +extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue Bun__appendRequireParents(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedError, JSC::EncodedJSValue encodedParent) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto error = JSValue::decode(encodedError); + auto* requirer = dynamicDowncast(JSValue::decode(encodedParent)); + if (!requirer) + return encodedError; + Strong protectedError(vm, error); + MarkedArgumentBuffer parents; + HashSet seen; + seen.add(requirer); + for (auto* parent = requirer->m_parent.get(); parent && seen.add(parent).isNewEntry; parent = parent->m_parent.get()) + parents.append(parent); + if (parents.hasOverflowed()) { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + for (unsigned i = 0; i < parents.size(); ++i) { + auto* parent = uncheckedDowncast(parents.at(i)); + JSValue filename = parent->m_filename.get(); + if (!filename.isString()) + continue; + auto string = filename.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto path = Bun::toString(string); + ResolveMessage__appendRequireParent(JSValue::encode(error), &path); + } + return encodedError; +} + ImportMetaObject* ImportMetaObject::create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, const WTF::String& url) { ImportMetaObject* ptr = new (NotNull, JSC::allocateCell(vm)) ImportMetaObject(vm, structure, url); @@ -186,7 +220,7 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSync(JSC::JSGlobalObje } } - auto result = Bun__resolveSync(globalObject, JSC::JSValue::encode(moduleName), from, isESM, false); + auto result = Bun__resolveSync(globalObject, JSC::JSValue::encode(moduleName), from, isESM, false, JSValue::encode(jsUndefined())); RETURN_IF_EXCEPTION(scope, {}); if (!JSC::JSValue::decode(result).isString()) { @@ -299,7 +333,7 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo paths.append(Bun::toStringRef(pathStr)); } - result = Bun__resolveSyncWithPaths(lexicalGlobalObject, JSC::JSValue::encode(moduleName), JSValue::encode(from), isESM, isRequireDotResolve, paths.begin(), paths.size()); + result = Bun__resolveSyncWithPaths(lexicalGlobalObject, JSC::JSValue::encode(moduleName), JSValue::encode(from), isESM, isRequireDotResolve, paths.begin(), paths.size(), JSValue::encode(parentModule)); if (scope.exception()) [[unlikely]] goto cleanup; @@ -328,7 +362,7 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo return {}; } - auto result = Bun__resolveSync(lexicalGlobalObject, JSC::JSValue::encode(moduleName), JSValue::encode(from), isESM, isRequireDotResolve); + auto result = Bun__resolveSync(lexicalGlobalObject, JSC::JSValue::encode(moduleName), JSValue::encode(from), isESM, isRequireDotResolve, JSValue::encode(parentModule)); RETURN_IF_EXCEPTION(scope, {}); if (!JSC::JSValue::decode(result).isString()) { @@ -404,7 +438,7 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, auto fromWTFString = from.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - // Try to resolve it to a relative file path. This path is not meant to throw module resolution errors. + // File URLs can name missing files; existing files still require package format validation. if (specifier.startsWith("./"_s) || specifier.startsWith("../"_s) || specifier.startsWith("/"_s) || specifier.startsWith("file://"_s) #if OS(WINDOWS) || specifier.startsWith(".\\"_s) || specifier.startsWith("..\\"_s) || specifier.startsWith("\\"_s) @@ -417,6 +451,12 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, } WTF::URL url(fromURL, specifier); + if (url.protocolIsFile()) { + auto pathString = url.fileSystemPath(); + auto path = Bun::toString(pathString); + Bun__validateImportMetaPackageConfig(globalObject, &path); + RETURN_IF_EXCEPTION(scope, {}); + } RELEASE_AND_RETURN(scope, JSValue::encode(jsString(vm, url.string()))); } diff --git a/src/jsc/bindings/ImportMetaObject.h b/src/jsc/bindings/ImportMetaObject.h index 2bbb4a0576a1..eb928371e1b9 100644 --- a/src/jsc/bindings/ImportMetaObject.h +++ b/src/jsc/bindings/ImportMetaObject.h @@ -10,10 +10,11 @@ extern "C" JSC_DECLARE_HOST_FUNCTION(functionImportMeta__resolveSync); extern "C" JSC_DECLARE_HOST_FUNCTION(functionImportMeta__resolveSyncPrivate); -extern "C" JSC::EncodedJSValue Bun__resolveSync(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, JSC::EncodedJSValue from, bool is_esm, bool isUserRequireResolve); -extern "C" JSC::EncodedJSValue Bun__resolveSyncWithPaths(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, JSC::EncodedJSValue from, bool is_esm, bool isUserRequireResolve, const BunString* paths, size_t paths_len); +extern "C" JSC::EncodedJSValue Bun__resolveSync(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, JSC::EncodedJSValue from, bool is_esm, bool isUserRequireResolve, JSC::EncodedJSValue parent); +extern "C" JSC::EncodedJSValue Bun__resolveSyncWithPaths(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, JSC::EncodedJSValue from, bool is_esm, bool isUserRequireResolve, const BunString* paths, size_t paths_len, JSC::EncodedJSValue parent); extern "C" JSC::EncodedJSValue Bun__resolveSyncWithSourceIfExists(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, BunString* from, bool is_esm); extern "C" JSC::EncodedJSValue Bun__resolveSyncWithStrings(JSC::JSGlobalObject* global, BunString* specifier, BunString* from, bool is_esm); +extern "C" JSC::EncodedJSValue Bun__validateImportMetaPackageConfig(JSC::JSGlobalObject* global, const BunString* path); namespace Bun { class JSModuleGraph; diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index 5f42782ec712..2433c493537d 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -295,6 +295,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, default: { JSC::JSValue moduleName = callFrame->argument(0); JSC::JSValue fromValue = callFrame->argument(1); + JSC::JSValue parentModule = fromValue; JSC::JSValue optionsValue = callFrame->argument(3); // 4th argument is options auto& names = builtinNames(vm); @@ -378,7 +379,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, return {}; } - result = Bun__resolveSyncWithPaths(globalObject, JSC::JSValue::encode(moduleName), JSValue::encode(fromValue), false, true, paths.begin(), paths.size()); + result = Bun__resolveSyncWithPaths(globalObject, JSC::JSValue::encode(moduleName), JSValue::encode(fromValue), false, true, paths.begin(), paths.size(), JSValue::encode(parentModule)); // Clean up BunStrings to avoid leaking for (auto& path : paths) { @@ -396,7 +397,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, } // No paths provided, use regular resolution - result = Bun__resolveSync(globalObject, JSC::JSValue::encode(moduleName), JSValue::encode(fromValue), false, true); + result = Bun__resolveSync(globalObject, JSC::JSValue::encode(moduleName), JSValue::encode(fromValue), false, true, JSValue::encode(parentModule)); RETURN_IF_EXCEPTION(scope, {}); if (!JSC::JSValue::decode(result).isString()) { diff --git a/src/parsers/json_index.rs b/src/parsers/json_index.rs index a4487909af52..ba6619353c2b 100644 --- a/src/parsers/json_index.rs +++ b/src/parsers/json_index.rs @@ -44,6 +44,7 @@ pub struct StructuralIndex<'c> { kernel_state: [u64; 3], use_scalar: bool, + node_package_json: bool, s_i: usize, s_prev_scalar: bool, s_pending_escape: bool, @@ -55,6 +56,12 @@ impl<'c> StructuralIndex<'c> { Self::with_producer(contents, !bun_core::env::IS_NATIVE) } + pub(crate) fn for_node_package_json(contents: &'c [u8]) -> Self { + let mut index = Self::new(contents); + index.node_package_json = true; + index + } + fn with_producer(contents: &'c [u8], use_scalar: bool) -> Self { if contents.len() > i32::MAX as usize { let mut idx = Self::empty(contents, use_scalar); @@ -83,6 +90,7 @@ impl<'c> StructuralIndex<'c> { src_off: 0, kernel_state: [0; 3], use_scalar, + node_package_json: false, s_i: 0, s_prev_scalar: false, s_pending_escape: false, @@ -209,7 +217,7 @@ impl<'c> StructuralIndex<'c> { let was_escaped = self.s_pending_escape; self.s_pending_escape = false; match c { - b'"' | b'\'' if !was_escaped => { + b'"' | b'\'' if !was_escaped && (c == b'"' || !self.node_package_json) => { emit!(i); self.s_prev_scalar = false; let quote = c; @@ -240,7 +248,7 @@ impl<'c> StructuralIndex<'c> { i += 1; } } - b'/' => { + b'/' if !self.node_package_json => { self.s_prev_scalar = false; let start = i; match s.get(i + 1) { diff --git a/src/parsers/lib.rs b/src/parsers/lib.rs index 1488ec613198..520e296b89d7 100644 --- a/src/parsers/lib.rs +++ b/src/parsers/lib.rs @@ -6,6 +6,8 @@ pub use error::{Error, Result}; pub mod json_index; mod json_stage2; +mod node_json_diagnostic; +pub mod node_package_json; pub mod xml_index; #[cfg(test)] diff --git a/src/parsers/node_json_diagnostic.LICENSE b/src/parsers/node_json_diagnostic.LICENSE new file mode 100644 index 000000000000..bbad26627eaa --- /dev/null +++ b/src/parsers/node_json_diagnostic.LICENSE @@ -0,0 +1,26 @@ +Copyright 2014, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/parsers/node_json_diagnostic.rs b/src/parsers/node_json_diagnostic.rs new file mode 100644 index 000000000000..76e509ac42bd --- /dev/null +++ b/src/parsers/node_json_diagnostic.rs @@ -0,0 +1,279 @@ +//! JSON syntax validation for Node's lazily materialized package maps. +//! Diagnostics follow https://github.com/nodejs/node/blob/v24.21.0/deps/v8/src/json/json-parser.cc. +//! V8 diagnostic templates are BSD-3-Clause licensed; see node_json_diagnostic.LICENSE. + +#[derive(Clone, Copy)] +enum State { + End, + Value, + ObjectKey(bool), + Colon, + ObjectNext, + ArrayFirst, + ArrayNext, +} + +#[derive(Clone, Copy)] +enum Issue { + Unexpected, + At(&'static str), +} + +pub(crate) fn syntax_error(bytes: &[u8]) -> Option> { + let text = std::str::from_utf8(bytes).ok()?; + let mut cursor = 0; + let mut states = vec![State::End, State::Value]; + while let Some(state) = states.pop() { + while bytes + .get(cursor) + .is_some_and(|c| matches!(c, b' ' | b'\t' | b'\r' | b'\n')) + { + cursor += 1; + } + let issue = match state { + State::End => (cursor != bytes.len()) + .then_some(Issue::At("Unexpected non-whitespace character after JSON")), + State::Value => match bytes.get(cursor) { + Some(b'{') => { + cursor += 1; + states.push(State::ObjectKey(true)); + None + } + Some(b'[') => { + cursor += 1; + states.push(State::ArrayFirst); + None + } + Some(b'"') => scan_string(text, &mut cursor), + Some(b'-' | b'0'..=b'9') => scan_number(bytes, &mut cursor), + Some(b't' | b'f' | b'n') => { + let literal: &[u8] = match bytes[cursor] { + b't' => b"true", + b'f' => b"false", + _ => b"null", + }; + let mut issue = None; + for expected in literal { + if bytes.get(cursor) != Some(expected) { + issue = Some(Issue::Unexpected); + break; + } + cursor += 1; + } + issue + } + _ => Some(Issue::Unexpected), + }, + State::ObjectKey(first) => { + if first && bytes.get(cursor) == Some(&b'}') { + cursor += 1; + None + } else if bytes.get(cursor) == Some(&b'"') { + let issue = scan_string(text, &mut cursor); + states.extend([State::ObjectNext, State::Value, State::Colon]); + issue + } else { + Some(Issue::At(if first { + "Expected property name or '}' in JSON" + } else { + "Expected double-quoted property name in JSON" + })) + } + } + State::Colon => { + if bytes.get(cursor) == Some(&b':') { + cursor += 1; + None + } else { + Some(Issue::At("Expected ':' after property name in JSON")) + } + } + State::ObjectNext => match bytes.get(cursor) { + Some(b',') => { + cursor += 1; + states.push(State::ObjectKey(false)); + None + } + Some(b'}') => { + cursor += 1; + None + } + _ => Some(Issue::At( + "Expected ',' or '}' after property value in JSON", + )), + }, + State::ArrayFirst => { + if bytes.get(cursor) == Some(&b']') { + cursor += 1; + } else { + states.extend([State::ArrayNext, State::Value]); + } + None + } + State::ArrayNext => match bytes.get(cursor) { + Some(b',') => { + cursor += 1; + states.extend([State::ArrayNext, State::Value]); + None + } + Some(b']') => { + cursor += 1; + None + } + _ => Some(Issue::At("Expected ',' or ']' after array element in JSON")), + }, + }; + if let Some(issue) = issue { + return Some(render(text, cursor, issue)); + } + } + None +} + +fn scan_string(text: &str, cursor: &mut usize) -> Option { + let bytes = text.as_bytes(); + *cursor += 1; + loop { + match bytes.get(*cursor) { + None => return Some(Issue::At("Unterminated string in JSON")), + Some(b'"') => { + *cursor += 1; + return None; + } + Some(b'\\') => { + *cursor += 1; + match bytes.get(*cursor) { + Some(b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't') => *cursor += 1, + Some(b'u') => { + for _ in 0..4 { + *cursor += 1; + if !bytes.get(*cursor).is_some_and(u8::is_ascii_hexdigit) { + return Some(Issue::At("Bad Unicode escape in JSON")); + } + } + *cursor += 1; + } + None => return Some(Issue::Unexpected), + _ if text[*cursor..] + .chars() + .next() + .is_some_and(|c| c as u32 > 255) => + { + return Some(Issue::Unexpected); + } + _ => return Some(Issue::At("Bad escaped character in JSON")), + } + } + Some(0..=0x1f) => { + return Some(Issue::At("Bad control character in string literal in JSON")); + } + _ => *cursor += 1, + } + } +} + +fn scan_number(bytes: &[u8], cursor: &mut usize) -> Option { + if bytes.get(*cursor) == Some(&b'-') { + *cursor += 1; + } + if bytes.get(*cursor) == Some(&b'0') { + *cursor += 1; + if bytes.get(*cursor).is_some_and(u8::is_ascii_digit) { + return Some(Issue::Unexpected); + } + } else { + let start = *cursor; + while bytes.get(*cursor).is_some_and(u8::is_ascii_digit) { + *cursor += 1; + } + if *cursor == start { + return Some(Issue::At("No number after minus sign in JSON")); + } + } + if bytes.get(*cursor) == Some(&b'.') { + *cursor += 1; + let start = *cursor; + while bytes.get(*cursor).is_some_and(u8::is_ascii_digit) { + *cursor += 1; + } + if *cursor == start { + return Some(Issue::At("Unterminated fractional number in JSON")); + } + } + if matches!(bytes.get(*cursor), Some(b'e' | b'E')) { + *cursor += 1; + if matches!(bytes.get(*cursor), Some(b'+' | b'-')) { + *cursor += 1; + } + let start = *cursor; + while bytes.get(*cursor).is_some_and(u8::is_ascii_digit) { + *cursor += 1; + } + if *cursor == start { + return Some(Issue::At("Exponent part is missing a number in JSON")); + } + } + None +} + +fn render(text: &str, byte_position: usize, issue: Issue) -> Box<[u16]> { + let position = text[..byte_position].encode_utf16().count(); + let token = text[byte_position..].encode_utf16().next(); + let at = match issue { + Issue::At(message) => Some(message), + Issue::Unexpected => match token { + None => return "Unexpected end of JSON input".encode_utf16().collect(), + Some(0x22) => Some("Unexpected string in JSON"), + Some(0x2d | 0x30..=0x39) => Some("Unexpected number in JSON"), + _ => None, + }, + }; + if let Some(message) = at { + let (mut line, mut column) = (1usize, 1usize); + let mut prefix = text[..byte_position].encode_utf16().peekable(); + while let Some(unit) = prefix.next() { + if unit == 13 && prefix.peek() == Some(&10) { + prefix.next(); + } + if matches!(unit, 10 | 13) { + line += 1; + column = 1; + } else { + column += 1; + } + } + return format!("{message} at position {position} (line {line} column {column})") + .encode_utf16() + .collect(); + } + if matches!(text, "[object Object]" | "undefined" | "Infinity" | "NaN") { + return format!("\"{text}\" is not valid JSON") + .encode_utf16() + .collect(); + } + let mut message: Vec = "Unexpected token '".encode_utf16().collect(); + message.push(token.expect("end of input was handled above")); + message.extend("', ".encode_utf16()); + // V8 includes ten UTF-16 units on either side when the input has at least 21 units. + let length = text.encode_utf16().count(); + let (start, end, before, after) = if length < 21 { + (0, length, false, false) + } else if position < 10 { + (0, position + 10, false, true) + } else if position < length - 10 { + (position - 10, position + 10, true, true) + } else { + (position - 10, length, true, false) + }; + if before { + message.extend("...".encode_utf16()); + } + message.push(0x22); + message.extend(text.encode_utf16().skip(start).take(end - start)); + message.push(0x22); + if after { + message.extend("...".encode_utf16()); + } + message.extend(" is not valid JSON".encode_utf16()); + message.into_boxed_slice() +} diff --git a/src/parsers/node_package_json.rs b/src/parsers/node_package_json.rs new file mode 100644 index 000000000000..8ad04f99e803 --- /dev/null +++ b/src/parsers/node_package_json.rs @@ -0,0 +1,182 @@ +//! Node's package reader validates the outer object and selected strings, not unused values. +//! https://github.com/nodejs/node/blob/v24.21.0/src/node_modules.cc#L139-L267 + +use std::borrow::Cow; +use std::ops::Range; + +use crate::json::ParsedJson; +use crate::json_index::{FLAG_HAS_CTRL_IN_STRING, StructuralIndex}; + +pub const FIELDS: [&[u8]; 5] = [b"name", b"main", b"type", b"exports", b"imports"]; + +#[derive(Default)] +pub struct NodePackageJson { + pub fields: [Option>; 5], + pub needs_recovery: bool, + pub json_errors: [Option>; 2], +} + +impl NodePackageJson { + pub fn parse(contents: &[u8]) -> crate::Result { + let offset = if contents.starts_with(b"\xef\xbb\xbf") { + 3 + } else { + 0 + }; + let text = &contents[offset..]; + let invalid = crate::Error::SyntaxError; + let mut index = StructuralIndex::for_node_package_json(text); + let mut cursor = 0; + if text.get(index.at(cursor)) != Some(&b'{') { + return Err(invalid); + } + cursor += 1; + let mut result = Self::default(); + let mut seen = 0u8; + loop { + let start = index.at(cursor); + if text.get(start) == Some(&b'}') { + cursor += 1; + break; + } + if text.get(start) != Some(&b'"') { + return Err(invalid); + } + let end = index.at(cursor + 1); + if text.get(end) != Some(&b'"') || end <= start { + return Err(invalid); + } + let field = FIELDS.iter().position(|key| *key == &text[start + 1..end]); + if let Some(field) = field { + result.needs_recovery |= seen & (1 << field) != 0; + seen |= 1 << field; + } else { + result.needs_recovery |= + bun_core::strings::contains_char(&text[start + 1..end], b'\\'); + } + cursor += 2; + if text.get(index.at(cursor)) != Some(&b':') { + return Err(invalid); + } + cursor += 1; + let value_start = index.at(cursor); + let first = *text.get(value_start).ok_or(invalid)?; + let value_end = match first { + b'{' | b'[' => { + let mut depth = 1usize; + cursor += 1; + while depth != 0 { + match text.get(index.at(cursor)).ok_or(invalid)? { + b'"' => { + cursor += 1; + if text.get(index.at(cursor)) != Some(&b'"') { + return Err(invalid); + } + } + b'{' | b'[' => depth += 1, + b'}' | b']' => depth -= 1, + _ => {} + } + cursor += 1; + } + index.at(cursor) + } + b'"' => { + let end = index.at(cursor + 1); + if text.get(end) != Some(&b'"') { + return Err(invalid); + } + cursor += 2; + end + 1 + } + b'}' | b']' | b',' | b':' => return Err(invalid), + _ => { + cursor += 1; + let mut end = index.at(cursor); + while end > value_start && matches!(text[end - 1], b' ' | b'\n' | b'\r' | b'\t') + { + end -= 1; + } + end + } + }; + if let Some(field) = field { + let value = &text[value_start..value_end]; + let string = (first == b'"').then(|| string_value(value)).flatten(); + let valid_string = string.is_some(); + result.needs_recovery |= field == 1 && first == b'"' && !valid_string; + if (field == 0 || field == 2) && !valid_string { + return Err(invalid); + } + if (field == 3 || field == 4) && first == b'"' && !valid_string { + return Err(invalid); + } + if (valid_string + && (field != 2 || matches!(string.as_deref(), Some(b"module" | b"commonjs")))) + || (field >= 3 && matches!(first, b'{' | b'[')) + { + result.fields[field] = Some(value_start + offset..value_end + offset); + } + } + match text.get(index.at(cursor)) { + Some(b'}') => { + cursor += 1; + break; + } + Some(b',') => { + cursor += 1; + if text.get(index.at(cursor)) == Some(&b'}') { + return Err(invalid); + } + } + _ => return Err(invalid), + } + } + // Node checks the document's closing token but does not consume trailing objects. + let mut last = index.at(cursor - 1); + while index.at(cursor) < text.len() { + last = index.at(cursor); + cursor += 1; + } + if text.get(last) != Some(&b'}') + || index.index_error.is_some() + || index.flags & FLAG_HAS_CTRL_IN_STRING != 0 + || std::str::from_utf8(text).is_err() + { + return Err(invalid); + } + for field in 3..5 { + let Some(range) = result.fields[field].as_ref() else { + continue; + }; + let raw = &contents[range.clone()]; + if let Some(json) = map_json_source(raw) { + result.needs_recovery |= raw.first() == Some(&b'"'); + result.json_errors[field - 3] = crate::node_json_diagnostic::syntax_error(&json); + } + } + Ok(result) + } +} + +pub fn map_json_source(text: &[u8]) -> Option> { + let value = if text.first() == Some(&b'"') { + string_value(text)? + } else { + Cow::Borrowed(text) + }; + matches!(value.first(), Some(b'{' | b'[')).then_some(value) +} + +fn string_value(text: &[u8]) -> Option> { + if !text.iter().any(|b| *b == b'\\' || *b < 0x20) { + return std::str::from_utf8(text) + .ok() + .map(|_| Cow::Borrowed(&text[1..text.len() - 1])); + } + let source = bun_ast::Source::init_path_string_owned(b"package.json", text.to_vec()); + let parsed = ParsedJson::parse_json(&source, &mut bun_ast::Log::default()).ok()?; + let value = parsed.root.as_utf8_string_literal()?; + std::str::from_utf8(value).ok()?; + Some(Cow::Owned(value.to_vec())) +} diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index b90cba7326e7..a51bf07d88fa 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -183,6 +183,26 @@ fn arena_ref(p: NonNull) -> &'static T { } impl DirInfo { + pub fn package_json_for_node_scope(&self) -> Option<&'static PackageJSON> { + if self.is_node_modules() { + return None; + } + if let Some(pkg) = self.package_json() { + return Some(pkg.for_node()); + } + let mut parent = self.get_parent(); + while let Some(dir) = parent { + if dir.is_node_modules() { + break; + } + if let Some(pkg) = dir.package_json() { + return Some(pkg.for_node()); + } + parent = dir.get_parent(); + } + None + } + /// Is there a "node_modules" subdirectory? #[inline] pub(crate) fn has_node_modules(&self) -> bool { diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 25986a502f8d..ebc71d9eb066 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -18,6 +18,7 @@ pub mod error; #[path = "fs.rs"] mod fs_full; pub mod node_fallbacks; +pub mod node_module_error; pub mod package_json; pub mod tsconfig_json; @@ -36,8 +37,10 @@ pub use data_url::DataURL; /// Re-export real `DirInfo`. pub use dir_info::DirInfo; pub use dir_info::DirInfoRef; + /// Re-export real filesystem `Path`. pub use fs::Path; +pub use node_module_error::{NodeModuleError, NodeModuleErrorKind}; /// Re-export real `PackageJSON`. pub use package_json::PackageJSON; /// Re-export real `TSConfigJSON`. diff --git a/src/resolver/node_module_error.rs b/src/resolver/node_module_error.rs new file mode 100644 index 000000000000..b8a10b815738 --- /dev/null +++ b/src/resolver/node_module_error.rs @@ -0,0 +1,319 @@ +//! Node-shaped module-resolution failure info captured by the resolver for the +//! runtime to surface as Node's exact `ERR_*` errors when a resolve fails. +//! Message templates: https://github.com/nodejs/node/blob/main/lib/internal/errors.js + +use std::io::Write as _; + +use bstr::BStr; + +/// Which Node error the capture maps to. The JS-visible `code` also depends +/// on the import kind (`require()` spells module-not-found `MODULE_NOT_FOUND`; +/// ESM spells it `ERR_MODULE_NOT_FOUND`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum NodeModuleErrorKind { + InvalidPackageJson, + /// ERR_PACKAGE_PATH_NOT_EXPORTED + PackagePathNotExported, + /// ERR_PACKAGE_IMPORT_NOT_DEFINED + PackageImportNotDefined, + /// ERR_INVALID_PACKAGE_TARGET + InvalidPackageTarget, + /// ERR_INVALID_PACKAGE_CONFIG — unparseable package.json. The referrer + /// clause is ` while importing "" from ` and the + /// message ends with a period. + InvalidPackageConfig, + /// ERR_INVALID_PACKAGE_CONFIG — parseable package.json with an invalid + /// `exports`/`imports` shape. The referrer clause is + /// ` while importing `. + InvalidPackageConfigStructure, +} + +/// A captured failure. `head` is Node's message with the referrer clause +/// omitted; the clause (whose shape depends on `kind` and the import kind) is +/// inserted at byte offset `insert_at` once the referrer is known. +pub struct NodeModuleError { + pub kind: NodeModuleErrorKind, + pub json_message: Box<[u16]>, + pub head: Vec, + pub insert_at: usize, + /// Node includes the referrer clause for `require()` of `#imports` + /// specifiers but not for `require()` of package `exports`. + pub referrer_in_require: bool, + pub suppress_referrer: bool, + pub specifier_override: Option>, + pub referrer_override: Option>, +} + +/// JSON-stringify `target` the way Node's `JSONStringify(target)` renders a +/// string target in ERR_INVALID_PACKAGE_TARGET. +fn write_json_string(out: &mut Vec, s: &[u8]) { + let start = out.len(); + let _ = write!( + out, + "{}", + bun_core::fmt::format_json_string_utf8(s, Default::default()) + ); + // JSON.stringify uses lowercase escapes and literal Unicode separators. + let end = out.len(); + let (mut read, mut write) = (start, start); + while let Some(offset) = bun_core::strings::index_of_char_usize(&out[read..end], b'\\') { + out.copy_within(read..read + offset, write); + read += offset; + write += offset; + if read + 6 <= end && out[read + 1] == b'u' { + out[read + 2..read + 6].make_ascii_lowercase(); + let literal = match &out[read + 2..read + 6] { + b"2028" => Some("\u{2028}"), + b"2029" => Some("\u{2029}"), + b"feff" => Some("\u{feff}"), + _ => None, + }; + if let Some(literal) = literal { + let bytes = literal.as_bytes(); + out[write..write + bytes.len()].copy_from_slice(bytes); + write += bytes.len(); + } else { + out.copy_within(read..read + 6, write); + write += 6; + } + read += 6; + } else { + out.copy_within(read..read + 2, write); + read += 2; + write += 2; + } + } + out.copy_within(read..end, write); + out.truncate(write + end - read); +} + +impl NodeModuleError { + pub fn package_config_for_import( + path: &[u8], + reason: crate::package_json::PackageConfigError, + specifier: &[u8], + imports_referrer: Option<&[u8]>, + ) -> Box { + let mut error = Self::package_config(path, reason); + if matches!(reason, crate::package_json::PackageConfigError::Invalid) { + error.suppress_referrer = false; + if let Some(referrer) = imports_referrer { + error.referrer_in_require = true; + error.specifier_override = Some(Box::from(specifier)); + error.referrer_override = Some(Box::from(referrer)); + } + } + error + } + + pub fn package_config( + path: &[u8], + reason: crate::package_json::PackageConfigError, + ) -> Box { + let mut error = Self::invalid_package_config(path); + error.suppress_referrer = true; + if let crate::package_json::PackageConfigError::Read(errno) = reason { + let label = bun_sys::Error::new(errno, bun_sys::Tag::read) + .uv_code_label() + .map_or("unknown error", |(_, label)| label); + error.head = + format!("Cannot read package config {}: {label}.", BStr::new(path)).into_bytes(); + error.insert_at = error.head.len(); + } + error + } + + pub fn invalid_json(message: &[u16]) -> Box { + let mut error = Self::at_end(NodeModuleErrorKind::InvalidPackageJson, Vec::new(), false); + error.json_message = Box::from(message); + error.suppress_referrer = true; + error + } + + pub fn is_fatal(&self) -> bool { + matches!( + self.kind, + NodeModuleErrorKind::InvalidPackageConfig + | NodeModuleErrorKind::InvalidPackageConfigStructure + | NodeModuleErrorKind::InvalidPackageJson + ) + } + + pub fn message(&self, is_esm: bool, specifier: &[u8], referrer: &[u8]) -> Vec { + let specifier = self.specifier_override.as_deref().unwrap_or(specifier); + let referrer = self.referrer_override.as_deref().unwrap_or(referrer); + let mut text = Vec::with_capacity(self.head.len() + referrer.len() + 32); + text.extend_from_slice(&self.head[..self.insert_at]); + if !self.suppress_referrer + && !referrer.is_empty() + && referrer != b"bun:main" + && (is_esm || self.referrer_in_require) + { + match self.kind { + NodeModuleErrorKind::InvalidPackageConfig => { + let _ = write!( + text, + " while importing \"{}\" from {}", + BStr::new(specifier), + BStr::new(referrer) + ); + } + NodeModuleErrorKind::InvalidPackageConfigStructure => { + let url = + bun_url::file_url_from_string(&bun_core::String::from_bytes(referrer)); + let url = url.to_utf8(); + let _ = write!(text, " while importing {}", BStr::new(url.slice())); + } + _ => { + let _ = write!(text, " imported from {}", BStr::new(referrer)); + } + } + } + text.extend_from_slice(&self.head[self.insert_at..]); + text + } + + fn at_end(kind: NodeModuleErrorKind, head: Vec, referrer_in_require: bool) -> Box { + let insert_at = head.len(); + Box::new(Self { + kind, + json_message: Box::default(), + head, + insert_at, + referrer_in_require, + suppress_referrer: false, + specifier_override: None, + referrer_override: None, + }) + } + + /// `Package subpath './x' is not defined by "exports" in /package.json` + /// / `No "exports" main defined in /package.json` + pub fn package_path_not_exported(pkg_json_path: &[u8], subpath: &[u8]) -> Box { + let mut head = Vec::new(); + if subpath == b"." { + let _ = write!( + head, + "No \"exports\" main defined in {}", + BStr::new(pkg_json_path) + ); + } else { + let _ = write!( + head, + "Package subpath '{}' is not defined by \"exports\" in {}", + BStr::new(subpath), + BStr::new(pkg_json_path) + ); + } + Self::at_end(NodeModuleErrorKind::PackagePathNotExported, head, false) + } + + /// `Package import specifier "#x" is not defined in package /package.json` + pub fn package_import_not_defined(specifier: &[u8], pkg_json_path: &[u8]) -> Box { + let mut head = Vec::new(); + let _ = write!( + head, + "Package import specifier \"{}\" is not defined in package {}", + BStr::new(specifier), + BStr::new(pkg_json_path) + ); + Self::at_end(NodeModuleErrorKind::PackageImportNotDefined, head, true) + } + + /// `Invalid "exports" [main ]target defined [for '' ]in the + /// package config /package.json[; targets must start with "./"]` + pub fn invalid_package_target( + pkg_json_path: &[u8], + key: Option<&[u8]>, + target: Option<&[u8]>, + is_imports: bool, + bare_string_target: bool, + ) -> Box { + let field: &str = if is_imports { "imports" } else { "exports" }; + let mut head = Vec::new(); + let _ = write!(head, "Invalid \"{field}\" "); + match key { + Some(b".") | None => { + head.extend_from_slice(b"main target "); + if let Some(target) = target { + write_json_string(&mut head, target); + } + let _ = write!( + head, + " defined in the package config {}", + BStr::new(pkg_json_path) + ); + } + Some(key) => { + head.extend_from_slice(b"target "); + if let Some(target) = target { + write_json_string(&mut head, target); + } + let _ = write!( + head, + " defined for '{}' in the package config {}", + BStr::new(key), + BStr::new(pkg_json_path) + ); + } + } + let insert_at = head.len(); + // Node's `relError` clause is exports-only. + if bare_string_target && !is_imports { + head.extend_from_slice(b"; targets must start with \"./\""); + } + Box::new(Self { + kind: NodeModuleErrorKind::InvalidPackageTarget, + json_message: Box::default(), + head, + insert_at, + referrer_in_require: is_imports, + suppress_referrer: false, + specifier_override: None, + referrer_override: None, + }) + } + + /// `Invalid package config /package.json.` (unparseable file; the + /// period trails the referrer clause). + pub fn invalid_package_config(pkg_json_path: &[u8]) -> Box { + let mut head = Vec::new(); + let _ = write!(head, "Invalid package config {}", BStr::new(pkg_json_path)); + let insert_at = head.len(); + head.push(b'.'); + Box::new(Self { + kind: NodeModuleErrorKind::InvalidPackageConfig, + json_message: Box::default(), + head, + insert_at, + referrer_in_require: false, + suppress_referrer: false, + specifier_override: None, + referrer_override: None, + }) + } + + /// `Invalid package config /package.json. ` (invalid + /// `exports`/`imports` shape). + pub fn invalid_package_config_structure( + pkg_json_path: &[u8], + message: Option<&[u8]>, + ) -> Box { + let mut head = Vec::new(); + let _ = write!(head, "Invalid package config {}", BStr::new(pkg_json_path)); + let insert_at = head.len(); + if let Some(message) = message { + let _ = write!(head, ". {}", BStr::new(message)); + } + Box::new(Self { + kind: NodeModuleErrorKind::InvalidPackageConfigStructure, + json_message: Box::default(), + head, + insert_at, + referrer_in_require: false, + suppress_referrer: false, + specifier_override: None, + referrer_override: None, + }) + } +} diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index a17e1cd9e20e..313c76de6a7a 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -59,6 +59,12 @@ type DependencyHashMap = ArrayHashMap; pub struct PackageJSON { + pub(crate) node_only: bool, + pub(crate) node_override: Option>, + pub node_error: Option, + pub(crate) node_json_errors: [Option>; 2], + pub(crate) node_exports: bool, + pub(crate) node_imports: bool, pub name: Box<[u8]>, pub source: bun_ast::Source, /// Owns the file bytes that `source.contents` (and the @@ -124,6 +130,12 @@ pub struct PackageJSON { impl Default for PackageJSON { fn default() -> Self { PackageJSON { + node_only: false, + node_override: None, + node_error: None, + node_json_errors: [None, None], + node_exports: false, + node_imports: false, name: Box::default(), source: bun_ast::Source::default(), source_contents: Box::default(), @@ -145,6 +157,12 @@ impl Default for PackageJSON { } } +#[derive(Clone, Copy)] +pub enum PackageConfigError { + Invalid, + Read(bun_errno::SystemErrno), +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum IncludeScripts { IgnoreScripts, @@ -188,6 +206,94 @@ impl ::bun_install_types::resolver_hooks::PackageJsonView for PackageJSON { } impl PackageJSON { + pub fn has_bun_metadata(&self) -> bool { + !self.node_only + } + + pub fn for_node(&self) -> &Self { + self.node_override.as_deref().unwrap_or(self) + } + + fn from_node_fields( + path: &'static [u8], + contents: &[u8], + fields: Option, + ) -> Self { + let mut pkg = Self { + node_only: true, + source: bun_ast::Source::init_path_string_owned(path, contents.to_vec()), + node_error: fields.is_none().then_some(PackageConfigError::Invalid), + ..Default::default() + }; + if let Some(fields) = fields { + pkg.apply_node_fields(path, contents, fields); + } + pkg + } + + fn apply_node_fields( + &mut self, + path: &'static [u8], + contents: &[u8], + fields: bun_parsers::node_package_json::NodePackageJson, + ) { + let pkg = self; + pkg.node_json_errors = fields.json_errors; + pkg.name = Box::default(); + pkg.module_type = ModuleType::Unknown; + pkg.main_fields.swap_remove(b"main"); + pkg.exports = None; + pkg.imports = None; + pkg.node_exports = false; + pkg.node_imports = false; + for (field, range) in fields.fields.into_iter().enumerate() { + let Some(range) = range else { continue }; + let raw = &contents[range]; + let map_source = (field >= 3) + .then(|| bun_parsers::node_package_json::map_json_source(raw)) + .flatten(); + let source = bun_ast::Source::init_path_string_owned( + path, + map_source.as_deref().unwrap_or(raw).to_vec(), + ); + let mut log = bun_ast::Log::default(); + let parsed = json_parser::ParsedJson::parse_json(&source, &mut log); + if field >= 3 { + let map = match parsed { + Ok(parsed) => ExportsMap::parse(&source, &mut log, parsed.root), + Err(_) => Some(ExportsMap { + root: Entry { + data: EntryData::InvalidJson, + }, + }), + }; + if field == 3 { + pkg.exports = map; + pkg.node_exports = true; + } else { + pkg.imports = map; + pkg.node_imports = true; + } + } else if let Ok(parsed) = parsed { + if let Some(value) = parsed.root.as_utf8_string_literal() { + match field { + 0 => pkg.name = Box::from(value), + 1 if !value.is_empty() => { + pkg.main_fields.put(b"main", Box::from(value)).expect("oom"); + } + 2 => { + pkg.module_type = ModuleType::LIST + .get(value) + .copied() + .unwrap_or(ModuleType::Unknown) + } + _ => {} + } + } + } + } + } + /// Normalize path separators to forward slashes for glob matching /// This is needed because glob patterns use forward slashes but Windows uses backslashes fn normalize_path_for_glob(path: &[u8]) -> Result, bun_alloc::AllocError> { @@ -402,6 +508,21 @@ impl PackageJSON { ) { Ok(e) => e, Err(err) => { + if let crate::Error::Sys(errno) = err { + if !matches!( + errno, + bun_errno::SystemErrno::ENOENT + | bun_errno::SystemErrno::ENOTDIR + | bun_errno::SystemErrno::EISDIR + ) { + return Some(PackageJSON { + node_only: true, + source: bun_ast::Source::init_path_string(package_json_path, b""), + node_error: Some(PackageConfigError::Read(errno)), + ..Default::default() + }); + } + } if err != crate::Error::Sys(bun_errno::SystemErrno::EISDIR) { r_log.add_error_fmt( None, @@ -455,9 +576,32 @@ impl PackageJSON { let contents_static: &'static [u8] = unsafe { bun_ptr::detach_lifetime(&entry_contents) }; let json_source = bun_ast::Source::init_path_string(package_json_path, contents_static); + let node_fields = bun_parsers::node_package_json::NodePackageJson::parse(&entry_contents); + let node_error = node_fields + .as_ref() + .err() + .map(|_| PackageConfigError::Invalid); + let node_exports = node_fields + .as_ref() + .is_ok_and(|fields| fields.fields[3].is_some()); + let node_imports = node_fields + .as_ref() + .is_ok_and(|fields| fields.fields[4].is_some()); + let node_json_errors = node_fields + .as_ref() + .ok() + .map(|fields| fields.json_errors.clone()) + .unwrap_or_default(); + let parsed_json = match r.caches.json.parse_package_json(r_log, &json_source) { Ok(Some(v)) => v, - Ok(None) => return None, + Ok(None) => { + return Some(Self::from_node_fields( + package_json_path, + &entry_contents, + node_fields.ok(), + )); + } Err(err) => { if cfg!(debug_assertions) { Output::print_error(format_args!( @@ -466,19 +610,30 @@ impl PackageJSON { bstr::BStr::new(err.name()) )); } - return None; + return Some(Self::from_node_fields( + package_json_path, + &entry_contents, + node_fields.ok(), + )); } }; let json: js_ast::Expr = parsed_json.root; if !json.is_object() { - // Invalid package.json in node_modules is noisy. - // Let's just ignore it. - // (allocator.free dropped — entry.contents owned by `entry`) - return None; + return Some(Self::from_node_fields( + package_json_path, + &entry_contents, + None, + )); } let mut package_json = PackageJSON { + node_only: false, + node_override: None, + node_error, + node_json_errors, + node_exports, + node_imports, name: Box::default(), version: Box::default(), // Reshaped for borrowck — `json_source` stays a local until the @@ -983,6 +1138,16 @@ impl PackageJSON { } let _ = (include_scripts, package_id); + if let Ok(fields) = node_fields { + if fields.needs_recovery { + package_json.node_override = Some(Box::new(Self::from_node_fields( + package_json_path, + &entry_contents, + Some(fields), + ))); + } + } + // Reshaped for borrowck — assign source last (see struct init above). // `bun_ast::Source` isn't `Clone`; reconstruct from its (all-Copy/Clone) fields. package_json.source = bun_ast::Source { @@ -1061,13 +1226,22 @@ impl<'a> Visitor<'a> { }, js_ast::E::JsonValue::Object(e_obj) => self.visit_object(e_obj.get()), js_ast::E::JsonValue::Array(e_array) => self.visit_array(e_array.get(), &vloc), - js_ast::E::JsonValue::Boolean(_) => { + js_ast::E::JsonValue::Boolean(b) => { let loc = vloc.resolve(&self.source.contents); - self.invalid(js_lexer::range_of_identifier(self.source, loc)) + self.invalid( + js_lexer::range_of_identifier(self.source, loc), + if *b { + b"true".to_vec() + } else { + b"false".to_vec() + }, + ) } - js_ast::E::JsonValue::Number(_) => { + js_ast::E::JsonValue::Number(n) => { let loc = vloc.resolve(&self.source.contents); - self.invalid(bun_ast::Range { loc, len: 1 }) + let mut buffer = [0; 124]; + let rendered = bun_core::fmt::FormatDouble::dtoa(&mut buffer, n.value()).to_vec(); + self.invalid(bun_ast::Range { loc, len: 1 }, rendered) } } } @@ -1077,6 +1251,7 @@ impl<'a> Visitor<'a> { let mut map_data: EntryDataMapList = Vec::with_capacity(rows.len()); let mut expansion_keys: Vec = Vec::with_capacity(rows.len()); let mut is_conditional_sugar = false; + let mut warned_mixed_keys = false; for (i, prop) in rows.iter().enumerate() { let key: Box<[u8]> = Box::from(prop.key.slice()); let key_range: bun_ast::Range = self.source.range_of_string(prop.key_loc); @@ -1086,7 +1261,7 @@ impl<'a> Visitor<'a> { let cur_is_conditional_sugar = !strings::starts_with_char(&key, b'.'); if i == 0 { is_conditional_sugar = cur_is_conditional_sugar; - } else if is_conditional_sugar != cur_is_conditional_sugar { + } else if is_conditional_sugar != cur_is_conditional_sugar && !warned_mixed_keys { let prev = &map_data[i - 1]; self.log .add_range_warning_fmt_with_note( @@ -1102,9 +1277,7 @@ impl<'a> Visitor<'a> { ), prev.key_range, ); - return Entry { - data: EntryData::Invalid, - }; + warned_mixed_keys = true; } let value = self.visit_value( @@ -1180,18 +1353,18 @@ impl<'a> Visitor<'a> { ..bun_ast::Range::NONE }, }; - self.invalid(first_token) + self.invalid(first_token, Vec::new()) } #[cold] - fn invalid(&mut self, first_token: bun_ast::Range) -> Entry { + fn invalid(&mut self, first_token: bun_ast::Range, rendered: Vec) -> Entry { self.log.add_range_warning( Some(self.source), first_token, b"This value must be a string, an object, an array, or null", ); Entry { - data: EntryData::Invalid, + data: EntryData::Invalid(rendered.into_boxed_slice()), } } } @@ -1203,7 +1376,9 @@ pub struct Entry { #[derive(Clone)] pub enum EntryData { - Invalid, + InvalidJson, + /// Invalid target primitive, rendered for Node's error message. + Invalid(Box<[u8]>), Null, String(Box<[u8]>), // owned copy Array(Box<[Entry]>), @@ -1250,6 +1425,7 @@ impl Entry { pub type ConditionsMap = StringArrayHashMap<()>; pub(crate) struct ESModule<'a> { + pub(crate) validate_package_config: bool, pub(crate) debug_logs: Option<&'a mut resolver::DebugLogs>, pub(crate) conditions: &'a ConditionsMap, } @@ -1260,6 +1436,24 @@ pub struct Resolution { // The source-buffer case (`EntryData::String(Box<[u8]>)`) is owned by a // possibly-temporary `Entry`, so borrowing would dangle. Copy out into an owned buffer. pub(crate) path: Box<[u8]>, + /// Context for Node-shaped error messages when `status` is a failure. + pub(crate) detail: Option>, +} + +/// Context captured where the failing package key and target are still in scope. +#[derive(Clone)] +pub(crate) enum ResolutionDetail { + /// The offending map key and target value. `bare_string_target` is + /// Node's `relError`: a non-empty target converted to a string not starting with "./" + /// (which for `exports` appends `; targets must start with "./"`). + InvalidTarget { + key: Option>, + target: Option>, + bare_string_target: bool, + }, + /// The package config shape itself is invalid; `message` is Node's + /// trailing explanation. + ConfigMessage { message: Box<[u8]> }, } impl Default for Resolution { @@ -1267,6 +1461,7 @@ impl Default for Resolution { Resolution { status: Status::Undefined, path: Box::default(), + detail: None, } } } @@ -1483,6 +1678,9 @@ use bun_core::strings::{replace, replacement_size}; const INVALID_PERCENT_CHARS: [&[u8]; 4] = [b"%2f", b"%2F", b"%5c", b"%5C"]; +/// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/modules/esm/resolve.js#L569-L573 +pub const NODE_MIXED_KEYS_MESSAGE: &[u8] = b"\"exports\" cannot contain some keys starting with '.' and some not. The exports object must either be an object of package subpath keys or an object of main entry condition name keys only."; + struct ModuleBufs { resolved_path_buf_percent: PathBuffer, resolve_target_buf: PathBuffer, @@ -1544,7 +1742,7 @@ impl<'a> ESModule<'a> { pub(crate) fn resolve_imports(&mut self, specifier: &[u8], imports: &Entry) -> Resolution { if !matches!(imports.data, EntryData::Map(_)) { return Resolution { - status: Status::InvalidPackageConfiguration, + status: Status::PackageImportNotDefined, ..Default::default() }; } @@ -1589,6 +1787,7 @@ impl<'a> ESModule<'a> { return Resolution { status: Status::InvalidModuleSpecifier, path: result.path, + detail: None, }; } @@ -1605,6 +1804,7 @@ impl<'a> ESModule<'a> { return Resolution { status: Status::InvalidModuleSpecifier, path: result.path, + detail: None, }; } }; @@ -1616,6 +1816,7 @@ impl<'a> ESModule<'a> { return Resolution { status: Status::UnsupportedDirectoryImport, path: result.path, + detail: None, }; } @@ -1630,15 +1831,22 @@ impl<'a> ESModule<'a> { subpath: &[u8], exports: &Entry, ) -> Resolution { - if matches!(exports.data, EntryData::Invalid) { - if let Some(logs) = self.debug_logs.as_deref_mut() { - logs.add_note(b"Invalid package configuration".to_vec()); + if let EntryData::Map(object) = &exports.data { + if let Some(first) = object.list.first() { + if object + .list + .iter() + .any(|entry| entry.key.starts_with(b".") != first.key.starts_with(b".")) + { + return Resolution { + status: Status::InvalidPackageConfiguration, + detail: Some(Box::new(ResolutionDetail::ConfigMessage { + message: Box::from(NODE_MIXED_KEYS_MESSAGE), + })), + ..Default::default() + }; + } } - - return Resolution { - status: Status::InvalidPackageConfiguration, - ..Default::default() - }; } if subpath == b"." { @@ -1653,7 +1861,7 @@ impl<'a> ESModule<'a> { if !matches!(main_export.data, EntryData::Null) { let result = self.resolve_target::(package_url, main_export, b"", false); if result.status != Status::Null && result.status != Status::Undefined { - return result; + return Self::attach_failure_key(result, b"."); } } } @@ -1705,7 +1913,8 @@ impl<'a> ESModule<'a> { log.add_note_fmt(format_args!("Found \"{}\"", bstr::BStr::new(match_key))); } - return self.resolve_target::(package_url, target, b"", is_imports); + let result = self.resolve_target::(package_url, target, b"", is_imports); + return Self::attach_failure_key(result, match_key); } } @@ -1741,12 +1950,13 @@ impl<'a> ESModule<'a> { bstr::BStr::new(subpath) )); } - return self.resolve_target::( + let result = self.resolve_target::( package_url, target, subpath, is_imports, ); + return Self::attach_failure_key(result, &expansion.key); } } } else { @@ -1762,8 +1972,10 @@ impl<'a> ESModule<'a> { bstr::BStr::new(subpath) )); } - let mut result = - self.resolve_target::(package_url, target, subpath, is_imports); + let mut result = Self::attach_failure_key( + self.resolve_target::(package_url, target, subpath, is_imports), + &expansion.key, + ); if result.status == Status::Exact || result.status == Status::ExactEndsWithStar { @@ -1796,6 +2008,28 @@ impl<'a> ESModule<'a> { } } + /// Fills the failing `exports`/`imports` map key into a failure + /// `Resolution` so Node-shaped messages can name it. + fn attach_failure_key(mut result: Resolution, key: &[u8]) -> Resolution { + match result.status { + Status::InvalidPackageTarget => match result.detail.as_deref_mut() { + Some(ResolutionDetail::InvalidTarget { key: k @ None, .. }) => { + *k = Some(Box::<[u8]>::from(key)); + } + None => { + result.detail = Some(Box::new(ResolutionDetail::InvalidTarget { + key: Some(Box::<[u8]>::from(key)), + target: None, + bare_string_target: false, + })); + } + _ => {} + }, + _ => {} + } + result + } + fn resolve_target( &mut self, package_url: &[u8], @@ -1846,6 +2080,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(subpath), status: Status::InvalidModuleSpecifier, + detail: None, }; } }; @@ -1862,6 +2097,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(str), status: Status::InvalidPackageTarget, + detail: None, }; } @@ -1880,6 +2116,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(str), status: Status::InvalidModuleSpecifier, + detail: None, }; } } @@ -1903,6 +2140,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(subpath), status: Status::InvalidModuleSpecifier, + detail: None, }; } } @@ -1919,6 +2157,8 @@ impl<'a> ESModule<'a> { if internal && !strings::has_prefix(str, b"../") && !strings::has_prefix(str, b"/") + && (!self.validate_package_config + || bun_url::whatwg::Parsed::from_utf8(str).is_none()) { if PATTERN { // Return the URL resolution of resolvedTarget with every instance of "*" replaced with subpath. @@ -1938,6 +2178,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(result), status: Status::PackageResolve, + detail: None, }; } else { // Latent Windows bug (#30839): this branch runs when an @@ -1965,6 +2206,7 @@ impl<'a> ESModule<'a> { return Resolution { path, status: Status::PackageResolve, + detail: None, }; } } @@ -1972,6 +2214,11 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(str), status: Status::InvalidPackageTarget, + detail: Some(Box::new(ResolutionDetail::InvalidTarget { + key: None, + target: Some(Box::<[u8]>::from(str)), + bare_string_target: !str.is_empty(), + })), }; } @@ -1989,6 +2236,11 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(str), status: Status::InvalidPackageTarget, + detail: Some(Box::new(ResolutionDetail::InvalidTarget { + key: None, + target: Some(Box::<[u8]>::from(str)), + bare_string_target: false, + })), }; } @@ -2012,6 +2264,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(str), status: Status::InvalidModuleSpecifier, + detail: None, }; } @@ -2042,6 +2295,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(result), status: Status::InvalidModuleSpecifier, + detail: None, }; } @@ -2050,6 +2304,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::<[u8]>::from(result), status: Status::ExactEndsWithStar, + detail: None, }; } else { let parts2 = [package_url, str, subpath]; @@ -2069,10 +2324,24 @@ impl<'a> ESModule<'a> { return Resolution { path, status: Status::Exact, + detail: None, }; } } EntryData::Map(object) => { + if self.validate_package_config + && object.list.iter().any(|entry| is_array_index(&entry.key)) + { + return Resolution { + status: Status::InvalidPackageConfiguration, + detail: Some(Box::new(ResolutionDetail::ConfigMessage { + message: Box::from( + b"\"exports\" cannot contain numeric property keys.".as_slice(), + ), + })), + ..Default::default() + }; + } for entry in object.list.iter() { let key: &[u8] = &entry.key; if self.conditions.contains_key(key) { @@ -2111,6 +2380,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::default(), status: Status::UndefinedNoConditionsMatch, + detail: None, }; } EntryData::Array(array) => { @@ -2125,10 +2395,11 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::default(), status: Status::Null, + detail: None, }; } - let mut last_exception = Status::Undefined; + let mut last_exception = Resolution::default(); for target_value in array.iter() { // Let resolved be the result, continuing the loop on any Invalid Package Target error. @@ -2141,7 +2412,8 @@ impl<'a> ESModule<'a> { if result.status == Status::InvalidPackageTarget || result.status == Status::Null { - last_exception = result.status; + last_exception = result; + continue; } if result.status.is_undefined() { @@ -2151,9 +2423,17 @@ impl<'a> ESModule<'a> { return result; } + return last_exception; + } + EntryData::Invalid(rendered) => { return Resolution { - path: Box::default(), - status: last_exception, + status: Status::InvalidPackageTarget, + detail: Some(Box::new(ResolutionDetail::InvalidTarget { + key: None, + target: Some(rendered.clone()), + bare_string_target: !rendered.is_empty(), + })), + ..Default::default() }; } EntryData::Null => { @@ -2167,6 +2447,7 @@ impl<'a> ESModule<'a> { return Resolution { path: Box::default(), status: Status::Null, + detail: None, }; } _ => {} @@ -2186,6 +2467,22 @@ impl<'a> ESModule<'a> { } } +fn is_array_index(key: &[u8]) -> bool { + let Some(number) = std::str::from_utf8(key) + .ok() + .and_then(|key| key.parse::().ok()) + else { + return false; + }; + // Node's predicate also rejects canonical fractional keys. + // https://github.com/nodejs/node/blob/v24.21.0/lib/internal/modules/esm/resolve.js#L463-L466 + if !(0.0..f64::from(u32::MAX)).contains(&number) { + return false; + } + let mut buffer = [0; 124]; + bun_core::fmt::FormatDouble::dtoa(&mut buffer, number) == key +} + fn find_invalid_segment(path_: &[u8]) -> Option<&[u8]> { let Some(slash) = strings::index_of_any(path_, b"/\\") else { return Some(b""); diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 3615a6efe7fe..3a2bc76e48ae 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -487,6 +487,10 @@ pub struct Resolver<'a> { pub debug_logs: Option, pub elapsed: u64, // tracing + /// Runtime failure context; invalid selected package configuration is fatal. + pub node_module_error: Option>, + pub validate_package_config: bool, + pub watcher: Option, pub caches: CacheSet, @@ -624,6 +628,8 @@ impl<'a> Resolver<'a> { // `DebugLogs` owns Vecs — per-worker fresh. debug_logs: None, elapsed: 0, + node_module_error: None, + validate_package_config: false, watcher: from.watcher, caches: CacheSet::init(), generation: from.generation, @@ -924,6 +930,8 @@ impl<'a> Resolver<'a> { care_about_scripts: false, debug_logs: None, elapsed: 0, + node_module_error: None, + validate_package_config: false, watcher: None, generation: 0, package_manager: None, @@ -1549,7 +1557,12 @@ impl<'a> Resolver<'a> { // Node reads "type" from the nearest package.json, named or not. if primary && !kind.is_from_css() && module_type == options::ModuleType::Unknown { - if let Some(pkg) = dir.package_json_for_module_type { + let package_json = if self.validate_package_config { + dir.package_json_for_node_scope() + } else { + dir.package_json_for_module_type + }; + if let Some(pkg) = package_json { module_type = pkg.module_type; } } @@ -2085,7 +2098,7 @@ impl<'a> Resolver<'a> { let dirname = bun_paths::dirname(abs_path).unwrap_or(abs_path); if let Ok(Some(import_dir_info_outer)) = self.dir_info_cached(dirname) { if let Some(import_dir_info) = import_dir_info_outer.get_enclosing_browser_scope() { - let pkg = import_dir_info.package_json().unwrap(); + let pkg = self.package_json_for_resolution(&import_dir_info).unwrap(); if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( &import_dir_info, @@ -2238,7 +2251,7 @@ impl<'a> Resolver<'a> { if self.care_about_browser_field { // Support remapping one package path to another via the "browser" field if let Some(browser_scope) = source_dir_info.get_enclosing_browser_scope() { - if let Some(package_json) = browser_scope.package_json() { + if let Some(package_json) = self.package_json_for_resolution(&browser_scope) { if let Some(remapped) = self .check_browser_map::<{ BrowserMapPathKind::PackagePath }>( &browser_scope, @@ -2498,6 +2511,12 @@ impl<'a> Resolver<'a> { out: &mut MatchResult, ) -> MatchStatus { let mut dir_info: DirInfoRef = _dir_info; + let imports_referrer = if forbid_imports { + self.package_json_for_resolution(&_dir_info) + .map(|pkg| pkg.source.path.text) + } else { + None + }; if let Some(debug) = self.debug_logs.as_mut() { debug.add_note_fmt(format_args!( "Searching for {} in \"node_modules\" directories starting from \"{}\"", @@ -2546,7 +2565,11 @@ impl<'a> Resolver<'a> { // Find the parent directory with the "package.json" file let mut dir_info_package_json: Option = Some(dir_info); while let Some(d) = dir_info_package_json { - if d.package_json.is_some() { + if self.validate_package_config && d.is_node_modules() { + dir_info_package_json = None; + break; + } + if self.package_json_for_resolution(&d).is_some() { break; } dir_info_package_json = d.get_parent(); @@ -2554,9 +2577,61 @@ impl<'a> Resolver<'a> { // Check for subpath imports: https://nodejs.org/api/packages.html#subpath-imports if let Some(_dir_info_package_json) = dir_info_package_json { - let package_json = _dir_info_package_json.package_json().unwrap(); + let package_json = self + .package_json_for_resolution(&_dir_info_package_json) + .unwrap(); + if self.validate_package_config { + if package_json.node_error.is_some() { + if let Some(error) = + self.node_package_scope_error_for_directory(_dir_info_package_json.abs_path) + { + self.capture_node_module_error(error); + return MatchStatus::NotFound; + } + } + // CJS self lookup reads exports lazily; #imports reads imports before the ESM scope reader. + let maps = if matches!( + kind, + ast::ImportKind::Require | ast::ImportKind::RequireResolve + ) { + if !forbid_imports && import_path.starts_with(b"#") && package_json.node_imports + { + if self.capture_package_map_error(package_json, PackageMapRead::Imports) { + return MatchStatus::NotFound; + } + PackageMapRead::Both + } else { + PackageMapRead::Exports + } + } else { + PackageMapRead::Both + }; + if self.capture_package_map_error(package_json, maps) { + return MatchStatus::NotFound; + } + if import_path.starts_with(b"#") + && !forbid_imports + && !package_json.node_imports + && !matches!( + kind, + ast::ImportKind::Require | ast::ImportKind::RequireResolve + ) + { + self.capture_node_module_error( + crate::NodeModuleError::package_import_not_defined( + import_path, + package_json.source.path.text, + ), + ); + return MatchStatus::NotFound; + } + } - if import_path.starts_with(b"#") && !forbid_imports && package_json.imports.is_some() { + if import_path.starts_with(b"#") + && !forbid_imports + && package_json.imports.is_some() + && (!self.validate_package_config || package_json.node_imports) + { let r = self.load_package_imports( import_path, _dir_info_package_json, @@ -2573,7 +2648,10 @@ impl<'a> Resolver<'a> { // https://nodejs.org/api/packages.html#packages_self_referencing_a_package_using_its_name let package_name = crate::package_json::Package::parse_name(import_path); if let Some(_package_name) = package_name { - if _package_name == package_json.name.as_ref() && package_json.exports.is_some() { + if _package_name == package_json.name.as_ref() + && package_json.exports.is_some() + && (!self.validate_package_config || package_json.node_exports) + { if let Some(debug) = self.debug_logs.as_mut() { debug.add_note_fmt(format_args!( "\"{}\" is a self-reference", @@ -2633,7 +2711,18 @@ impl<'a> Resolver<'a> { .abs_buf(&parts, bufs!(esm_absolute_package_path)) }; - if let Ok(Some(pkg_dir_info)) = self.dir_info_cached(abs_package_path) { + let pkg_dir_info = self.dir_info_cached(abs_package_path); + if self.validate_package_config + && !matches!(pkg_dir_info, Ok(Some(_))) + && self.capture_unreadable_package( + abs_package_path, + import_path, + imports_referrer, + ) + { + return MatchStatus::NotFound; + } + if let Ok(Some(pkg_dir_info)) = pkg_dir_info { self.extension_order = match kind { ast::ImportKind::Url | ast::ImportKind::AtConditional @@ -2641,8 +2730,42 @@ impl<'a> Resolver<'a> { _ => self.opts.extension_order.kind(kind, true), }; - if let Some(package_json) = pkg_dir_info.package_json() { - if let Some(exports_map) = package_json.exports.as_ref() { + if let Some(package_json) = + self.package_json_for_resolution(&pkg_dir_info) + { + if self.validate_package_config { + if let Some(reason) = package_json.node_error { + let error = + crate::NodeModuleError::package_config_for_import( + package_json.source.path.text, + reason, + import_path, + imports_referrer, + ); + self.capture_node_module_error(error); + self.extension_order = prev_extension_order; + return MatchStatus::NotFound; + } + let maps = if is_self_reference + && matches!( + kind, + ast::ImportKind::Require + | ast::ImportKind::RequireResolve + ) { + PackageMapRead::Exports + } else { + PackageMapRead::Both + }; + if self.capture_package_map_error(package_json, maps) { + self.extension_order = prev_extension_order; + return MatchStatus::NotFound; + } + } + if let Some(exports_map) = + package_json.exports.as_ref().filter(|_| { + !self.validate_package_config || package_json.node_exports + }) + { // The condition set is determined by the kind of import // NOTE: keeping a single // `ESModule` (which holds `&mut self.debug_logs`) alive across a @@ -2657,6 +2780,7 @@ impl<'a> Resolver<'a> { // directory path accidentally being interpreted as URL escapes. { let esm_resolution = ESModule { + validate_package_config: self.validate_package_config, conditions: match kind { ast::ImportKind::Require | ast::ImportKind::RequireResolve => { @@ -2680,6 +2804,8 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, + false, + is_self_reference, out, ) .is_success() @@ -2712,6 +2838,7 @@ impl<'a> Resolver<'a> { let extname = bun_paths::extension(esm.subpath); if extname == b".js" && esm.subpath.len() > 3 { let esm_resolution = ESModule { + validate_package_config: self.validate_package_config, conditions: match kind { ast::ImportKind::Require | ast::ImportKind::RequireResolve => { @@ -2737,6 +2864,8 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, + false, + is_self_reference, out, ) .is_success() @@ -3139,8 +3268,32 @@ impl<'a> Resolver<'a> { Ok(dir_info_to_use_) => { if let Some(pkg_dir_info) = dir_info_to_use_ { let abs_package_path = pkg_dir_info.abs_path; - if let Some(package_json) = pkg_dir_info.package_json() { - if let Some(exports_map) = package_json.exports.as_ref() { + if let Some(package_json) = + self.package_json_for_resolution(&pkg_dir_info) + { + if self.validate_package_config { + if let Some(reason) = package_json.node_error { + self.capture_node_module_error( + crate::NodeModuleError::package_config_for_import( + package_json.source.path.text, + reason, + import_path, + imports_referrer, + ), + ); + return MatchStatus::NotFound; + } + } + if self + .capture_package_map_error(package_json, PackageMapRead::Both) + { + return MatchStatus::NotFound; + } + if let Some(exports_map) = + package_json.exports.as_ref().filter(|_| { + !self.validate_package_config || package_json.node_exports + }) + { // The condition set is determined by the kind of import // NOTE: reshaped for borrowck — see identical note above. // Resolve against the path "/", then join it with the absolute @@ -3151,6 +3304,7 @@ impl<'a> Resolver<'a> { // directory path accidentally being interpreted as URL escapes. { let esm_resolution = ESModule { + validate_package_config: self.validate_package_config, conditions: match kind { ast::ImportKind::Require | ast::ImportKind::RequireResolve => { @@ -3169,6 +3323,8 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, + false, + is_self_reference, out, ) .is_success() @@ -3189,6 +3345,7 @@ impl<'a> Resolver<'a> { let extname = bun_paths::extension(esm.subpath); if extname == b".js" && esm.subpath.len() > 3 { let esm_resolution = ESModule { + validate_package_config: self.validate_package_config, conditions: match kind { ast::ImportKind::Require | ast::ImportKind::RequireResolve => { @@ -3210,6 +3367,8 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, + false, + is_self_reference, out, ) .is_success() @@ -3604,6 +3763,184 @@ impl<'a> Resolver<'a> { unreachable!("TODO: implement enqueueDependencyToResolve for non-root packages") } + pub fn node_package_scope_error(&mut self, path: &[u8]) -> Option> { + if !matches!(bun_paths::extension(path), b"" | b".js" | b".ts") { + return None; + } + self.node_package_scope_error_for_directory(Fs::PathName::init(path).dir) + } + + fn node_package_scope_error_for_directory( + &mut self, + directory: &[u8], + ) -> Option> { + // Node continues after invalid metadata until a valid scope or boundary. + // https://github.com/nodejs/node/blob/v24.21.0/src/node_modules.cc#L310-L350 + let mut error = None; + if let Ok(Some(dir)) = self.read_dir_info(directory) { + let mut current = Some(dir); + while let Some(dir) = current { + if dir.is_node_modules() { + break; + } + if let Some(package) = dir.package_json().map(PackageJSON::for_node) { + let Some(reason) = package.node_error else { + return error; + }; + error = Some(crate::NodeModuleError::package_config( + package.source.path.text, + reason, + )); + } + current = dir.get_parent(); + } + return error; + } + // Execute-only directories can expose files without allowing a listing. + let mut directory = bun_paths::string_paths::without_trailing_slash_windows_path(directory); + loop { + if bun_paths::basename(directory) == b"node_modules" { + return error; + } + let path = package_config_path(directory); + match check_node_package_config_file(&path) { + Ok(PackageConfigProbe::Present(_)) => return error, + Err(reason) => error = Some(crate::NodeModuleError::package_config(&path, reason)), + Ok(PackageConfigProbe::Absent) => {} + } + let Some(parent) = bun_paths::dirname(directory) else { + return error; + }; + if parent == directory { + return error; + } + directory = parent; + } + } + + fn package_json_for_resolution(&self, dir: &DirInfo::DirInfo) -> Option<&'static PackageJSON> { + let pkg = dir.package_json()?; + if self.validate_package_config { + Some(pkg.for_node()) + } else { + pkg.has_bun_metadata().then_some(pkg) + } + } + + fn capture_unreadable_package( + &mut self, + directory: &[u8], + specifier: &[u8], + imports_referrer: Option<&[u8]>, + ) -> bool { + // A failed directory listing must not hide Node 24.21's package read errors. + let path = package_config_path(directory); + let error = match check_node_package_config_file(&path) { + Err(reason) => crate::NodeModuleError::package_config_for_import( + &path, + reason, + specifier, + imports_referrer, + ), + Ok(PackageConfigProbe::Present(errors)) => { + let Some(message) = errors.into_iter().flatten().next() else { + return false; + }; + crate::NodeModuleError::invalid_json(&message) + } + _ => return false, + }; + self.capture_node_module_error(error); + true + } + + /// Set-if-empty: the first Node-shaped failure encountered during a + /// resolve wins (matching Node, which throws at the first failing step). + fn capture_node_module_error(&mut self, err: Box) { + if self.validate_package_config && self.node_module_error.is_none() { + self.node_module_error = Some(err); + } + } + + fn capture_package_map_error(&mut self, package: &PackageJSON, maps: PackageMapRead) -> bool { + if self.validate_package_config { + let [exports, imports] = &package.node_json_errors; + let message = match maps { + PackageMapRead::Exports => exports.as_deref(), + PackageMapRead::Imports => imports.as_deref(), + PackageMapRead::Both => exports.as_deref().or(imports.as_deref()), + }; + if let Some(message) = message { + self.capture_node_module_error(crate::NodeModuleError::invalid_json(message)); + return true; + } + } + false + } + + /// Map a failed `exports`/`imports` `Resolution` to Node's error shape. + fn capture_esm_resolution_failure( + &mut self, + esm_resolution: &crate::package_json::Resolution, + package_json: &PackageJSON, + request: &[u8], + is_imports: bool, + ) { + use crate::NodeModuleError; + use crate::package_json::{ResolutionDetail, Status}; + if !self.validate_package_config || self.node_module_error.is_some() { + return; + } + let pkg_json_path: &[u8] = package_json.source.path.text; + let err = match esm_resolution.status { + // Bun-only intermediate statuses all correspond to Node's + // "not exported" / "not defined" outcomes. + Status::PackagePathNotExported + | Status::PackagePathDisabled + | Status::Null + | Status::Undefined + | Status::UndefinedNoConditionsMatch => { + if is_imports { + NodeModuleError::package_import_not_defined(request, pkg_json_path) + } else { + NodeModuleError::package_path_not_exported(pkg_json_path, request) + } + } + Status::PackageImportNotDefined => { + NodeModuleError::package_import_not_defined(request, pkg_json_path) + } + Status::InvalidPackageTarget => { + let (key, target, bare) = match esm_resolution.detail.as_deref() { + Some(ResolutionDetail::InvalidTarget { + key, + target, + bare_string_target, + }) => (key.as_deref(), target.as_deref(), *bare_string_target), + _ => (None, None, false), + }; + NodeModuleError::invalid_package_target( + pkg_json_path, + key, + target, + is_imports, + bare, + ) + } + Status::InvalidPackageConfiguration => { + let message = match esm_resolution.detail.as_deref() { + Some(ResolutionDetail::ConfigMessage { message }) => Some(&**message), + _ => None, + }; + let mut error = + NodeModuleError::invalid_package_config_structure(pkg_json_path, message); + error.referrer_in_require = is_imports; + error + } + _ => return, + }; + self.node_module_error = Some(err); + } + fn handle_esm_resolution( &mut self, esm_resolution_: crate::package_json::Resolution, @@ -3611,6 +3948,8 @@ impl<'a> Resolver<'a> { kind: ast::ImportKind, package_json: &PackageJSON, package_subpath: &[u8], + is_imports: bool, + is_self_reference: bool, out: &mut MatchResult, ) -> MatchStatus { let mut esm_resolution = esm_resolution_; @@ -3621,6 +3960,17 @@ impl<'a> Resolver<'a> { )) && !esm_resolution.path.is_empty() && esm_resolution.path[0] == SEP) { + self.capture_esm_resolution_failure( + &esm_resolution, + package_json, + package_subpath, + is_imports, + ); + if is_self_reference { + if let Some(error) = self.node_module_error.as_mut() { + error.referrer_in_require = true; + } + } return MatchStatus::NotFound; } @@ -3775,11 +4125,12 @@ impl<'a> Resolver<'a> { } entry_query.entry().abs_path.as_bytes() }; - let module_type = if let Some(pkg) = resolved_dir_info.package_json() { - pkg.module_type - } else { - options::ModuleType::Unknown - }; + let module_type = + if let Some(pkg) = self.package_json_for_resolution(&resolved_dir_info) { + pkg.module_type + } else { + options::ModuleType::Unknown + }; *out = MatchResult { path_pair: PathPair { @@ -3791,8 +4142,7 @@ impl<'a> Resolver<'a> { dir_info: Some(resolved_dir_info), is_node_module: true, package_json: Some( - resolved_dir_info - .package_json() + self.package_json_for_resolution(&resolved_dir_info) .map(std::ptr::from_ref) .unwrap_or_else(|| std::ptr::from_ref(package_json)), ), @@ -3944,7 +4294,7 @@ impl<'a> Resolver<'a> { } query.entry().abs_path.as_bytes() }; - let module_type = if let Some(pkg) = resolved_dir_info.package_json() { + let module_type = if let Some(pkg) = self.package_json_for_resolution(&resolved_dir_info) { pkg.module_type } else { options::ModuleType::Unknown @@ -3960,8 +4310,7 @@ impl<'a> Resolver<'a> { dir_info: Some(resolved_dir_info), is_node_module: true, package_json: Some( - resolved_dir_info - .package_json() + self.package_json_for_resolution(&resolved_dir_info) .map(std::ptr::from_ref) .unwrap_or_else(|| std::ptr::from_ref(package_json)), ), @@ -4974,7 +5323,7 @@ impl<'a> Resolver<'a> { global_cache: GlobalCache, out: &mut MatchResult, ) -> MatchStatus { - let package_json = dir_info.package_json().unwrap(); + let package_json = self.package_json_for_resolution(&dir_info).unwrap(); if let Some(debug) = self.debug_logs.as_mut() { debug.add_note_fmt(format_args!( "Looking for {} in \"imports\" map in {}", @@ -5001,6 +5350,7 @@ impl<'a> Resolver<'a> { // the `ESModule` is constructed as a temporary whose // borrow of `self.debug_logs` ends as soon as `resolve_imports` returns. let esm_resolution = ESModule { + validate_package_config: self.validate_package_config, conditions: match kind { ast::ImportKind::Require | ast::ImportKind::RequireResolve => { &self.opts.conditions.require @@ -5058,7 +5408,9 @@ impl<'a> Resolver<'a> { package_json.source.path.name().dir, kind, package_json, - b"", + import_path, + true, + false, out, ) } @@ -5068,7 +5420,7 @@ impl<'a> Resolver<'a> { dir_info: &DirInfo::DirInfo, input_path_: &[u8], ) -> Option<&'static [u8]> { - let package_json = dir_info.package_json()?; + let package_json = self.package_json_for_resolution(&dir_info)?; let browser_map = &package_json.browser_map; if browser_map.count() == 0 { @@ -5195,7 +5547,7 @@ impl<'a> Resolver<'a> { if self.care_about_browser_field { // Potentially remap using the "browser" field if let Some(browser_scope) = dir_info.get_enclosing_browser_scope() { - if let Some(browser_json) = browser_scope.package_json() { + if let Some(browser_json) = self.package_json_for_resolution(&browser_scope) { if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( &browser_scope, @@ -5230,7 +5582,7 @@ impl<'a> Resolver<'a> { // Is this a file? if let Some(result) = self.load_as_file(field_abs_path, extension_order) { - if let Some(package_json) = dir_info.package_json() { + if let Some(package_json) = self.package_json_for_resolution(&dir_info) { *out = MatchResult { path_pair: PathPair { primary: Fs::Path::init(result.path), @@ -5381,7 +5733,7 @@ impl<'a> Resolver<'a> { .add_note_fmt(format_args!("Found file: \"{}\"", bstr::BStr::new(out_buf))); } - if let Some(package_json) = dir_info.package_json() { + if let Some(package_json) = self.package_json_for_resolution(&dir_info) { *out = MatchResult { path_pair: PathPair { primary: Path::init(out_buf), @@ -5444,7 +5796,7 @@ impl<'a> Resolver<'a> { if let Some(browser_scope) = dir_info.get_enclosing_browser_scope() { const FIELD_REL_PATH: &[u8] = b"index"; - if let Some(browser_json) = browser_scope.package_json() { + if let Some(browser_json) = self.package_json_for_resolution(&browser_scope) { let index_paths = [path, FIELD_REL_PATH]; let index_abs_path = self.fs_ref().abs_buf(&index_paths, bufs!(remap_path)); if let Some(remap) = self @@ -5528,7 +5880,9 @@ impl<'a> Resolver<'a> { if let Ok(Some(package_dir_info)) = self.dir_info_cached( &file.path[0..node_modules_folder_offset + package_name_length as usize], ) { - if let Some(package_json) = package_dir_info.package_json() { + if let Some(package_json) = + self.package_json_for_resolution(&package_dir_info) + { *out = MatchResult { path_pair: PathPair { primary: Path::init(file.path), @@ -5595,7 +5949,19 @@ impl<'a> Resolver<'a> { let mut package_json: Option<*const PackageJSON> = None; // Try using the main field(s) from "package.json" - if let Some(pkg_json) = dir_info.package_json() { + if let Some(pkg_json) = self.package_json_for_resolution(&dir_info) { + if self.validate_package_config { + if let Some(reason) = pkg_json.node_error { + self.capture_node_module_error(crate::NodeModuleError::package_config( + pkg_json.source.path.text, + reason, + )); + dec_ret!(MatchStatus::NotFound); + } + } + if self.capture_package_map_error(pkg_json, PackageMapRead::Both) { + dec_ret!(MatchStatus::NotFound); + } package_json = Some(std::ptr::from_ref(pkg_json)); if pkg_json.main_fields.count() > 0 { let main_field_values = &pkg_json.main_fields; @@ -6233,7 +6599,9 @@ impl<'a> Resolver<'a> { info.package_json_for_browser_field = parent_.package_json_for_browser_field; info.enclosing_tsconfig_json = parent_.enclosing_tsconfig_json; - if let Some(parent_package_json) = parent_.package_json() { + if let Some(parent_package_json) = + parent_.package_json().filter(|pkg| pkg.has_bun_metadata()) + { // https://github.com/oven-sh/bun/issues/229 if !parent_package_json.name.is_empty() || self.care_about_bin_folder { info.enclosing_package_json = Some(parent_package_json); @@ -6372,7 +6740,7 @@ impl<'a> Resolver<'a> { .flatten() }; - if let Some(pkg) = info.package_json() { + if let Some(pkg) = info.package_json().filter(|pkg| pkg.has_bun_metadata()) { if pkg.browser_map.count() > 0 { info.enclosing_browser_scope = result.index; info.package_json_for_browser_field = Some(pkg); @@ -6404,6 +6772,7 @@ impl<'a> Resolver<'a> { info.package_json_for_module_type = info .package_json() + .filter(|pkg| pkg.has_bun_metadata()) .or_else(|| parent.and_then(|parent_| parent_.package_json_for_module_type)); // Record if this directory has a tsconfig.json or jsconfig.json file @@ -6839,3 +7208,48 @@ impl Dirname { &path[0..end_index + 1] } } + +fn package_config_path(directory: &[u8]) -> Vec { + let mut path = directory.to_vec(); + if !path.ends_with(&[SEP]) { + path.push(SEP); + } + path.extend_from_slice(b"package.json"); + path +} + +enum PackageConfigProbe { + Absent, + Present([Option>; 2]), +} + +enum PackageMapRead { + Exports, + Imports, + Both, +} + +/// Native scope lookup ignores map syntax, while package reads materialize both maps. +fn check_node_package_config_file( + path: &[u8], +) -> core::result::Result { + use crate::package_json::PackageConfigError; + match bun_sys::File::read_from(FD::cwd(), path) { + Ok(bytes) => bun_parsers::node_package_json::NodePackageJson::parse(&bytes) + .map(|fields| PackageConfigProbe::Present(fields.json_errors)) + .map_err(|_| PackageConfigError::Invalid), + Err(error) => { + let errno = error.to_zig_err(); + if matches!( + errno, + bun_errno::SystemErrno::ENOENT + | bun_errno::SystemErrno::ENOTDIR + | bun_errno::SystemErrno::EISDIR + ) { + Ok(PackageConfigProbe::Absent) + } else { + Err(PackageConfigError::Read(errno)) + } + } + } +} diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index d5dea22f877e..c145cf359fc7 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1161,6 +1161,23 @@ fn resolve(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult JsResult { + match resolve_with_args::(global, specifier, source, mode)? { + Resolved::Found(value) => Ok(value), + Resolved::NotFound(error) => { + let _protected = error.protected(); + let error = jsc::cpp::Bun__appendRequireParents(global, error, parent)?; + Err(global.throw_value(error)) + } + } +} + // HOST_EXPORT(Bun__resolveSync, c) pub(crate) fn bun_resolve_sync( global: &JSGlobalObject, @@ -1168,6 +1185,7 @@ pub(crate) fn bun_resolve_sync( source: JSValue, is_esm: bool, is_user_require_resolve: bool, + parent: JSValue, ) -> JSValue { let Ok(specifier_str) = specifier.to_bun_string(global) else { return JSValue::ZERO; @@ -1188,11 +1206,12 @@ pub(crate) fn bun_resolve_sync( }; jsc::to_js_host_call(global, || { - do_resolve_with_args::( + resolve_with_parent( global, &specifier_str, &source_str, ResolveMode::from_ffi_bools(is_esm, is_user_require_resolve), + parent, ) }) } @@ -1212,6 +1231,7 @@ pub(crate) fn bun_resolve_sync_with_paths( is_user_require_resolve: bool, paths_ptr: *const BunString, paths_len: usize, + parent: JSValue, ) -> JSValue { let paths: &[BunString] = if paths_len == 0 { &[] @@ -1251,17 +1271,57 @@ pub(crate) fn bun_resolve_sync_with_paths( } jsc::to_js_host_call(global, || { - do_resolve_with_args::( + resolve_with_parent( global, &specifier_str, &source_str, ResolveMode::from_ffi_bools(is_esm, is_user_require_resolve), + parent, ) }) } bun_output::declare_scope!(importMetaResolve, visible); +// HOST_EXPORT(Bun__validateImportMetaPackageConfig, c) +pub(crate) fn bun_validate_import_meta_package_config( + global: &JSGlobalObject, + path: &BunString, +) -> JSValue { + jsc::to_js_host_call(global, || { + let path = path.to_utf8(); + let path_z = bun_core::ZBox::from_bytes(path.slice()); + // Missing files and directories still resolve to URLs without format lookup. + if !matches!( + bun_sys::exists_at_type(bun_sys::Fd::cwd(), &path_z), + Ok(bun_sys::ExistsAtType::File) + ) { + return Ok(JSValue::UNDEFINED); + } + let vm = global.bun_vm_ptr(); + // SAFETY: the resolver belongs to the live VM on its JS thread. + let resolver = unsafe { &raw mut (*vm).transpiler.resolver }; + let mut log = bun_ast::Log::default(); + // SAFETY: the VM and local log outlive the guard; it drops before the log. + let _log_scope = unsafe { + bun_resolver::Resolver::scoped_log(resolver, std::ptr::NonNull::from(&mut log)) + }; + // SAFETY: the live VM resolver is exclusively used on its JS thread. + let resolver = unsafe { &mut *resolver }; + let mut realpath_buffer = bun_paths::path_buffer_pool::get(); + let selected_path = if resolver.opts.preserve_symlinks { + path.slice() + } else { + bun_sys::realpath(&path_z, &mut realpath_buffer).unwrap_or(path.slice()) + }; + if let Some(error) = resolver.node_package_scope_error(selected_path) { + let error = jsc::ResolveMessage::from_node_module_error(global, &error, true, b"", b""); + return Err(global.throw_value(error)); + } + Ok(JSValue::UNDEFINED) + }) +} + // HOST_EXPORT(Bun__resolveSyncWithStrings, c) pub(crate) fn bun_resolve_sync_with_strings( global: &JSGlobalObject, diff --git a/src/runtime/cli/filter_arg.rs b/src/runtime/cli/filter_arg.rs index 5d53f1cd9b0b..0fd0fbf8a0ea 100644 --- a/src/runtime/cli/filter_arg.rs +++ b/src/runtime/cli/filter_arg.rs @@ -190,7 +190,8 @@ pub(crate) fn select_packages( bun_sys::Fd::invalid(), None, IncludeScripts::IncludeScripts, - ) else { + ) + .filter(|pkg| pkg.has_bun_metadata()) else { bun_core::warn!( "Failed to read {}, skipping this workspace package\n", bun_core::fmt::quote(&*package_json_path), diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 5617984baa38..509b58110784 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -412,6 +412,7 @@ unsafe fn init_runtime_state( let t = &mut (*vm).transpiler; t.options.emit_dce_annotations = false; t.resolver.prefer_module_field = false; + t.resolver.validate_package_config = true; // Propagate `--preserve-symlinks` // from CLI args to the resolver so symlinked node_modules // entries resolve via their link path (peer deps stay reachable). @@ -1447,9 +1448,7 @@ mod vm_loader_ctx { // the call — narrows the borrow re-entrant JS could alias. match (*this).transpiler.resolver.read_dir_info(dir) { Ok(Some(dir_info)) => { - dir_info - .package_json() - .or(dir_info.enclosing_package_json) + dir_info.package_json_for_node_scope() .map(core::ptr::from_ref::) } _ => None, @@ -2898,9 +2897,7 @@ fn transpile_source_code_inner( .resolver .read_dir_info(source.path.name().dir) } { - Ok(Some(dir_info)) => { - dir_info.package_json().or(dir_info.enclosing_package_json) - } + Ok(Some(dir_info)) => dir_info.package_json_for_node_scope(), _ => None, } }); @@ -3146,8 +3143,7 @@ fn transpile_source_code_inner( // stable cache slot. match unsafe { (*jsc_vm).transpiler.resolver.read_dir_info(dir) } { Ok(Some(dir_info)) => dir_info - .package_json() - .or(dir_info.enclosing_package_json) + .package_json_for_node_scope() .map(|p| p.module_type), _ => None, } @@ -3988,7 +3984,7 @@ unsafe fn get_loader_and_virtual_source<'a>( // SAFETY: per fn contract — `transpiler.resolver` is a value field of // the VM; `read_dir_info` is re-entrant on the JS thread. match unsafe { (*jsc_vm).transpiler.resolver.read_dir_info(dir) } { - Ok(Some(dir_info)) => dir_info.package_json().or(dir_info.enclosing_package_json), + Ok(Some(dir_info)) => dir_info.package_json_for_node_scope(), _ => None, } } else { @@ -4194,6 +4190,19 @@ pub(crate) unsafe extern "C" fn Bun__transpileFile( } // regex /\.[jt]s$/ if ext.len() == b".ts".len() && (ext == b".js" || ext == b".ts") { + // SAFETY: the resolver belongs to this live VM on its JS thread. + if let Some(error) = unsafe { + (*jsc_vm) + .transpiler + .resolver + .node_package_scope_error(lr.path.text) + } { + *ret = + ErrorableResolvedSource::err(bun_jsc::ResolveMessage::from_node_module_error( + global, &error, false, b"", b"", + )); + return ptr::null_mut(); + } // Use the package.json module type if it exists. break 'brk lr .package_json diff --git a/test/js/bun/resolve/jsonc.test.ts b/test/js/bun/resolve/jsonc.test.ts index fd73f3b7714a..a8e59fce1cf5 100644 --- a/test/js/bun/resolve/jsonc.test.ts +++ b/test/js/bun/resolve/jsonc.test.ts @@ -4,12 +4,12 @@ import { join } from "path"; test.concurrent("empty jsonc - package.json", async () => { await using dir = tempDir("jsonc", { "package.json": ``, - "index.ts": ` + "index.mjs": ` import pkg from './package.json'; if (JSON.stringify(pkg) !== '{}') throw new Error('package.json should be empty'); `, }); - expect(await bunRun(join(dir, "index.ts"))).toSpawn(); + expect(await bunRun(join(dir, "index.mjs"))).toSpawn(); }); test.concurrent("empty jsonc - tsconfig.json", async () => { diff --git a/test/js/bun/resolve/resolve-error.test.ts b/test/js/bun/resolve/resolve-error.test.ts index 978f4947d126..fb79a0404a3a 100644 --- a/test/js/bun/resolve/resolve-error.test.ts +++ b/test/js/bun/resolve/resolve-error.test.ts @@ -378,3 +378,660 @@ it.skipIf(isWindows)( // take well over the default 5s. 120_000, ); + +describe.concurrent("Node 24 package configuration validation", () => { + async function run(files: Record, source: string) { + using dir = tempDir("package-config", { "package.json": "{}", ...files, "driver.mjs": source }); + await using child = Bun.spawn({ + cmd: [bunExe(), "--no-install", "driver.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect({ stdout: stdout.trim().replaceAll(String(dir), ""), stderr, exitCode }).toEqual({ + stdout: "ok", + stderr: "", + exitCode: 0, + }); + } + + it.each(["import", "require"])("defers selected malformed package metadata for %s", async mode => { + await run( + { + "pkg/package.json": JSON.stringify({ imports: { "#selected": { node: "bad", default: "good" } } }), + "pkg/node_modules/bad/package.json": "invalid JSON", + "pkg/node_modules/bad/index.cjs": "module.exports = 42;", + "pkg/node_modules/good/package.json": '{"main":"index.cjs"}', + "pkg/node_modules/good/index.cjs": "module.exports = 7;", + "pkg/entry.mjs": "export const read = () => import('#selected');", + "pkg/entry.cjs": "exports.read = () => require('#selected');", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const entry = ${mode === "import" ? "await import('./pkg/entry.mjs')" : "createRequire(import.meta.url)('./pkg/entry.cjs')"}; + await assert.rejects(async () => entry.read(), { + name: 'Error', code: 'ERR_INVALID_PACKAGE_CONFIG', + message: 'Invalid package config ' + join(process.cwd(), 'pkg/node_modules/bad/package.json') + + ' while importing "bad" from ' + join(process.cwd(), 'pkg/package.json') + '.', + }); + console.log('ok');`, + ); + }); + + it.each([ + "", + " \n", + "{", + "null", + "[]", + "42", + '{"type":null}', + '{"name":42}', + '{"type":null,"type":"commonjs"}', + '{"type":"module","type":null}', + ])("validates a selected scope without poisoning explicit extensions or nested scopes: %j", async contents => { + await run( + { + "bad/package.json": contents, + "bad/value.js": "module.exports = 42;", + "bad/value.cjs": "module.exports = 42;", + "bad/value.mjs": "export default 42;", + "bad/nested/package.json": "{}", + "bad/nested/value.js": "module.exports = 42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const require = createRequire(import.meta.url); + const expected = {name:'Error', code:'ERR_INVALID_PACKAGE_CONFIG', message:'Invalid package config ' + join(process.cwd(),'bad/package.json') + '.'}; + await assert.rejects(import('./bad/value.js'), expected); + assert.throws(() => require('./bad/value.js'), expected); + for (const file of ['./bad/value.cjs','./bad/value.mjs','./bad/nested/value.js']) { + assert.equal((await import(file)).default, 42); + const value = require(file); + assert.equal(typeof value === 'number' ? value : value.default, 42); + } + console.log('ok');`, + ); + }); + + it("validates existing ESM resolve-only scopes while deferring require.resolve", async () => { + await run( + { + "bad/package.json": "invalid JSON", + "bad/value": "", + "bad/.hidden": "", + "bad/value.js": "", + "bad/value.ts": "", + "bad/value.mjs": "", + "bad/value.cjs": "", + "bad/value.mts": "", + "bad/value.cts": "", + "bad/value.jsx": "", + "bad/value.tsx": "", + "bad/value.json": "{}", + "bad/value.wasm": "", + "bad/value.css": "", + "bad/nested/package.json": "{}", + "bad/nested/value.js": "", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + import {pathToFileURL} from 'node:url'; + const require = createRequire(import.meta.url); + const expected = {code:'ERR_INVALID_PACKAGE_CONFIG',message:'Invalid package config '+join(process.cwd(),'bad/package.json')+'.'}; + for(const invalid of ['./bad/value.js','./bad/value.ts','./bad/value','./bad/.hidden']) { + assert.throws(() => import.meta.resolve(invalid), expected); + assert.throws(() => import.meta.resolve(pathToFileURL(join(process.cwd(),invalid)).href), expected); + assert.equal(require.resolve(invalid),join(process.cwd(),invalid)); + await assert.rejects(import(invalid), expected); + } + assert.deepEqual(Object.keys(require('./bad/value')),[]); + for(const file of ['./bad/value.mjs','./bad/value.cjs','./bad/value.mts','./bad/value.cts','./bad/value.jsx','./bad/value.tsx','./bad/value.json','./bad/value.wasm','./bad/value.css','./bad/nested/value.js','./bad/missing.js']) { + assert.equal(import.meta.resolve(file),pathToFileURL(join(process.cwd(),file)).href); + } + console.log('ok');`, + ); + }); + + // Creating file symlinks requires additional privileges on Windows. + it.skipIf(isWindows)("validates the real scope of a resolve-only symlink", async () => { + await run( + { "bad/package.json": "invalid JSON", "bad/value.js": "", "good.js": "" }, + `import assert from 'node:assert/strict'; + import {symlinkSync} from 'node:fs'; + import {join} from 'node:path'; + symlinkSync('bad/value.js','link.js'); + symlinkSync('../good.js','bad/good-link.js'); + assert.throws(() => import.meta.resolve('./link.js'), { + code:'ERR_INVALID_PACKAGE_CONFIG',message:'Invalid package config '+join(process.cwd(),'bad/package.json')+'.', + }); + assert.equal(typeof import.meta.resolve('./bad/good-link.js'),'string'); + console.log('ok');`, + ); + }); + + it("reports the last malformed scope before reaching valid parent metadata", async () => { + await run( + { + "outer/package.json": "invalid JSON", + "outer/inner/package.json": "invalid JSON", + "outer/inner/value.js": "module.exports=42;", + "outer/inner/entry.mjs": "export const read=()=>import('missing-package');", + "outer/inner/entry.cjs": "exports.read=()=>require('missing-package');", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const require=createRequire(import.meta.url); + const expected={code:'ERR_INVALID_PACKAGE_CONFIG',message:'Invalid package config '+join(process.cwd(),'outer/package.json')+'.'}; + const file='./outer/inner/value.js'; + assert.throws(()=>import.meta.resolve(file),expected); + await assert.rejects(import(file),expected); + assert.throws(()=>require(file),expected); + const esm=await import('./outer/inner/entry.mjs'); + const cjs=require('./outer/inner/entry.cjs'); + await assert.rejects(esm.read(),expected); + assert.throws(()=>cjs.read(),expected); + console.log('ok');`, + ); + }); + + it.each([ + "{}", + "\ufeff{}", + '{"main":42}', + '{"type":"unknown"}', + '{"exports":true}', + '{"exports":42}', + '{"imports":42}', + '{"main":"index.js","unknown":undefined}', + '{"main":"index.js","unknown":{"value":tru}}', + '{"main":"index.js","unknown":"\\q"}', + "{}{}", + '{"main":"index.js","unknown":foo/bar}', + '{"main":"index.js","unknown":{/* skipped */ "value":tru}}', + '{"main":"index.js","t\\u0079pe":null}', + ])("accepts metadata Node ignores: %j", async contents => { + await run( + { "node_modules/pkg/package.json": contents, "node_modules/pkg/index.js": "module.exports = 42;" }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const name = 'pkg'; + assert.equal((await import(name)).default, 42); + assert.equal(createRequire(import.meta.url)(name), 42); + console.log('ok');`, + ); + }); + + it.each(["import", "require"])( + "validates exports only when selected by %s, including self references", + async mode => { + await run( + { + "pkg/package.json": '{"name":"pkg","exports":{"0":"./value.cjs","default":"./value.cjs"}}', + "pkg/value.cjs": "module.exports = 42;", + "pkg/entry.mjs": "export const read = () => import('pkg');", + "pkg/entry.cjs": "exports.read = () => require('pkg');", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + import {pathToFileURL} from 'node:url'; + assert.equal((await import('./pkg/value.cjs')).default,42); + const file = './pkg/entry.${mode === "import" ? "mjs" : "cjs"}'; + const entry = ${mode === "import" ? "await import(file)" : "createRequire(import.meta.url)(file)"}; + await assert.rejects(async () => entry.read(), { + name:'Error', code:'ERR_INVALID_PACKAGE_CONFIG', + message:'Invalid package config ' + join(process.cwd(),'pkg/package.json') + ' while importing ' + pathToFileURL(join(process.cwd(),file)).href + '. "exports" cannot contain numeric property keys.', + }); + console.log('ok');`, + ); + }, + ); + + it.each(["0", "1.5", "1e-7", "0.000001", "4294967294.5"])( + "rejects Node's canonical numeric conditions in exports and imports: %s", + async key => { + const target = { [key]: "./unused.cjs", default: "./value.cjs" }; + await run( + { "package.json": JSON.stringify({ name: "pkg", exports: target, imports: { "#selected": target } }) }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const require = createRequire(import.meta.url); + const expected = {code:'ERR_INVALID_PACKAGE_CONFIG',message:'Invalid package config '+join(process.cwd(),'package.json')+' while importing '+import.meta.url+'. "exports" cannot contain numeric property keys.'}; + for(const name of ['pkg','#selected']) { + await assert.rejects(import(name),expected); + assert.throws(() => require(name),expected); + } + console.log('ok');`, + ); + }, + ); + + it.each(["01", "1.0", "1e+0", "-0", "-1", "4294967295", "Infinity", "NaN"])( + "accepts numeric-looking conditions that Node ignores: %s", + async key => { + const target = { [key]: "./unused.cjs", default: "./value.cjs" }; + await run( + { + "package.json": JSON.stringify({ name: "pkg", exports: target, imports: { "#selected": target } }), + "value.cjs": "module.exports=42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const require = createRequire(import.meta.url); + for(const name of ['pkg','#selected']) { + assert.equal((await import(name)).default,42); + assert.equal(require(name),42); + } + console.log('ok');`, + ); + }, + ); + + it("ignores invalid conditional targets until their condition is selected", async () => { + await run( + { + "node_modules/pkg/package.json": '{"exports":{"unused":{"0":"./value.cjs"},"default":"./value.cjs"}}', + "node_modules/pkg/value.cjs": "module.exports = 42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const name = 'pkg'; + assert.equal((await import(name)).default,42); + assert.equal(createRequire(import.meta.url)(name),42); + console.log('ok');`, + ); + }); + + it("continues past invalid array targets and retains the final target error", async () => { + await run( + { + "node_modules/pkg/package.json": '{"exports":["bad","./value.cjs"]}', + "node_modules/pkg/value.cjs": "module.exports = 42;", + "node_modules/invalid/package.json": '{"exports":["bad","worse"]}', + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const require = createRequire(import.meta.url); + const name = 'pkg'; + assert.equal((await import(name)).default,42); + assert.equal(require(name),42); + const invalid = 'invalid'; + const head = 'Invalid "exports" main target "worse" defined in the package config ' + join(process.cwd(),'node_modules/invalid/package.json'); + const tail = '; targets must start with "./"'; + await assert.rejects(import(invalid), {code:'ERR_INVALID_PACKAGE_TARGET', message:head+' imported from '+join(process.cwd(),'driver.mjs')+tail}); + assert.throws(() => require(invalid), {code:'ERR_INVALID_PACKAGE_TARGET',message:head+tail}); + console.log('ok');`, + ); + }); + + it("continues after a URL imports target in an array", async () => { + await run( + { + "package.json": '{"imports":{"#selected":["https://example.invalid/value","./value.cjs"]}}', + "value.cjs": "module.exports = 42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const name = '#selected'; + assert.equal((await import(name)).default,42); + assert.equal(createRequire(import.meta.url)(name),42); + console.log('ok');`, + ); + }); + + it.each([42, false, 1e21, -0])("preserves the final primitive array target: %j", async target => { + await run( + { "node_modules/pkg/package.json": JSON.stringify({ exports: ["bad", target] }) }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const name = 'pkg'; + const head = ${JSON.stringify(`Invalid "exports" main target ${JSON.stringify(String(target))} defined in the package config `)} + join(process.cwd(),'node_modules/pkg/package.json'); + const tail = '; targets must start with "./"'; + await assert.rejects(import(name), {code:'ERR_INVALID_PACKAGE_TARGET',message:head+' imported from '+join(process.cwd(),'driver.mjs')+tail}); + assert.throws(() => createRequire(import.meta.url)(name), {code:'ERR_INVALID_PACKAGE_TARGET',message:head+tail}); + console.log('ok');`, + ); + }); + + it("leaves numeric conditions accepted by the bundler", async () => { + using dir = tempDir("package-config-bundle", { + "package.json": "{}", + "entry.js": "import value from 'pkg'; console.log(value);", + "node_modules/pkg/package.json": '{"exports":{"0":"./unused.js","default":"./value.js"}}', + "node_modules/pkg/value.js": "export default 42;", + }); + const result = await Bun.build({ entrypoints: [path.join(String(dir), "entry.js")], target: "bun", throw: false }); + expect(result.logs).toEqual([]); + expect(result.success).toBe(true); + }); + + it("keeps escaped package fields in the bundler's metadata view", async () => { + using dir = tempDir("package-config-bundler-fields", { + "package.json": "{}", + "entry.js": "import value from 'pkg'; console.log(value);", + "node_modules/pkg/package.json": '{"ma\\u0069n":"value.js"}', + "node_modules/pkg/value.js": "export default 42;", + }); + const result = await Bun.build({ entrypoints: [path.join(String(dir), "entry.js")], target: "bun", throw: false }); + expect(result.logs).toEqual([]); + expect(result.success).toBe(true); + }); + + it.each([ + '{"main":"index.cjs","type":"module","type":"unknown"}', + '{"main":"index.cjs","type":"module","type":"commonjs","type":""}', + '{"main":"index.cjs","type":"unknown","type":"m\\u006fdule","type":"unknown"}', + ])("accepts repeated recognized and ignored type strings: %s", async contents => { + await run( + { + "node_modules/pkg/package.json": contents, + "node_modules/pkg/index.cjs": "module.exports = 42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const name='pkg'; + assert.equal((await import(name)).default,42); + assert.equal(createRequire(import.meta.url)(name),42); + console.log('ok');`, + ); + }); + + // POSIX mode bits do not deny reads to root and do not model Windows ACLs. + it.skipIf(isWindows || process.getuid?.() === 0).each(["file", "directory"])( + "uses Node 24.21's unreadable package policy (%s)", + async target => { + await run( + { + "node_modules/pkg/package.json": '{"main":"main.cjs"}', + "node_modules/pkg/main.cjs": "module.exports = 'selected';", + "node_modules/pkg/index.js": "module.exports = 'fallback';", + }, + `import assert from 'node:assert/strict'; + import {chmodSync} from 'node:fs'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const file = join(process.cwd(),'node_modules/pkg/package.json'); + const protectedPath = ${target === "file" ? "file" : "join(process.cwd(),'node_modules/pkg')"}; + chmodSync(protectedPath,0); + try { + const expected = {name:'Error',code:'ERR_INVALID_PACKAGE_CONFIG',message:'Cannot read package config ' + file + ': permission denied.'}; + const name = 'pkg'; + await assert.rejects(import(name),expected); + assert.throws(() => createRequire(import.meta.url)(name),expected); + } finally { chmodSync(protectedPath,0o700); } + console.log('ok');`, + ); + }, + ); + + // POSIX search permission can allow file access while denying a directory listing. + it.skipIf(isWindows || process.getuid?.() === 0).each(["unreadable", "malformed", "missing", "valid"])( + "checks scopes without directory enumeration (%s)", + async kind => { + await run( + { + "scope/package.json": kind === "unreadable" || kind === "malformed" ? "{}" : "invalid JSON", + "scope/locked/value.js": "", + ...(kind === "missing" ? {} : { "scope/locked/package.json": kind === "malformed" ? "invalid JSON" : "{}" }), + }, + `import assert from 'node:assert/strict'; + import {chmodSync} from 'node:fs'; + import {join} from 'node:path'; + const directory = join(process.cwd(),'scope/locked'); + const manifest = join(directory,'package.json'); + chmodSync(directory,0o111); + try { + if (${JSON.stringify(kind)} === 'unreadable') chmodSync(manifest,0); + const selected = './scope/locked/value.js'; + if (${JSON.stringify(kind)} === 'valid') { + assert.doesNotThrow(() => import.meta.resolve(selected)); + } else { + const message = ${JSON.stringify(kind)} === 'unreadable' + ? 'Cannot read package config '+manifest+': permission denied.' + : 'Invalid package config '+(${JSON.stringify(kind)} === 'missing' ? join(process.cwd(),'scope/package.json') : manifest)+'.'; + assert.throws(() => import.meta.resolve(selected), {code:'ERR_INVALID_PACKAGE_CONFIG',message}); + } + } finally { + chmodSync(directory,0o700); + if (${JSON.stringify(kind)} !== 'missing') chmodSync(manifest,0o600); + } + console.log('ok');`, + ); + }, + ); + + it.each([ + [ + '{"abcdefgh\ud83d\udc38ijkl":tru,"other":false}', + 'Unexpected token \',\', ..."\udc38ijkl":tru,"other":f"... is not valid JSON', + ], + ['{"x":tru}', 'Unexpected token \'}\', "{"x":tru}" is not valid JSON'], + ['{"x":trux}', 'Unexpected token \'x\', "{"x":trux}" is not valid JSON'], + ['{"x":truex}', "Expected ',' or '}' after property value in JSON at position 9 (line 1 column 10)"], + ['{"x":undefined}', 'Unexpected token \'u\', "{"x":undefined}" is not valid JSON'], + ['{"x":NaN}', 'Unexpected token \'N\', "{"x":NaN}" is not valid JSON'], + ['{"x":Infinity}', 'Unexpected token \'I\', "{"x":Infinity}" is not valid JSON'], + ['{"x":-Infinity}', "No number after minus sign in JSON at position 6 (line 1 column 7)"], + ['{"x":01}', "Unexpected number in JSON at position 6 (line 1 column 7)"], + ['{"x":.1}', 'Unexpected token \'.\', "{"x":.1}" is not valid JSON'], + ['{"x":1.}', "Unterminated fractional number in JSON at position 7 (line 1 column 8)"], + ['{"x":1e}', "Exponent part is missing a number in JSON at position 7 (line 1 column 8)"], + ['{"x":1e+}', "Exponent part is missing a number in JSON at position 8 (line 1 column 9)"], + ['{"x":"\\q"}', "Bad escaped character in JSON at position 7 (line 1 column 8)"], + ['{"x":"\\u00xz"}', "Bad Unicode escape in JSON at position 10 (line 1 column 11)"], + ['{"x":[true,]}', 'Unexpected token \']\', "{"x":[true,]}" is not valid JSON'], + ['{"x":{"a":1,}}', "Expected double-quoted property name in JSON at position 12 (line 1 column 13)"], + ['{"x":{a:1}}', "Expected property name or '}' in JSON at position 6 (line 1 column 7)"], + ['{"x":{"a" 1}}', "Expected ':' after property name in JSON at position 10 (line 1 column 11)"], + ['{"x":[1 2]}', "Expected ',' or ']' after array element in JSON at position 8 (line 1 column 9)"], + ['{"x":[1,,2]}', 'Unexpected token \',\', "{"x":[1,,2]}" is not valid JSON'], + ['{"x":{"a":}}', 'Unexpected token \'}\', "{"x":{"a":}}" is not valid JSON'], + ['{"x":/*c*/null}', 'Unexpected token \'/\', "{"x":/*c*/null}" is not valid JSON'], + ['{"x":null //c\n}', "Expected ',' or '}' after property value in JSON at position 10 (line 1 column 11)"], + ['{"x":{"a":tru},"y":false}', 'Unexpected token \'}\', ..."":{"a":tru},"y":fals"... is not valid JSON'], + [ + '{"\ud83d\udc38\ud83d\udc38\ud83d\udc38\ud83d\udc38\ud83d\udc38":tru}', + 'Unexpected token \'}\', "{"\ud83d\udc38\ud83d\udc38\ud83d\udc38\ud83d\udc38\ud83d\udc38":tru}" is not valid JSON', + ], + ['{"abcd\ud83d\udc38efghij":tru}', 'Unexpected token \'}\', "{"abcd\ud83d\udc38efghij":tru}" is not valid JSON'], + [ + '{\r\n "\ud83d\udc38": [1 2]}', + "Expected ',' or ']' after array element in JSON at position 13 (line 2 column 11)", + ], + ["[object Object]", '"[object Object]" is not valid JSON'], + ["{", "Expected property name or '}' in JSON at position 1 (line 1 column 2)"], + ["[1", "Expected ',' or ']' after array element in JSON at position 2 (line 1 column 3)"], + ["[true 42]", "Expected ',' or ']' after array element in JSON at position 6 (line 1 column 7)"], + ["[fals1]", "Unexpected number in JSON at position 5 (line 1 column 6)"], + ['[tru"e"]', "Unexpected string in JSON at position 4 (line 1 column 5)"], + ["[true] []", "Unexpected non-whitespace character after JSON at position 7 (line 1 column 8)"], + ])("materializes both package maps with Node JSON diagnostics: %j", async (value, message) => { + for (const field of ["exports", "imports"]) { + await run( + { + "node_modules/pkg/package.json": JSON.stringify({ + main: "value.cjs", + exports: "./value.cjs", + [field]: value, + }), + "node_modules/pkg/value.cjs": "module.exports=42;", + "node_modules/pkg/value.js": "module.exports=42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const require=createRequire(import.meta.url); + assert.equal((await import('./node_modules/pkg/value.cjs')).default,42); + assert.equal(require('./node_modules/pkg/value.js'),42); + const check=error=>{assert.equal(error.name,'SyntaxError');assert.equal(error.code,undefined);assert.equal(error.message,${JSON.stringify(message)});return true;}; + const name='pkg'; + await assert.rejects(import(name),check); + assert.throws(()=>require(name),check); + console.log('ok');`, + ); + } + }); + + it("materializes unused raw maps and gives exports JSON errors precedence", async () => { + const messages = [ + ["pkg", 'Unexpected token \'}\', "{"x":tru}" is not valid JSON'], + ["both", "Unexpected token ']', \"[tru]\" is not valid JSON"], + ]; + await run( + { + "node_modules/pkg/package.json": '{"exports":"./value.cjs","imports":{"x":tru}}', + "node_modules/pkg/value.cjs": "module.exports=42;", + "node_modules/both/package.json": JSON.stringify({ imports: '{"x":tru}', exports: "[tru]" }), + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const require=createRequire(import.meta.url); + for(const [name,message] of ${JSON.stringify(messages)}) { + await assert.rejects(import(name),{name:'SyntaxError',message}); + assert.throws(()=>require(name),{name:'SyntaxError',message}); + } + console.log('ok');`, + ); + }); + + it("accepts JSON-encoded maps in string fields like Node", async () => { + await run( + { + "package.json": JSON.stringify({ + name: "pkg", + exports: JSON.stringify(["./value.cjs"]), + imports: JSON.stringify({ "#value": "./value.cjs" }), + }), + "value.cjs": "module.exports=42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const require=createRequire(import.meta.url); + for(const name of ['pkg','#value']) { + assert.equal((await import(name)).default,42); + assert.equal(require(name),42); + } + console.log('ok');`, + ); + }); + + it.each(["\ud800", "\udfff", "\u000b", "\u2028", "\u2029", "\ufeff", "\\uD800"])( + "uses JSON.stringify escaping in invalid target diagnostics: %j", + async target => { + await run( + { "node_modules/pkg/package.json": JSON.stringify({ exports: [target] }) }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const require=createRequire(import.meta.url); + const name='pkg'; + const head=${JSON.stringify(`Invalid "exports" main target ${JSON.stringify(target)} defined in the package config `)}+join(process.cwd(),'node_modules/pkg/package.json'); + const tail='; targets must start with "./"'; + await assert.rejects(import(name),{code:'ERR_INVALID_PACKAGE_TARGET',message:head+' imported from '+join(process.cwd(),'driver.mjs')+tail}); + assert.throws(()=>require(name),{code:'ERR_INVALID_PACKAGE_TARGET',message:head+tail}); + console.log('ok');`, + ); + }, + ); + + it.each([ + { + "label": "self-unused-imports", + "metadata": { "name": "pkg", "exports": "./value.cjs", "imports": "[tru]" }, + "specifier": "pkg", + "esm": "Unexpected token ']', \"[tru]\" is not valid JSON", + "cjs": null, + }, + { + "label": "external-unused-imports", + "metadata": { "name": "pkg", "exports": "./value.cjs", "imports": "[tru]" }, + "specifier": "external", + "esm": "Unexpected token ']', \"[tru]\" is not valid JSON", + "cjs": null, + }, + { + "label": "imports-both-invalid", + "metadata": { "name": "pkg", "exports": "[tru]", "imports": '{"x":tru}' }, + "specifier": "#selected", + "esm": "Unexpected token ']', \"[tru]\" is not valid JSON", + "cjs": 'Unexpected token \'}\', "{"x":tru}" is not valid JSON', + }, + { + "label": "imports-valid-exports-invalid", + "metadata": { "name": "pkg", "exports": "[tru]", "imports": { "#selected": "./value.cjs" } }, + "specifier": "#selected", + "esm": "Unexpected token ']', \"[tru]\" is not valid JSON", + "cjs": "Unexpected token ']', \"[tru]\" is not valid JSON", + }, + { + "label": "external-exports-invalid", + "metadata": { "name": "pkg", "exports": "[tru]", "imports": '{"x":tru}' }, + "specifier": "external", + "esm": "Unexpected token ']', \"[tru]\" is not valid JSON", + "cjs": "Unexpected token ']', \"[tru]\" is not valid JSON", + }, + { + "label": "nameless-exports-invalid", + "metadata": { "exports": "[tru]" }, + "specifier": "external", + "esm": "Unexpected token ']', \"[tru]\" is not valid JSON", + "cjs": "Unexpected token ']', \"[tru]\" is not valid JSON", + }, + { + "label": "imports-absent-exports-invalid", + "metadata": { "name": "pkg", "exports": "[tru]" }, + "specifier": "#selected", + "esm": "Unexpected token ']', \"[tru]\" is not valid JSON", + "cjs": "Unexpected token ']', \"[tru]\" is not valid JSON", + }, + ])("preserves CommonJS map getter ordering: $label", async ({ metadata, specifier, esm, cjs }) => { + await run( + { + "package.json": JSON.stringify(metadata), + "value.cjs": "module.exports=42;", + "node_modules/external/package.json": '{"main":"index.cjs"}', + "node_modules/external/index.cjs": "module.exports=42;", + }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const require=createRequire(import.meta.url); + const name=${JSON.stringify(specifier)}; + await assert.rejects(import(name),{name:'SyntaxError',message:${JSON.stringify(esm)}}); + const message=${JSON.stringify(cjs)}; + if(message===null) { + assert.equal(require(name),42); + assert.equal(typeof require.resolve(name),'string'); + } else { + assert.throws(()=>require(name),{name:'SyntaxError',message}); + assert.throws(()=>require.resolve(name),{name:'SyntaxError',message}); + } + console.log('ok');`, + ); + }); + + it("retains CommonJS parents in missing-package diagnostics", async () => { + await run( + { "entry.cjs": "exports.read = () => require('missing-package');" }, + `import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + import {join} from 'node:path'; + const require = createRequire(import.meta.url); + const entry = require('./entry.cjs'); + const stack = [join(process.cwd(),'entry.cjs'), join(process.cwd(),'driver.mjs')]; + assert.throws(() => entry.read(), { + code:'MODULE_NOT_FOUND', requireStack:stack, + message:"Cannot find module 'missing-package'\\nRequire stack:\\n- " + stack.join('\\n- '), + }); + console.log('ok');`, + ); + }); +}); From 29c71e8cd01f5cba9122afe91b0f1d9a1de94ccd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Oct 2026 21:32:57 -0700 Subject: [PATCH 2/5] refactor(resolve): make package map resolution context explicit --- src/resolver/resolver.rs | 31 ++++++++++++++++++------------- src/runtime/api/BunObject.rs | 2 +- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 3a2bc76e48ae..2cf5eead6240 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2804,8 +2804,7 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, - false, - is_self_reference, + PackageMapContext::Exports { is_self_reference }, out, ) .is_success() @@ -2864,8 +2863,7 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, - false, - is_self_reference, + PackageMapContext::Exports { is_self_reference }, out, ) .is_success() @@ -3323,8 +3321,7 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, - false, - is_self_reference, + PackageMapContext::Exports { is_self_reference }, out, ) .is_success() @@ -3367,8 +3364,7 @@ impl<'a> Resolver<'a> { kind, package_json, esm.subpath, - false, - is_self_reference, + PackageMapContext::Exports { is_self_reference }, out, ) .is_success() @@ -3948,10 +3944,13 @@ impl<'a> Resolver<'a> { kind: ast::ImportKind, package_json: &PackageJSON, package_subpath: &[u8], - is_imports: bool, - is_self_reference: bool, + context: PackageMapContext, out: &mut MatchResult, ) -> MatchStatus { + let (is_imports, is_self_reference) = match context { + PackageMapContext::Imports => (true, false), + PackageMapContext::Exports { is_self_reference } => (false, is_self_reference), + }; let mut esm_resolution = esm_resolution_; use crate::package_json::Status; if !((matches!( @@ -5409,8 +5408,7 @@ impl<'a> Resolver<'a> { kind, package_json, import_path, - true, - false, + PackageMapContext::Imports, out, ) } @@ -5420,7 +5418,7 @@ impl<'a> Resolver<'a> { dir_info: &DirInfo::DirInfo, input_path_: &[u8], ) -> Option<&'static [u8]> { - let package_json = self.package_json_for_resolution(&dir_info)?; + let package_json = self.package_json_for_resolution(dir_info)?; let browser_map = &package_json.browser_map; if browser_map.count() == 0 { @@ -7223,6 +7221,13 @@ enum PackageConfigProbe { Present([Option>; 2]), } +#[derive(Clone, Copy)] +enum PackageMapContext { + Imports, + Exports { is_self_reference: bool }, +} + +#[derive(Clone, Copy)] enum PackageMapRead { Exports, Imports, diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index c145cf359fc7..a3354446ce3d 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1312,7 +1312,7 @@ pub(crate) fn bun_validate_import_meta_package_config( let selected_path = if resolver.opts.preserve_symlinks { path.slice() } else { - bun_sys::realpath(&path_z, &mut realpath_buffer).unwrap_or(path.slice()) + bun_sys::realpath(&path_z, &mut realpath_buffer).unwrap_or_else(|_| path.slice()) }; if let Some(error) = resolver.node_package_scope_error(selected_path) { let error = jsc::ResolveMessage::from_node_module_error(global, &error, true, b"", b""); From 84026a67232615a8dd6a1ff81a135d70f9d484a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Oct 2026 23:32:35 -0700 Subject: [PATCH 3/5] fix(resolve): allow Bun built-ins in package imports Permit exact bun: targets recognized by the canonical built-in registry only for package imports. Preserve Node 24.21 validation for unknown names, other URL schemes, and exports targets. Cover import, require and require.resolve with positive and negative regressions and document the exception. --- docs/runtime/nodejs-compat.mdx | 2 + src/resolver/package_json.rs | 6 ++ test/js/bun/resolve/resolve.test.ts | 98 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 986df3933a29..559cc7d3db88 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -121,6 +121,8 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:module`](https://nodejs.org/api/module.html) +Package `imports` targets may name a recognized Bun built-in such as `bun:test` or `bun:sqlite`. This Bun-specific exception does not allow unknown `bun:` names, other URL schemes (including `node:`), or built-in targets in `exports`; those keep Node's `ERR_INVALID_PACKAGE_TARGET` validation. + Runtime package resolution follows Node 24.21's `package.json` validation. Failures in the metadata reader, non-string `name` or `type` fields, and invalid exports condition objects throw `ERR_INVALID_PACKAGE_CONFIG` with the package path and Node's resolution context. Unselected metadata and explicit `.mjs`/`.cjs` formats remain deferred, and values Node ignores are not rejected. Reading a selected dependency package materializes both `exports` and `imports`. Invalid JSON in either map throws `SyntaxError` with Node's JSON diagnostic. CommonJS self-reference lookup reads only `exports`; CommonJS `#imports` reads `imports` before entering ESM scope resolution. Format-only scope lookups defer map errors. String fields beginning with `{` or `[` are parsed as JSON maps, matching Node's package reader. diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index 313c76de6a7a..adec197f3c47 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -2158,6 +2158,12 @@ impl<'a> ESModule<'a> { && !strings::has_prefix(str, b"../") && !strings::has_prefix(str, b"/") && (!self.validate_package_config + || (str.starts_with(b"bun:") + && bun_resolve_builtins::Alias::has( + str, + bun_ast::Target::Bun, + Default::default(), + )) || bun_url::whatwg::Parsed::from_utf8(str).is_none()) { if PATTERN { diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index bc89dd83f6b0..3a95110da484 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -314,6 +314,104 @@ it("import override to bun:test", async () => { expect(await import("#bun_test")).toBeDefined(); }); +describe("package imports Bun built-in targets", () => { + test.concurrent.each(["bun:test", "bun:sqlite", "bun:jsc"])("loads the recognized target %s", async target => { + using dir = tempDir("imports-bun-builtin", { + "package.json": JSON.stringify({ imports: { "#builtin": target } }), + "entry.mjs": ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const imported = await import("#builtin"); + const direct = await import(${JSON.stringify(target)}); + console.log(JSON.stringify({ + imported: imported === direct, + required: require("#builtin") === require(${JSON.stringify(target)}), + resolved: require.resolve("#builtin") === ${JSON.stringify(target)}, + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: '{"imported":true,"required":true,"resolved":true}\n', + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent.each([ + "bun:not-a-builtin", + "bun:test?query", + "bun:test#fragment", + "bun:sqlite/extra", + "BUN:test", + "node:fs", + "https://example.invalid/module.js", + "file:///not-a-package-target.mjs", + "data:text/javascript,export default 1", + ])("rejects the URL-shaped imports target %s", async target => { + using dir = tempDir("imports-invalid-url-target", { + "package.json": JSON.stringify({ imports: { "#target": target } }), + "entry.mjs": ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const results = []; + for (const load of [() => import("#target"), () => require("#target"), () => require.resolve("#target")]) { + try { await load(); results.push("loaded"); } catch (error) { results.push(error.code); } + } + console.log(JSON.stringify(results)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: JSON.stringify(Array(3).fill("ERR_INVALID_PACKAGE_TARGET")) + "\n", + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("does not allow a Bun built-in as an exports target", async () => { + using dir = tempDir("exports-bun-builtin-invalid", { + "node_modules/pkg/package.json": JSON.stringify({ name: "pkg", exports: "bun:test" }), + "entry.mjs": ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const results = []; + for (const load of [() => import("pkg"), () => require("pkg")]) { + try { await load(); results.push("loaded"); } catch (error) { results.push(error.code); } + } + console.log(JSON.stringify(results)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: '["ERR_INVALID_PACKAGE_TARGET","ERR_INVALID_PACKAGE_TARGET"]\n', + stderr: "", + exitCode: 0, + }); + }); +}); + it.if(isWindows)("directory cache key computation", () => { expect(import(`${process.cwd()}\\\\doesnotexist.ts`)).rejects.toThrow(); expect(import(`${process.cwd()}\\\\\\doesnotexist.ts`)).rejects.toThrow(); From 9ea7bd0aff0c4e04f82b017b3e6b2a802a6af286 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Oct 2026 04:11:44 -0700 Subject: [PATCH 4/5] fix(resolve): preserve Node package-import array semantics --- docs/runtime/nodejs-compat.mdx | 2 +- src/resolver/package_json.rs | 21 ++++++-- test/js/bun/resolve/resolve.test.ts | 83 +++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 559cc7d3db88..079d8094831b 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -121,7 +121,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:module`](https://nodejs.org/api/module.html) -Package `imports` targets may name a recognized Bun built-in such as `bun:test` or `bun:sqlite`. This Bun-specific exception does not allow unknown `bun:` names, other URL schemes (including `node:`), or built-in targets in `exports`; those keep Node's `ERR_INVALID_PACKAGE_TARGET` validation. +Scalar string targets in package `imports` may name a recognized Bun built-in such as `bun:test` or `bun:sqlite`. Inside fallback arrays, including nested arrays and conditional targets within an array, `bun:` URLs remain invalid package targets under Node 24.21 rules, so the next alternative is tried. For example, `["bun:sqlite", "./fallback.cjs"]` selects `./fallback.cjs`. A valid relative target that names a missing file still produces a missing-module error; arrays do not retry after filesystem resolution fails. The Bun-specific scalar exception does not allow unknown `bun:` names, other URL schemes (including `node:`), or built-in targets in `exports`; those keep Node's `ERR_INVALID_PACKAGE_TARGET` validation. Runtime package resolution follows Node 24.21's `package.json` validation. Failures in the metadata reader, non-string `name` or `type` fields, and invalid exports condition objects throw `ERR_INVALID_PACKAGE_CONFIG` with the package path and Node's resolution context. Unselected metadata and explicit `.mjs`/`.cjs` formats remain deferred, and values Node ignores are not rejected. diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index adec197f3c47..a180f3ad984e 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -1859,7 +1859,8 @@ impl<'a> ESModule<'a> { if let Some(main_export) = main_export { if !matches!(main_export.data, EntryData::Null) { - let result = self.resolve_target::(package_url, main_export, b"", false); + let result = + self.resolve_target::(package_url, main_export, b"", false, false); if result.status != Status::Null && result.status != Status::Undefined { return Self::attach_failure_key(result, b"."); } @@ -1913,7 +1914,8 @@ impl<'a> ESModule<'a> { log.add_note_fmt(format_args!("Found \"{}\"", bstr::BStr::new(match_key))); } - let result = self.resolve_target::(package_url, target, b"", is_imports); + let result = + self.resolve_target::(package_url, target, b"", is_imports, false); return Self::attach_failure_key(result, match_key); } } @@ -1955,6 +1957,7 @@ impl<'a> ESModule<'a> { target, subpath, is_imports, + false, ); return Self::attach_failure_key(result, &expansion.key); } @@ -1973,7 +1976,13 @@ impl<'a> ESModule<'a> { )); } let mut result = Self::attach_failure_key( - self.resolve_target::(package_url, target, subpath, is_imports), + self.resolve_target::( + package_url, + target, + subpath, + is_imports, + false, + ), &expansion.key, ); if result.status == Status::Exact @@ -2036,6 +2045,7 @@ impl<'a> ESModule<'a> { target: &Entry, subpath: &[u8], internal: bool, + in_array: bool, ) -> Resolution { match &target.data { EntryData::String(str) => { @@ -2158,7 +2168,8 @@ impl<'a> ESModule<'a> { && !strings::has_prefix(str, b"../") && !strings::has_prefix(str, b"/") && (!self.validate_package_config - || (str.starts_with(b"bun:") + || (!in_array + && str.starts_with(b"bun:") && bun_resolve_builtins::Alias::has( str, bun_ast::Target::Bun, @@ -2363,6 +2374,7 @@ impl<'a> ESModule<'a> { &entry.value, subpath, internal, + in_array, ); if result.status.is_undefined() { continue; @@ -2414,6 +2426,7 @@ impl<'a> ESModule<'a> { target_value, subpath, internal, + true, ); if result.status == Status::InvalidPackageTarget || result.status == Status::Null diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 3a95110da484..678ce74b2f87 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -345,6 +345,89 @@ describe("package imports Bun built-in targets", () => { }); }); + const arrayTargets: { name: string; target: unknown; expected: string[]; key?: string; specifier?: string }[] = [ + { + name: "recognized builtin before fallback", + target: ["bun:sqlite", "./fallback.cjs"], + expected: Array(3).fill("fallback"), + }, + { + name: "unknown builtin before fallback", + target: ["bun:not-a-builtin", "./fallback.cjs"], + expected: Array(3).fill("fallback"), + }, + { name: "nested array", target: [["bun:sqlite"], "./fallback.cjs"], expected: Array(3).fill("fallback") }, + { + name: "condition inside array", + target: [{ default: "bun:sqlite" }, "./fallback.cjs"], + expected: Array(3).fill("fallback"), + }, + { + name: "array inside condition", + target: { default: ["bun:sqlite", "./fallback.cjs"] }, + expected: Array(3).fill("fallback"), + }, + { + name: "pattern array", + key: "#target/*", + specifier: "#target/fallback", + target: ["bun:sqlite", "./*.cjs"], + expected: Array(3).fill("fallback"), + }, + { name: "builtin only", target: ["bun:sqlite"], expected: Array(3).fill("ERR_INVALID_PACKAGE_TARGET") }, + { + name: "invalid target before builtin", + target: ["../invalid.cjs", "bun:sqlite"], + expected: Array(3).fill("ERR_INVALID_PACKAGE_TARGET"), + }, + { + name: "nested invalid target before conditional builtin", + target: [["../invalid.cjs"], { default: "bun:sqlite" }], + expected: Array(3).fill("ERR_INVALID_PACKAGE_TARGET"), + }, + { + name: "missing file before builtin", + target: ["./missing.cjs", "bun:sqlite"], + expected: ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND", "MODULE_NOT_FOUND"], + }, + ]; + test.concurrent.each(arrayTargets)( + "keeps Node array-target semantics: $name", + async ({ target, expected, key = "#target", specifier = "#target" }) => { + using dir = tempDir("imports-bun-array", { + "package.json": JSON.stringify({ imports: { [key]: target } }), + "fallback.cjs": "exports.Database = 'fallback';", + "entry.mjs": ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const specifier = ${JSON.stringify(specifier)}; + const results = []; + for (const load of [ + async () => (await import(specifier)).Database, + () => require(specifier).Database, + () => require.resolve(specifier).endsWith("fallback.cjs") ? "fallback" : "wrong-target", + ]) { + try { results.push(await load()); } catch (error) { results.push(error.code); } + } + console.log(JSON.stringify(results)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: JSON.stringify(expected) + "\n", + stderr: "", + exitCode: 0, + }); + }, + ); + test.concurrent.each([ "bun:not-a-builtin", "bun:test?query", From f6d56a01d83895fd8fcf147abfdcf8bdaf83178c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Oct 2026 04:20:08 -0700 Subject: [PATCH 5/5] fix(resolve): defer inline entry package scope validation --- docs/runtime/nodejs-compat.mdx | 2 + src/jsc/VirtualMachine.rs | 6 +++ src/resolver/package_json.rs | 9 ++++- src/resolver/resolver.rs | 14 +++++++ src/runtime/cli/bunx_command.rs | 1 + src/runtime/cli/filter_run.rs | 1 + src/runtime/cli/multi_run.rs | 1 + src/runtime/cli/pack_command.rs | 1 + src/runtime/cli/run_command.rs | 4 ++ test/cli/run/run-eval.test.ts | 68 +++++++++++++++++++++++++++++++++ 10 files changed, 106 insertions(+), 1 deletion(-) diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 079d8094831b..e8e07c7cc526 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -125,6 +125,8 @@ Scalar string targets in package `imports` may name a recognized Bun built-in su Runtime package resolution follows Node 24.21's `package.json` validation. Failures in the metadata reader, non-string `name` or `type` fields, and invalid exports condition objects throw `ERR_INVALID_PACKAGE_CONFIG` with the package path and Node's resolution context. Unselected metadata and explicit `.mjs`/`.cjs` formats remain deferred, and values Node ignores are not rejected. +Stdin and eval scripts report malformed ancestor package metadata only when an import or `require()` call needs that scope. + Reading a selected dependency package materializes both `exports` and `imports`. Invalid JSON in either map throws `SyntaxError` with Node's JSON diagnostic. CommonJS self-reference lookup reads only `exports`; CommonJS `#imports` reads `imports` before entering ESM scope resolution. Format-only scope lookups defer map errors. String fields beginning with `{` or `[` are parsed as JSON maps, matching Node's package reader. Unreadable selected package metadata also throws `ERR_INVALID_PACKAGE_CONFIG`, rather than falling through to an index file. This deliberately follows Node 24.21; Node 24.19 treated these read failures as absent metadata. Missing files, non-directory path components, and a `package.json` directory remain absence cases. diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 55af0fb73dc0..d2a960f143b6 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5407,6 +5407,12 @@ impl VirtualMachine { && mode.is_esm() && jsc_vm.transpiler.resolver.node_module_error.is_none() && bun_paths::is_absolute(result.path) + // Inline entries do not infer their module kind from a package scope. + && !jsc_vm + .module_loader + .eval_source + .as_ref() + .is_some_and(|source| source.path.text == result.path) { jsc_vm.transpiler.resolver.node_module_error = jsc_vm .transpiler diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index a180f3ad984e..cf431e5a44d1 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -593,7 +593,14 @@ impl PackageJSON { .map(|fields| fields.json_errors.clone()) .unwrap_or_default(); - let parsed_json = match r.caches.json.parse_package_json(r_log, &json_source) { + // Runtime resolution reports cached Node errors only when the scope is used. + let mut deferred_log = bun_ast::Log::default(); + let parse_log = if r.validate_package_config { + &mut deferred_log + } else { + &mut *r_log + }; + let parsed_json = match r.caches.json.parse_package_json(parse_log, &json_source) { Ok(Some(v)) => v, Ok(None) => { return Some(Self::from_node_fields( diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 2cf5eead6240..5d2e2e8f178f 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1727,6 +1727,20 @@ impl<'a> Resolver<'a> { ) -> ResultUnion { debug_assert!(bun_paths::is_absolute(source_dir)); + // Node's CJS self lookup reads the parent scope even for relative requests. + // https://github.com/nodejs/node/blob/v24.21.0/lib/internal/modules/cjs/loader.js#L659-L664 + if self.validate_package_config + && matches!( + kind, + ast::ImportKind::Require | ast::ImportKind::RequireResolve + ) + { + if let Some(error) = self.node_package_scope_error_for_directory(source_dir) { + self.capture_node_module_error(error); + return ResultUnion::NotFound; + } + } + let mut import_path = input_import_path; // This implements the module resolution algorithm from node.js, which is diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 42af649bb36a..16b2c39bdc38 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -752,6 +752,7 @@ impl BunxCommand { ConfigureEnvOptions { log_errors: true, store_root_fd: true, + defer_package_errors: false, }, )?; // SAFETY: `configure_env_for_run` returned `Ok`, so the slot is fully diff --git a/src/runtime/cli/filter_run.rs b/src/runtime/cli/filter_run.rs index fcc10802e940..1dd5d40b9ad5 100644 --- a/src/runtime/cli/filter_run.rs +++ b/src/runtime/cli/filter_run.rs @@ -813,6 +813,7 @@ pub(crate) fn run_scripts_with_filter( ConfigureEnvOptions { log_errors: true, store_root_fd: false, + defer_package_errors: false, }, )?; // SAFETY: configure_env_for_run fully initializes the out-param on Ok. diff --git a/src/runtime/cli/multi_run.rs b/src/runtime/cli/multi_run.rs index 83faeab5d470..e307823ed9cb 100644 --- a/src/runtime/cli/multi_run.rs +++ b/src/runtime/cli/multi_run.rs @@ -884,6 +884,7 @@ pub(crate) fn run(ctx: &mut Command::ContextData) -> Result( ConfigureEnvOptions { log_errors: ctx.manager.options.log_level != LogLevel::Silent, store_root_fd: false, + defer_package_errors: false, }, ) { if matches!(err, crate::Error::Alloc(_)) { diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 7cca76d9e9c7..b49df16e1904 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -92,6 +92,8 @@ pub(crate) struct ConfigureEnvOptions { /// for callers that go on to read files through it, like `bunx` resolving /// a package's `bin`. pub(crate) store_root_fd: bool, + /// Leave inline-source package errors for runtime resolution to report. + pub(crate) defer_package_errors: bool, } pub(crate) struct RunCommand; @@ -604,6 +606,7 @@ Full documentation is available at https://bun.com/docs/cli/run this_transpiler.resolver.care_about_bin_folder = true; this_transpiler.resolver.care_about_scripts = true; this_transpiler.resolver.store_fd = opts.store_root_fd; + this_transpiler.resolver.validate_package_config = opts.defer_package_errors; // Bundler-linker + JSX-runtime config: only callers that actually // transpile through this `Transpiler` need it. `configure_linker`'s @@ -2349,6 +2352,7 @@ impl RunCommand { ConfigureEnvOptions { log_errors, store_root_fd: false, + defer_package_errors: target_name == b"-", }, )?; // SAFETY: `configure_env_for_run_without_linker` returned `Ok`, so the diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0c7462e3b5ec..5660ed610931 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -5,6 +5,74 @@ import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import { tmpdir } from "os"; import { join, sep } from "path"; +for (const asNode of [false, true]) { + for (const entry of asNode ? ["eval"] : ["stdin", "eval", "run stdin"]) { + test.concurrent.each([ + { name: "plain script", source: 'console.log("executed")', stdout: "executed\n" }, + { + name: "builtin require", + source: 'console.log(require("node:path").basename("/a/b"))', + stdout: "b\n", + }, + { + name: "builtin import", + source: 'import { basename } from "node:path"; console.log(basename("/a/b"))', + stdout: "b\n", + }, + { + name: "script-owned JSON rejection", + source: + 'try { JSON.parse(require("node:fs").readFileSync("../package.json", "utf8")) } catch { console.log("invalid JSON from script"); process.exitCode = 17 }', + stdout: "invalid JSON from script\n", + exitCode: 17, + }, + { + name: "require validates its scope", + source: 'console.log("executed"); try { require("./value.cjs") } catch (e) { console.log(e.code) }', + stdout: "executed\nERR_INVALID_PACKAGE_CONFIG\n", + }, + { + name: "dynamic import validates a js scope", + source: 'console.log("executed"); import("./value.js").catch(e => console.log(e.code))', + stdout: "executed\nERR_INVALID_PACKAGE_CONFIG\n", + }, + { + name: "dynamic import does not need a cjs scope", + source: 'console.log("executed"); import("./value.cjs").then(m => console.log(m.default))', + stdout: "executed\n7\n", + }, + { + name: "nearer valid scope shields the ancestor", + source: 'console.log("executed"); import("./scoped/value.js").then(m => console.log(m.default))', + stdout: "executed\n9\n", + }, + ])(`inline entry ${entry}, node alias = ${asNode}: $name`, async ({ source, stdout, exitCode = 0 }) => { + using dir = tempDir("inline-package-scope-", { + "package.json": "{", + "nested/value.cjs": "module.exports = 7", + "nested/value.js": "module.exports = 8", + "nested/scoped/package.json": "{}", + "nested/scoped/value.js": "module.exports = 9", + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...(entry === "eval" ? ["-e", source] : entry === "stdin" ? ["-"] : ["run", "-"])], + ...(asNode ? { argv0: "node" } : {}), + cwd: join(String(dir), "nested"), + env: bunEnv, + stdin: entry === "eval" ? "ignore" : Buffer.from(source), + stdout: "pipe", + stderr: "pipe", + }); + const [actualStdout, stderr, actualExitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect({ stdout: actualStdout, stderr, exitCode: actualExitCode }).toEqual({ stdout, stderr: "", exitCode }); + }); + } +} + for (const flag of ["-e", "--print"]) { describe(`bun ${flag}`, () => { test("it works", async () => {