From 7f35ffa3ee3b828bfcc1daaa5d1f2593725fd276 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:33:46 +0000 Subject: [PATCH 1/9] resolver: report candidate paths that do not fit a path buffer as not found instead of aborting Every candidate path the resolver builds from config text (package.json main, module and browser values, tsconfig.json extends, baseUrl and paths targets), from a specifier, or from a real path behind a symlink now goes through FileSystem::abs_buf_checked, which also leaves room for a NUL terminator. A candidate that does not fit resolves as not found. load_as_file and load_extension bound the path they copy into their buffer. DirEntry::add_entry_with_store leaves out an entry whose dir/name does not fit a path buffer, so every path derived from a directory listing fits. That covers the FileSystemRouter scan and reload, PackageJSON::parse, the index and tsconfig.json lookups, and wildcard exports without changing them. A package name that does not fit while the full specifier does falls through to the plain node_modules lookup like a missing package, so /../pkg resolves to pkg as in node. The standalone graph join and bust_dir_cache_from_specifier use checked joins; the unchecked join_abs_string_buf and join_abs shims and abs_alloc are gone. --- src/resolver/fs.rs | 6 + src/resolver/lib.rs | 21 +- src/resolver/resolver.rs | 203 ++++++----- src/runtime/node/node_fs.rs | 3 +- test/bundler/bundler_compile.test.ts | 37 ++ test/harness.ts | 5 + .../fixtures/deep-directory-fixture.cjs | 59 ++++ test/js/bun/resolve/resolve-error.test.ts | 334 +++++++++++++++++- test/js/bun/util/filesystem_router.test.ts | 88 ++++- 9 files changed, 658 insertions(+), 98 deletions(-) create mode 100644 test/js/bun/resolve/fixtures/deep-directory-fixture.cjs diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index 53902579e5f9..e6894e2afb51 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -437,6 +437,12 @@ impl DirEntry { | DK::EventPort => return Ok(()), }; + // Nothing can open an entry whose path does not fit a path buffer. + if strings::without_trailing_slash(self.dir).len() + 1 + name_slice.len() >= MAX_PATH_BYTES + { + return Ok(()); + } + // Lowercase the entry basename once. The same bytes drive the // previous-generation case-insensitive probe, the new entry's // lowercased key, *and* the insert into `self.data` — and the hash is diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 9e5c9c02fef2..4e2239731a21 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -344,10 +344,12 @@ pub mod fs { join_abs_string_buf::(self.top_level_dir, buf, parts) } - /// Returns `None` on overflow. + /// `None` when the joined path does not fit `buf` with room for a NUL. pub fn abs_buf_checked<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> Option<&'b [u8]> { use bun_paths::resolve_path::{join_abs_string_buf_checked, platform}; + let capacity = buf.len(); join_abs_string_buf_checked::(self.top_level_dir, buf, parts) + .filter(|joined| joined.len() < capacity) } /// Normalizes `str` (separators, `.`/`..` segments) into `buf`. @@ -363,19 +365,10 @@ pub mod fs { join_abs_string::(self.top_level_dir, parts) } - /// Like `abs`, but interns the joined path into `DirnameStore` and - /// returns the `'static` copy. - pub(crate) fn abs_alloc( - &self, - parts: &[&[u8]], - ) -> core::result::Result<&'static [u8], bun_alloc::AllocError> { - use bun_paths::resolve_path::{join_abs_string, platform}; - let joined = join_abs_string::(self.top_level_dir, parts); - // Route through DirnameStore so - // the resolver's `&'static [u8]` storage contract holds. - DirnameStore::instance() - .append_slice(joined) - .map_err(|_| bun_alloc::AllocError) + /// Like `abs`, but spills into `spill` when the result does not fit the threadlocal buffer. + pub(crate) fn abs_spill<'b>(&self, spill: &'b mut Vec, parts: &[&[u8]]) -> &'b [u8] { + use bun_paths::resolve_path::{join_abs_string_spill, platform}; + join_abs_string_spill::(self.top_level_dir, spill, parts) } /// Relative path from `from` to `to`. Returns a slice into the diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 1e45ac9687a1..6c8cc4fa77c4 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -90,29 +90,16 @@ mod bun_paths { pub(super) fn dirname_platform(p: &[u8], platform: Platform) -> &[u8] { dispatch_platform!(platform, |P| ::bun_paths::resolve_path::dirname::

(p)) } - /// Port of `bun.path.joinAbsStringBuf` (value-dispatched). - pub(super) fn join_abs_string_buf<'b>( + /// `joinAbsStringBuf`, value-dispatched; `None` when the result does not fit `buf`. + pub(super) fn join_abs_string_buf_checked<'b>( cwd: &'b [u8], buf: &'b mut [u8], parts: &[&[u8]], platform: Platform, - ) -> &'b [u8] { - dispatch_platform!( - platform, - |P| ::bun_paths::resolve_path::join_abs_string_buf::

(cwd, buf, parts) - ) - } - pub(super) fn join_abs(cwd: &[u8], platform: Platform, part: &[u8]) -> &'static [u8] { - // NOTE: `resolve_path::join_abs` ties the result lifetime to `cwd`, but the - // returned slice always points into the threadlocal `PARSER_JOIN_INPUT_BUFFER` - // (or is `cwd` itself when `parts.is_empty()`, which never happens here — we - // pass exactly one part). Re-erase to `'static` so the resolver can hold it - // across `&mut self` calls. - let s = dispatch_platform!(platform, |P| ::bun_paths::resolve_path::join_abs::

( - cwd, part - )); - // SAFETY: see NOTE — slice borrows threadlocal storage, valid 'static per-thread. - unsafe { bun_ptr::detach_lifetime(s) } + ) -> Option<&'b [u8]> { + dispatch_platform!(platform, |P| { + ::bun_paths::resolve_path::join_abs_string_buf_checked::

(cwd, buf, parts) + }) } pub(super) fn join(parts: &[&[u8]], platform: Platform) -> &'static [u8] { dispatch_platform!(platform, |P| ::bun_paths::resolve_path::join::

(parts)) @@ -1318,7 +1305,8 @@ impl<'a> Resolver<'a> { ) { if import_path.len() > 2 && is_dot_slash(&import_path[0..2]) { let buf = bufs!(import_path_for_standalone_module_graph); - let joined = bun_paths::join_abs_string_buf( + // A specifier that does not fit a path buffer is never in the graph. + let joined = bun_paths::join_abs_string_buf_checked( source_dir, buf, &[import_path], @@ -1326,7 +1314,9 @@ impl<'a> Resolver<'a> { ); // Support relative paths in the graph - if let Some(file_name) = graph.find_assume_standalone_path(joined) { + if let Some(file_name) = + joined.and_then(|joined| graph.find_assume_standalone_path(joined)) + { // Intern: trait borrows into the graph; `Path::init` // needs `'static` (DirnameStore-backed). let file_name = Fs::file_system::DirnameStore::instance() @@ -1622,6 +1612,7 @@ impl<'a> Resolver<'a> { // SAFETY: rfs points at the process-global RealFS; the lazy-stat // rewrite inside `symlink()` is serialized on the per-entry mutex. let symlink_path = unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) }; + let mut buf = bun_paths::PathBuffer::uninit(); if !symlink_path.is_empty() { path.set_realpath(symlink_path); if !result.file_fd.is_valid() { @@ -1635,15 +1626,13 @@ impl<'a> Resolver<'a> { bstr::BStr::new(symlink_path) )); } - } else if !dir.abs_real_path.is_empty() { + } else if !dir.abs_real_path.is_empty() // When the directory is a symlink, we don't need to call getFdPath. - let parts = [dir.abs_real_path, query.entry().base()]; - let mut buf = bun_paths::PathBuffer::uninit(); - - // NOTE: `abs_buf` returns a borrow of `buf`; capture only the - // length so `buf` can be re-borrowed for null-termination below. - let out_len = self.fs_ref().abs_buf(&parts, &mut buf).len(); - + && let Some(out_len) = self + .fs_ref() + .abs_buf_checked(&[dir.abs_real_path, query.entry().base()], &mut buf) + .map(<[u8]>::len) + { let store_fd = self.store_fd; if !query.entry().cache().fd.is_valid() && store_fd { @@ -2482,11 +2471,15 @@ impl<'a> Resolver<'a> { return false; } - let joined = bun_paths::join_abs( + let mut buf = ::bun_paths::path_buffer_pool::get(); + let Some(joined) = bun_paths::join_abs_string_buf_checked( bun_paths::dirname_platform(import_source_file, bun_paths::Platform::AUTO), + &mut buf[..], + &[specifier], bun_paths::Platform::AUTO, - specifier, - ); + ) else { + return false; + }; let dir = bun_paths::dirname_platform(joined, bun_paths::Platform::AUTO); let a = self.bust_dir_cache(dir); @@ -2637,15 +2630,18 @@ impl<'a> Resolver<'a> { // NOTE: defer restore reshaped — restored at end of block if let Some(ref esm) = esm_ { - let abs_package_path: &[u8] = if is_self_reference { - dir_info.abs_path + // `/..` can fit above while the bare name does not. + let abs_package_path: Option<&[u8]> = if is_self_reference { + Some(dir_info.abs_path) } else { let parts = [dir_info.abs_path, b"node_modules".as_slice(), esm.name]; self.fs_ref() - .abs_buf(&parts, bufs!(esm_absolute_package_path)) + .abs_buf_checked(&parts, bufs!(esm_absolute_package_path)) }; - if let Ok(Some(pkg_dir_info)) = self.dir_info_cached(abs_package_path) { + if let Some(abs_package_path) = abs_package_path + && let Ok(Some(pkg_dir_info)) = self.dir_info_cached(abs_package_path) + { self.extension_order = match kind { ast::ImportKind::Url | ast::ImportKind::AtConditional @@ -4075,15 +4071,14 @@ impl<'a> Resolver<'a> { None => return Ok(None), }; + // Only ever the base of checked joins, so an over-long value matches nothing. + let mut spill = Vec::new(); if result.has_base_url() { // this might leak if !bun_paths::is_absolute(&result.base_url) { - // NOTE: `base_url: Box<[u8]>` owns its bytes, so - // copy `abs_buf`'s thread-local result directly instead of - // double-copying through the `dirname_store` arena. let abs = self .fs_ref() - .abs_buf(&[file_dir, &result.base_url[..]], bufs!(tsconfig_base_url)); + .abs_spill(&mut spill, &[file_dir, &result.base_url[..]]); result.base_url = Box::from(abs); } } @@ -4095,7 +4090,7 @@ impl<'a> Resolver<'a> { // this might leak let abs = self .fs_ref() - .abs_buf(&[file_dir, &result.base_url[..]], bufs!(tsconfig_base_url)); + .abs_spill(&mut spill, &[file_dir, &result.base_url[..]]); result.base_url_for_paths = Box::from(abs); } @@ -4825,8 +4820,13 @@ impl<'a> Resolver<'a> { if !bun_paths::is_absolute(absolute_original_path) { let parts: [&[u8]; 2] = [abs_base_url, original_path.as_ref()]; - absolute_original_path = - self.fs_ref().abs_buf(&parts, bufs!(tsconfig_path_abs)); + absolute_original_path = match self + .fs_ref() + .abs_buf_checked(&parts, bufs!(tsconfig_path_abs)) + { + Some(p) => p, + None => continue, + }; } if self @@ -5197,9 +5197,13 @@ impl<'a> Resolver<'a> { }}; } - let mut field_abs_path: &[u8] = self + // The field value is package.json text of any length. + let Some(mut field_abs_path) = self .fs_ref() - .abs_buf(&[path, field_rel_path], bufs!(field_abs_path)); + .abs_buf_checked(&[path, field_rel_path], bufs!(field_abs_path)) + else { + dec_ret!(MatchStatus::NotFound); + }; if self.care_about_browser_field { // Potentially remap using the "browser" field @@ -5213,8 +5217,11 @@ impl<'a> Resolver<'a> { { // Is the path disabled? if remap.is_empty() { - let paths = [path, field_rel_path]; - let new_path = self.fs_ref().abs_alloc(&paths).expect("unreachable"); + let new_path = self + .fs_ref() + .dirname_store + .append_slice(field_abs_path) + .expect("unreachable"); let mut _path = Path::init(new_path); _path.is_disabled = true; *out = MatchResult { @@ -5229,9 +5236,13 @@ impl<'a> Resolver<'a> { } // `remap` is relative to the package that owns the browser map. - field_abs_path = self - .fs_ref() - .abs_buf(&[browser_scope.abs_path, remap], bufs!(field_abs_path)); + field_abs_path = match self.fs_ref().abs_buf_checked( + &[browser_scope.abs_path, remap], + bufs!(field_abs_path), + ) { + Some(remapped) => remapped, + None => dec_ret!(MatchStatus::NotFound), + }; } } } @@ -5443,6 +5454,10 @@ impl<'a> Resolver<'a> { // this fn (only `remap_path` is, via a separate `bufs!` raw-ptr projection). let path_buf = bufs!(remap_path_trailing_slash); if !strings::ends_with_char(path_, SEP) { + // Room for the separator and the NUL. + if path.len() + 2 > path_buf.len() { + return MatchStatus::NotFound; + } path_buf[..path.len()].copy_from_slice(path); path_buf[path.len()] = SEP; path_buf[path.len() + 1] = 0; @@ -5455,7 +5470,12 @@ impl<'a> Resolver<'a> { if let Some(browser_json) = browser_scope.package_json() { let index_paths = [path, FIELD_REL_PATH]; - let index_abs_path = self.fs_ref().abs_buf(&index_paths, bufs!(remap_path)); + let Some(index_abs_path) = self + .fs_ref() + .abs_buf_checked(&index_paths, bufs!(remap_path)) + else { + return MatchStatus::NotFound; + }; if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( &browser_scope, @@ -5464,8 +5484,11 @@ impl<'a> Resolver<'a> { { // Is the path disabled? if remap.is_empty() { - let new_path = - self.fs_ref().abs_alloc(&index_paths).expect("unreachable"); + let new_path = self + .fs_ref() + .dirname_store + .append_slice(index_abs_path) + .expect("unreachable"); let mut _path = Path::init(new_path); _path.is_disabled = true; *out = MatchResult { @@ -5481,7 +5504,11 @@ impl<'a> Resolver<'a> { // `remap` is relative to the browser scope, which may be an ancestor of `path`. let new_paths = [browser_scope.abs_path, remap]; - let remapped_abs = self.fs_ref().abs_buf(&new_paths, bufs!(remap_path)); + let Some(remapped_abs) = + self.fs_ref().abs_buf_checked(&new_paths, bufs!(remap_path)) + else { + return MatchStatus::NotFound; + }; // Is this a file if let Some(file_result) = self.load_as_file(remapped_abs, extension_order) @@ -5777,6 +5804,11 @@ impl<'a> Resolver<'a> { path: &[u8], extension_order: options::ExtOrder, ) -> Option { + // The probes below build `path` plus an extension in `bufs!(load_as_file)`. + if path.len() >= MAX_PATH_BYTES { + return None; + } + // SAFETY: RealFS is the global singleton. Derive provenance from the raw // `*mut FileSystem` field so intervening `unsafe { &mut *self.fs() }` calls in // `load_extension` / `dirname_store.append_slice` don't invalidate `rfs` @@ -5948,6 +5980,9 @@ impl<'a> Resolver<'a> { }; for ext_to_replace in exts { + if path.len() - ext.len() + ext_to_replace.len() >= MAX_PATH_BYTES { + continue; + } let buffer = &mut tail[0..segment.len() + ext_to_replace.len()]; buffer[segment.len()..].copy_from_slice(ext_to_replace); @@ -6044,6 +6079,9 @@ impl<'a> Resolver<'a> { // field so `unsafe { &mut *self.fs() }` calls below (`filename_store.append_parts`) don't pop // its provenance under Stacked Borrows. let rfs: *mut Fs::file_system::RealFS = self.rfs_ptr(); + if path.len() + ext.len() >= MAX_PATH_BYTES { + return None; + } let buffer = &mut bufs!(load_as_file)[0..path.len() + ext.len()]; buffer[path.len()..].copy_from_slice(ext); let file_name = &buffer[path.len() - base.len()..buffer.len()]; @@ -6325,14 +6363,13 @@ impl<'a> Resolver<'a> { logs.add_note(buf); } info.abs_real_path = symlink; - } else if !parent_.abs_real_path.is_empty() { + } else if !parent_.abs_real_path.is_empty() + && let Some(joined) = self.fs_ref().abs_buf_checked( + &[parent_.abs_real_path, base], + bufs!(dir_info_uncached_filename), + ) + { // this might leak a little i'm not sure - let parts = [parent_.abs_real_path, base]; - // NOTE: split into two statements so the two `&mut FileSystem` - // borrows from `unsafe { &mut *self.fs() }` don't overlap (Stacked Borrows). - let joined = self - .fs_ref() - .abs_buf(&parts, bufs!(dir_info_uncached_filename)); symlink = self .fs_ref() .dirname_store @@ -6543,28 +6580,36 @@ impl<'a> Resolver<'a> { ); while !current.extends.is_empty() { let ts_dir_name = Dirname::dirname(¤t.abs_path); - let abs_path = ResolvePath::join_abs_string_buf( + // The `extends` value is config text of any length. + let parsed = match ResolvePath::join_abs_string_buf_checked( ts_dir_name, bufs!(tsconfig_path_abs), &[ts_dir_name, ¤t.extends], bun_paths::Platform::AUTO, - ); - let parent_config_maybe: Option<*mut TSConfigJSON> = - match self.parse_tsconfig(abs_path, FD::INVALID) { - Ok(v) => v.map(bun_core::heap::into_raw), - Err(err) => { - let _ = self.log_mut().add_debug_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "{} loading tsconfig.json extends {}", - bstr::BStr::new(err.name()), - bun_core::fmt::quote(abs_path) - ), - ); - break; - } - }; + ) { + Some(abs_path) => self + .parse_tsconfig(abs_path, FD::INVALID) + .map_err(|err| (err, abs_path)), + None => Err(( + crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG), + ¤t.extends[..], + )), + }; + let parent_config_maybe: Option<*mut TSConfigJSON> = match parsed { + Ok(v) => v.map(bun_core::heap::into_raw), + Err((err, shown_path)) => { + let _ = self.log_mut().add_debug_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "{} loading tsconfig.json extends {}", + bstr::BStr::new(err.name()), + bun_core::fmt::quote(shown_path) + ), + ); + break; + } + }; if let Some(parent_config) = parent_config_maybe { parent_configs.append(parent_config)?; current = bun_ptr::BackRef::from( diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 2ae5b67bba27..a3c3d691bbef 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -7594,8 +7594,7 @@ impl NodeFS { // SAFETY: instance() returns the leaked singleton; INSTANCE_LOADED checked above. let fs = FileSystem::get(); let parts = [fs.top_level_dir, path_slice]; - let inbuf_len = inbuf.len(); - let Some(joined) = fs.abs_buf_checked(&parts, &mut inbuf[..inbuf_len - 1]) else { + let Some(joined) = fs.abs_buf_checked(&parts, &mut inbuf[..]) else { return Err(sys::Error { errno: E::ENAMETOOLONG as _, syscall: sys::Tag::realpath, diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 01b514dbbe54..e7f287d27777 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -792,6 +792,43 @@ describe("bundler", () => { }, compile: true, }); + // A "./" specifier required or imported at runtime from an embedded module + // is joined onto the module's /$bunfs/ directory in a path buffer and looked + // up in the embedded graph. One that does not fit the buffer used to abort the + // executable; it is not found, like it would be outside the executable. + // Specifiers past 1.5 times the buffer are rejected before resolution starts, + // so the lengths sit between the two bounds. + itBundled("compile/RelativeSpecifierLongerThanPathBuffer", { + compile: true, + files: { + "/entry.ts": /* js */ ` + const req = (x) => require(x); + const imp = (x) => import(x); + const length = process.platform === "win32" ? 100_000 : process.platform === "linux" ? 5000 : 1200; + const tooLong = "./" + Buffer.alloc(length, "w").toString(); + const out = []; + try { req(tooLong); out.push("require resolved"); } catch (e) { out.push("require: " + e.code); } + try { await imp(tooLong); out.push("import resolved"); } catch (e) { out.push("import: " + e.code); } + out.push(req("./embedded-sibling.js").value); + out.push((await imp("./embedded-sibling.js")).value); + console.log(out.join("\\n")); + `, + "/embedded-sibling.ts": /* js */ ` + export const value = "embedded sibling resolved from the graph"; + `, + }, + entryPointsRaw: ["./entry.ts", "./embedded-sibling.ts"], + outfile: "dist/out", + run: { + file: "dist/out", + stdout: [ + "require: MODULE_NOT_FOUND", + "import: ERR_MODULE_NOT_FOUND", + "embedded sibling resolved from the graph", + "embedded sibling resolved from the graph", + ].join("\n"), + }, + }); // see comment in `usePackageManager` for why this is a test itBundled("compile/NoAutoInstall", { files: { diff --git a/test/harness.ts b/test/harness.ts index b3167919ec84..6da74ac8fff6 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -24,6 +24,11 @@ export const isFreeBSD = process.platform === "freebsd"; export const isAndroid = process.platform === "android"; export const isPosix = isMacOS || isLinux || isFreeBSD || isAndroid; export const isWindows = process.platform === "win32"; +/** + * Size of bun's fixed path buffers (`MAX_PATH_BYTES` in `src/bun_core/util.rs`). A path longer than this does not + * fit in one, which code taking a path from the user has to report rather than overflow the buffer. + */ +export const MAX_PATH_BYTES = isWindows ? 32767 * 3 + 1 : isLinux || isAndroid ? 4096 : 1024; export const isIntelMacOS = isMacOS && process.arch === "x64"; export const isArm64 = process.arch === "arm64"; export const isDebug = Bun.version.includes("debug"); diff --git a/test/js/bun/resolve/fixtures/deep-directory-fixture.cjs b/test/js/bun/resolve/fixtures/deep-directory-fixture.cjs new file mode 100644 index 000000000000..d84d18bb17ce --- /dev/null +++ b/test/js/bun/resolve/fixtures/deep-directory-fixture.cjs @@ -0,0 +1,59 @@ +// Helpers for tests about paths close to, or past, MAX_PATH_BYTES (see harness). +// +// The kernel limits the length of the path given to one syscall, not the depth +// of the tree. So a directory whose absolute path is longer than the limit can +// be created and filled with paths relative to the process cwd. Every helper +// here restores the cwd before it returns. +// +// process.chdir() needs the new cwd to fit a path buffer afterwards, and bun's +// currently fails one byte earlier than that. `makeDirectoryOfLength` only +// enters directories at least two bytes shorter than the one it returns, so +// `length` may go up to the limit. +// `writeFileIn` and `mkdirIn` enter `dir` itself, so `dir` has to be at least +// two bytes below the limit; what they create inside it may be of any length. +"use strict"; +const fs = require("fs"); + +function inDirectory(dir, fn) { + const previous = process.cwd(); + process.chdir(dir); + try { + fn(); + } finally { + process.chdir(previous); + } +} + +/** + * Creates a chain of directories below `parent` (an existing directory) and + * returns the absolute path of the deepest one, which is exactly `length` + * bytes long. Components are as long as NAME_MAX allows, so the chain is short + * enough to remove quickly. + */ +function makeDirectoryOfLength(parent, length) { + if (length < parent.length + 2) throw new Error(`${length} bytes is too short for a directory below ${parent}`); + let dir = parent; + while (dir.length < length) { + // Bytes left after the separator. When this is not the last component, + // keep two of them for the separator and first byte of the next one. + const remaining = length - dir.length - 1; + const size = remaining <= 255 ? remaining : Math.min(255, remaining - 2); + const name = Buffer.alloc(size, "d").toString(); + inDirectory(dir, () => fs.mkdirSync(name)); + dir = `${dir}/${name}`; + } + return dir; +} + +/** Writes `dir/name`, which may be too long to name in one path. */ +function writeFileIn(dir, name, contents) { + inDirectory(dir, () => fs.writeFileSync(name, contents)); +} + +/** Creates and returns `dir/name`, which may be too long to name in one path. */ +function mkdirIn(dir, name) { + inDirectory(dir, () => fs.mkdirSync(name)); + return `${dir}/${name}`; +} + +module.exports = { makeDirectoryOfLength, writeFileIn, mkdirIn }; diff --git a/test/js/bun/resolve/resolve-error.test.ts b/test/js/bun/resolve/resolve-error.test.ts index c608c2800a83..1ae03507f70d 100644 --- a/test/js/bun/resolve/resolve-error.test.ts +++ b/test/js/bun/resolve/resolve-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isMacOS, isWindows, MAX_PATH_BYTES, tempDir } from "harness"; import path from "node:path"; describe("ResolveMessage", () => { @@ -283,3 +283,335 @@ describe.concurrent("tsconfig paths wildcard with overlapping prefix/suffix", () await run("xy*xy", "xy"); }); }); + +// The resolver builds every candidate path in a buffer of MAX_PATH_BYTES. A +// candidate that did not fit was written past the end of that buffer and +// aborted the process: +// panic: range end index 4099 out of range for slice of length 4096 +// Nothing could open such a candidate anyway, so it now resolves like a path +// that does not exist. What gets there: config text of any length (package.json +// "main" / "module" / "browser", tsconfig.json "extends" / "baseUrl" / "paths"), +// specifiers, and the contents of directories that themselves still fit. +describe.concurrent("candidate paths that do not fit a path buffer", () => { + const deepDirectoryFixture = path.join(import.meta.dir, "fixtures", "deep-directory-fixture.cjs"); + // A relative value is joined onto a directory, so this never fits; an + // absolute one replaces the directory and is over the limit by itself. + const longName = Buffer.alloc(MAX_PATH_BYTES, "a").toString(); + + /** + * Runs main.cjs, which prints one JSON value, in a project. package.json and + * node_modules/ keep the resolver from auto-installing the bare specifiers. + */ + async function run(files: Record) { + using dir = tempDir("resolve-too-long", { + "package.json": `{"name": "test", "version": "0.0.0"}`, + "node_modules/.keep": "", + ...files, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.cjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + let result: unknown = stdout; + try { + result = JSON.parse(stdout); + } catch {} + return { result, stderr, exitCode }; + } + + const helpers = ` + const outcome = fn => { try { return "resolved: " + fn(); } catch (e) { return e.code; } }; + `; + + it("package.json main and module fields", async () => { + expect( + await run({ + "node_modules/relative/package.json": JSON.stringify({ name: "relative", main: `./${longName}` }), + "node_modules/absolute/package.json": JSON.stringify({ name: "absolute", module: `/${longName}` }), + "main.cjs": `${helpers} + import("absolute").then(() => "resolved", e => e.code).then(absolute => { + console.log(JSON.stringify({ relative: outcome(() => require("relative")), absolute })); + });`, + }), + ).toEqual({ + result: { relative: "MODULE_NOT_FOUND", absolute: "ERR_MODULE_NOT_FOUND" }, + stderr: "", + exitCode: 0, + }); + }); + + it("a package name whose subpath normalizes away", async () => { + // `/../pkg` as a whole normalizes to node_modules/pkg and fits; only + // the package directory probed for the name itself does not. Node resolves + // this to pkg, and so does bun with a short name. + const specifier = `${longName}/../pkg`; + expect( + await run({ + "node_modules/pkg/index.js": `module.exports = "pkg";`, + "main.cjs": `${helpers} + import(${JSON.stringify(specifier)}).then(m => "resolved: " + m.default, e => e.code).then(imported => { + console.log(JSON.stringify({ required: outcome(() => require(${JSON.stringify(specifier)})), imported })); + });`, + }), + ).toEqual({ + result: { required: "resolved: pkg", imported: "resolved: pkg" }, + stderr: "", + exitCode: 0, + }); + }); + + it("package.json browser field remapping the main field", async () => { + expect( + await run({ + "node_modules/m/package.json": JSON.stringify({ + name: "m", + main: "./x.js", + browser: { "./x.js": `./${longName}` }, + }), + "node_modules/m/x.js": "module.exports = 1;", + "entry.js": `import "m";`, + "main.cjs": ` + Bun.build({ entrypoints: ["./entry.js"], target: "browser", throw: false }).then(build => { + console.log(JSON.stringify({ success: build.success, messages: build.logs.map(log => log.message) })); + });`, + }), + ).toEqual({ + result: { success: false, messages: ['Could not resolve: "m". Maybe you need to "bun install"?'] }, + stderr: "", + exitCode: 0, + }); + }); + + it("tsconfig.json extends, baseUrl and paths", async () => { + // Each use.cjs is governed by the tsconfig.json next to it. The one with + // the unusable "extends" still loads; the other two resolve a bare + // specifier against the unusable base directory. + expect( + await run({ + "extends/tsconfig.json": JSON.stringify({ extends: `./${longName}` }), + "extends/use.cjs": `module.exports = "loaded";`, + "baseUrl/tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: longName } }), + "baseUrl/use.cjs": `module.exports = require("x");`, + "paths/tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { x: [longName] } } }), + "paths/use.cjs": `module.exports = require("x");`, + "main.cjs": `${helpers} + console.log(JSON.stringify({ + extends: outcome(() => require("./extends/use.cjs")), + baseUrl: outcome(() => require("./baseUrl/use.cjs")), + paths: outcome(() => require("./paths/use.cjs")), + }));`, + }), + ).toEqual({ + result: { extends: "resolved: loaded", baseUrl: "MODULE_NOT_FOUND", paths: "MODULE_NOT_FOUND" }, + stderr: "", + exitCode: 0, + }); + }); + + // The lengths below are lengths of whole paths. Windows has no way to build + // them: its buffers hold three times what NTFS accepts. + describe.skipIf(isWindows)("paths within a few bytes of the limit", () => { + const MAX = MAX_PATH_BYTES; + const deepHelpers = `${helpers} + const { makeDirectoryOfLength, writeFileIn, mkdirIn } = require(${JSON.stringify(deepDirectoryFixture)}); + const { symlinkSync, writeFileSync } = require("fs"); + const MAX = ${MAX}; + const cwd = process.cwd(); + const name = (length, fill) => Buffer.alloc(length, fill).toString(); + `; + + it("a missing file in an existing directory", async () => { + // Five bytes under the limit is the longest path every extension probe + // still fits behind; from there up, first the probes and then the path + // itself are over. + const lengths = [MAX - 5, MAX - 4, MAX - 1, MAX, MAX + 1, MAX + 300]; + expect( + await run({ + "main.cjs": `${deepHelpers} + const out = {}; + for (const length of ${JSON.stringify(lengths)}) { + const file = cwd + "/" + name(length - cwd.length - 1, "f"); + out[length] = [outcome(() => require(file)), outcome(() => require.resolve(file))]; + } + console.log(JSON.stringify(out));`, + }), + ).toEqual({ + result: Object.fromEntries(lengths.map(length => [length, ["MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]])), + stderr: "", + exitCode: 0, + }); + }); + + it("an existing directory", async () => { + // The directory fits, so it is read; what is probed inside it does not. + const lengths = [MAX - 3, MAX - 2, MAX - 1]; + expect( + await run({ + "main.cjs": `${deepHelpers} + const parent = makeDirectoryOfLength(cwd, MAX - 200); + (async () => { + const out = {}; + for (const length of ${JSON.stringify(lengths)}) { + const dir = mkdirIn(parent, name(length - parent.length - 1, "e")); + out[length] = [ + outcome(() => require(dir)), + outcome(() => require.resolve(dir)), + outcome(() => Bun.resolveSync(dir, "/")), + await import(dir).then(() => "resolved", e => e.code), + ]; + } + console.log(JSON.stringify(out)); + })();`, + }), + ).toEqual({ + result: Object.fromEntries( + lengths.map(length => [ + length, + ["MODULE_NOT_FOUND", "MODULE_NOT_FOUND", "ERR_MODULE_NOT_FOUND", "ERR_MODULE_NOT_FOUND"], + ]), + ), + stderr: "", + exitCode: 0, + }); + }); + + it("files listed in an existing directory", async () => { + // Such a file is left out of the directory listing, so it is not there + // as far as the resolver is concerned: a package.json or tsconfig.json + // in that position is ignored without a message, at runtime and in a + // build alike. + expect( + await run({ + "1/.keep": "", + "2/.keep": "", + "3/.keep": "", + "4/.keep": "", + "main.cjs": `${deepHelpers} + // "/index.js" is 9 bytes: the first index file ends one byte under the limit, the second one byte over. + const indexFits = makeDirectoryOfLength(cwd + "/1", MAX - 10); + writeFileIn(indexFits, "index.js", "module.exports = 'index that fits';"); + const indexTooLong = makeDirectoryOfLength(cwd + "/2", MAX - 8); + writeFileIn(indexTooLong, "index.js", "module.exports = 'unreachable';"); + // "/package.json" is 13 bytes, so its "main" (which fits) is not applied. + const packageJsonTooLong = makeDirectoryOfLength(cwd + "/3", MAX - 11); + writeFileIn(packageJsonTooLong, "package.json", '{"name": "deep", "main": "./m.cjs"}'); + writeFileIn(packageJsonTooLong, "m.cjs", "module.exports = 'unreachable';"); + // "/tsconfig.json" is 14 bytes; "/m.cjs" next to it still fits. + const tsconfigTooLong = makeDirectoryOfLength(cwd + "/4", MAX - 13); + writeFileIn(tsconfigTooLong, "tsconfig.json", '{"compilerOptions": {"jsx": "react"}}'); + writeFileIn(tsconfigTooLong, "m.cjs", "module.exports = 'module next to the tsconfig';"); + const build = entry => Bun.build({ entrypoints: [entry], throw: false }) + .then(b => ({ success: b.success, messages: b.logs.map(log => log.message) })); + Promise.all([build(packageJsonTooLong + "/m.cjs"), build(tsconfigTooLong + "/m.cjs")]).then(builds => { + console.log(JSON.stringify({ + indexFits: outcome(() => require(indexFits)), + indexTooLong: outcome(() => require(indexTooLong)), + packageJsonTooLong: outcome(() => require(packageJsonTooLong)), + tsconfigTooLong: outcome(() => require(tsconfigTooLong + "/m.cjs")), + builds, + })); + });`, + }), + ).toEqual({ + result: { + indexFits: "resolved: index that fits", + indexTooLong: "MODULE_NOT_FOUND", + packageJsonTooLong: "MODULE_NOT_FOUND", + tsconfigTooLong: "resolved: module next to the tsconfig", + builds: [ + { success: true, messages: [] }, + { success: true, messages: [] }, + ], + }, + stderr: "", + exitCode: 0, + }); + }); + + // Behind a symlink, the spelling the resolver was given fits while the real + // path it derives need not. The link replaces the first component of the + // tree, so its target stays short (some filesystems cap symlink targets at + // 1024 bytes) and the spelling is about 250 bytes shorter than the real + // path; the resolver derives the real path of each directory below the + // link from its parent's. The links go in a directory the resolver has not + // listed yet when they are made, because it caches listings. + const symlinkHelpers = `${deepHelpers} + // Makes the tree for \`real\`, links cwd/links/ to its first component + // and returns \`real\` spelled through the link. + const linkFirstComponent = (root, real, i) => { + const end = real.indexOf("/", root.length + 1); + if (end < 0) throw new Error("the tree below " + root + " needs at least two components"); + symlinkSync(real.slice(0, end), cwd + "/links/" + i); + return cwd + "/links/" + i + real.slice(end); + }; + const resolvedTo = (spelling, real) => { + try { + const resolved = require.resolve(spelling); + return resolved === spelling ? "the spelling" : resolved === real ? "the real path" : resolved; + } catch (e) { + return e.code; + } + }; + `; + + it("a file whose real path behind a symlink does not fit", async () => { + // One byte over the limit and exactly at it resolve to the spelling; + // one byte under it is recorded as the real path as usual. + const files = [ + ["MAX + 1", 1], + ["MAX", 0], + ["MAX - 1", -1], + ] as const; + expect( + await run({ + "0/.keep": "", + "1/.keep": "", + "2/.keep": "", + "links/.keep": "", + "main.cjs": `${symlinkHelpers} + const trees = ${JSON.stringify(files)}.map(([label, over], i) => { + // "/x.js" is 5 bytes. + const real = makeDirectoryOfLength(cwd + "/" + i, MAX - 5 + over); + writeFileIn(real, "x.js", ""); + return [label, real + "/x.js", linkFirstComponent(cwd + "/" + i, real, i) + "/x.js"]; + }); + const out = {}; + for (const [label, real, spelling] of trees) out[label] = resolvedTo(spelling, real); + console.log(JSON.stringify(out));`, + }), + ).toEqual({ + result: { "MAX + 1": "the spelling", "MAX": "the spelling", "MAX - 1": "the real path" }, + stderr: "", + exitCode: 0, + }); + }); + + // macOS applies the limit to the path after symlink expansion, so such a + // directory cannot be entered there at all. + it.skipIf(isMacOS)("a directory whose real path behind a symlink does not fit", async () => { + expect( + await run({ + "0/.keep": "", + "links/.keep": "", + "main.cjs": `${symlinkHelpers} + const parent = makeDirectoryOfLength(cwd + "/0", MAX - 60); + mkdirIn(parent, name(100, "s")); + const spelling = linkFirstComponent(cwd + "/0", parent, 0) + "/" + name(100, "s"); + writeFileSync(spelling + "/index.js", ""); + console.log(JSON.stringify(outcome(() => { + const resolved = require.resolve(spelling); + return resolved === spelling + "/index.js" ? "the index file, spelled through the link" : resolved; + })));`, + }), + ).toEqual({ + result: "resolved: the index file, spelled through the link", + stderr: "", + exitCode: 0, + }); + }); + }); +}); diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 9c9821457815..f9ee9f3f7c32 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1,7 +1,17 @@ import { FileSystemRouter } from "bun"; -import { expect, it } from "bun:test"; +import { describe, expect, it } from "bun:test"; import fs, { mkdirSync, rmSync } from "fs"; -import { bunEnv, bunExe, isASAN, isMacOS, isWindows, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness"; +import { + bunEnv, + bunExe, + isASAN, + isMacOS, + isWindows, + MAX_PATH_BYTES, + normalizeBunSnapshot, + tempDir, + tmpdirSync, +} from "harness"; import path, { dirname } from "path"; function createTree(basedir: string, paths: string[]) { @@ -1027,3 +1037,77 @@ it.skipIf(isWindows || isMacOS)( expect(exitCode).toBe(0); }, ); + +// The scan joins each listed entry onto its directory in a path buffer. The +// directories it lists always fit one (deeper ones cannot be read), but their +// entries need not, and such an entry used to abort the process: +// panic: range end index 4196 out of range for slice of length 4095 +// Nothing under such an entry could be served, so it is skipped. Windows cannot +// build these trees (its buffers hold far more than NTFS accepts). +describe.skipIf(isWindows)("entries whose paths do not fit a path buffer", () => { + const deepDirectoryFixture = path.join(import.meta.dir, "..", "resolve", "fixtures", "deep-directory-fixture.cjs"); + + async function scan(routesDir: string, setup: string) { + const code = /* js */ ` + const { makeDirectoryOfLength, writeFileIn, mkdirIn } = require(${JSON.stringify(deepDirectoryFixture)}); + const MAX = ${MAX_PATH_BYTES}; + // The lengths below count bytes of the spelling the router is given, + // so give it one without symlinks (macOS's temp dir is one). + const routesDir = require("fs").realpathSync(${JSON.stringify(routesDir)}); + ${setup} + const router = new Bun.FileSystemRouter({ dir: routesDir, style: "nextjs" }); + // Route names here are thousands of bytes long; keep their tails. + const names = () => Object.keys(router.routes).map(name => name.length > 20 ? "..." + name.slice(-2) : name).sort(); + const before = names(); + router.reload(); + console.log(JSON.stringify({ before, afterReload: names() })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + } + + it("a subdirectory", async () => { + using dir = tempDir("fsr-too-deep-dir", { "index.tsx": "export default 1;\n" }); + expect( + await scan( + String(dir), + ` + // The deepest directory that is listed still holds a route; the + // directory inside it is over the limit. + const deepest = makeDirectoryOfLength(routesDir, MAX - 100); + writeFileIn(deepest, "r.tsx", "export default 1;"); + mkdirIn(deepest, Buffer.alloc(200, "s").toString()); + `, + ), + ).toEqual({ + stdout: JSON.stringify({ before: [".../r", "/"], afterReload: [".../r", "/"] }), + stderr: "", + exitCode: 0, + }); + }); + + it("a route file", async () => { + using dir = tempDir("fsr-too-deep-file", { "index.tsx": "export default 1;\n", "1/.keep": "", "2/.keep": "" }); + expect( + await scan( + String(dir), + ` + // Each file name adds 6 bytes: the first route's path fits, the + // second one's fills the whole buffer and cannot be opened. + writeFileIn(makeDirectoryOfLength(routesDir + "/1", MAX - 10), "y.tsx", "export default 1;"); + writeFileIn(makeDirectoryOfLength(routesDir + "/2", MAX - 6), "x.tsx", "export default 1;"); + `, + ), + ).toEqual({ + stdout: JSON.stringify({ before: [".../y", "/"], afterReload: [".../y", "/"] }), + stderr: "", + exitCode: 0, + }); + }); +}); From 369fade991d6d92ffb61feb6775b7dca27cf93e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:59:34 +0000 Subject: [PATCH 2/9] Drop the heap fallback for over-long entry names, now unreachable --- src/resolver/fs.rs | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index e6894e2afb51..fbaee70b16cf 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -447,25 +447,10 @@ impl DirEntry { // previous-generation case-insensitive probe, the new entry's // lowercased key, *and* the insert into `self.data` — and the hash is // computed once here (`name_hash`) rather than re-derived by the probe - // and again by the insert. A stack scratch covers the common short - // case (matches `DirEntry::get`); only a basename longer than - // `MAX_PATH_BYTES` — which `getdents`/`FindNextFile` can't produce — - // would touch the heap. + // and again by the insert. The check above keeps the name inside the + // stack scratch (matches `DirEntry::get`). let mut name_lc_buf = PathBuffer::uninit(); - let name_lc_heap: Option = - if name_slice.len() <= MAX_PATH_BYTES { - None - } else { - Some( - bun_collections::StringHashMapContext::PrehashedCaseInsensitive::init( - name_slice, - ), - ) - }; - let name_lc: &[u8] = match &name_lc_heap { - Some(p) => &p.input[..], - None => strings::copy_lowercase_if_needed(name_slice, &mut name_lc_buf[..]), - }; + let name_lc: &[u8] = strings::copy_lowercase_if_needed(name_slice, &mut name_lc_buf[..]); let name_hash = self.data.hash_key(name_lc); let stored: *mut Entry = 'brk: { From 3bf514fb1a7abb460454bc489e71679efe7a5de1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:01:54 +0000 Subject: [PATCH 3/9] Shorten the lowercase-once comment in add_entry_with_store --- src/resolver/fs.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index fbaee70b16cf..aadfdeecf00e 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -443,12 +443,7 @@ impl DirEntry { return Ok(()); } - // Lowercase the entry basename once. The same bytes drive the - // previous-generation case-insensitive probe, the new entry's - // lowercased key, *and* the insert into `self.data` — and the hash is - // computed once here (`name_hash`) rather than re-derived by the probe - // and again by the insert. The check above keeps the name inside the - // stack scratch (matches `DirEntry::get`). + // Lowercased and hashed once for the probe, the key and the insert below. let mut name_lc_buf = PathBuffer::uninit(); let name_lc: &[u8] = strings::copy_lowercase_if_needed(name_slice, &mut name_lc_buf[..]); let name_hash = self.data.hash_key(name_lc); From 344eda91acac9ad4c48dd3d828fa58af77a005db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:52:25 +0000 Subject: [PATCH 4/9] Bound the extension probes, drop the unused PrehashedCaseInsensitive An extension from --extension-order or require.extensions that does not fit a path buffer next to the name it is appended to is skipped in load_index_with_extension, probe_target_extensions and the missing-suffix hint, like load_extension already does. PrehashedCaseInsensitive lost its last caller when add_entry_with_store stopped needing a heap fallback, so it and the StringHashMapContext module that only re-exported it are removed. finalize_result takes its pooled buffer only when the directory has a real path to join onto. The compile test for an over-long embedded specifier is dropped: main resolves those in StandaloneModuleGraph::resolve now. --- src/collections/array_hash_map.rs | 27 +------- src/collections/lib.rs | 4 +- src/resolver/resolver.rs | 13 +++- test/bundler/bundler_compile.test.ts | 37 ----------- .../fixtures/deep-directory-fixture.cjs | 9 ++- test/js/bun/resolve/resolve-error.test.ts | 64 +++++++++++++++++-- 6 files changed, 81 insertions(+), 73 deletions(-) diff --git a/src/collections/array_hash_map.rs b/src/collections/array_hash_map.rs index 817e3f0a9bad..d195df17ee3d 100644 --- a/src/collections/array_hash_map.rs +++ b/src/collections/array_hash_map.rs @@ -1867,35 +1867,12 @@ impl StringHash } // ────────────────────────────────────────────────────────────────────────── -// StringHashMapContext + PrehashedCaseInsensitive +// string_hash_map // ────────────────────────────────────────────────────────────────────────── -/// `bun.StringHashMapContext` — spelled as a module rather than a unit -/// struct so callers can path-access the nested `PrehashedCaseInsensitive` -/// type (`StringHashMapContext::PrehashedCaseInsensitive`) on stable Rust, -/// which forbids inherent associated types. -#[allow(non_snake_case)] -pub mod StringHashMapContext { - pub use super::string_hash_map::PrehashedCaseInsensitive; -} - /// String-hash helpers, namespaced so call sites can write -/// `bun_collections::string_hash_map::{PrehashedCaseInsensitive, GetOrPutResult}`. +/// `bun_collections::string_hash_map::GetOrPutResult`. pub mod string_hash_map { - /// `bun.StringHashMapContext.PrehashedCaseInsensitive` — owns a lowercased - /// copy of the input. Dropped via `Box`. - pub struct PrehashedCaseInsensitive { - pub input: Box<[u8]>, - } - - impl PrehashedCaseInsensitive { - pub fn init(input: &[u8]) -> Self { - let mut out = vec![0u8; input.len()].into_boxed_slice(); - bun_core::strings::copy_lowercase(input, &mut out); - Self { input: out } - } - } - /// Result type alias for `StringHashMap::get_or_put*` so callers can name /// it as `string_hash_map::GetOrPutResult<'_, V>`. pub type GetOrPutResult<'a, V> = super::StringHashMapGetOrPut<'a, V>; diff --git a/src/collections/lib.rs b/src/collections/lib.rs index cbf11701a3e0..12187d15443e 100644 --- a/src/collections/lib.rs +++ b/src/collections/lib.rs @@ -136,8 +136,8 @@ pub mod array_hash_map; pub use array_hash_map::{ ArrayHashMap, ArrayHashMapExt, AutoContext, CaseInsensitiveAsciiStringArrayHashMap, CaseInsensitiveAsciiStringContext, Entry, GetOrPutResult, MapEntry, OccupiedEntry, - StringArrayHashMap, StringHashMap, StringHashMapContext, StringHashMapKey, - StringHashMapUnownedKey, StringSet, VacantEntry, string_hash_map, + StringArrayHashMap, StringHashMap, StringHashMapKey, StringHashMapUnownedKey, StringSet, + VacantEntry, string_hash_map, }; /// Downstream crates name hashbrown's iterator/entry types in struct fields /// (e.g. `bun_resolver::DirEntryDirIter`). `StringHashMap` `Deref`s to a diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 218a784e9cb1..81bb017d5135 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1572,7 +1572,6 @@ impl<'a> Resolver<'a> { // SAFETY: rfs points at the process-global RealFS; the lazy-stat // rewrite inside `symlink()` is serialized on the per-entry mutex. let symlink_path = unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) }; - let mut buf = bun_paths::path_buffer_pool::get(); if !symlink_path.is_empty() { path.set_realpath(symlink_path); if !result.file_fd.is_valid() { @@ -1588,9 +1587,10 @@ impl<'a> Resolver<'a> { } } else if !dir.abs_real_path.is_empty() // When the directory is a symlink, we don't need to call getFdPath. + && let mut buf = bun_paths::path_buffer_pool::get() && let Some(out_len) = self .fs_ref() - .abs_buf_checked(&[dir.abs_real_path, query.entry().base()], &mut buf) + .abs_buf_checked(&[dir.abs_real_path, query.entry().base()], &mut buf[..]) .map(<[u8]>::len) { let store_fd = self.store_fd; @@ -3698,6 +3698,9 @@ impl<'a> Resolver<'a> { buf[..index.len()].copy_from_slice(index); for ext in self.opts.ext_order_slice(extension_order).iter() { let ext: &[u8] = ext; + if index.len() + ext.len() > buf.len() { + continue; + } let file_name = &mut buf[0..index.len() + ext.len()]; file_name[index.len()..].copy_from_slice(ext); let index_query = @@ -3818,6 +3821,9 @@ impl<'a> Resolver<'a> { buf[..base.len()].copy_from_slice(base); for ext in self.opts.ext_order_slice(extension_order).iter() { let ext: &[u8] = ext; + if base.len() + ext.len() > buf.len() { + continue; + } let file_name = &mut buf[0..base.len() + ext.len()]; file_name[base.len()..].copy_from_slice(ext); if let Some(ext_query) = @@ -5318,6 +5324,9 @@ impl<'a> Resolver<'a> { let ext_buf = bufs!(extension_path); + if b"index".len() + ext.len() > ext_buf.len() { + return MatchStatus::NotFound; + } let base = &mut ext_buf[0..b"index".len() + ext.len()]; base[0..b"index".len()].copy_from_slice(b"index"); base[b"index".len()..].copy_from_slice(ext); diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 8c4959b6d176..ac147a1d9dbd 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -1035,43 +1035,6 @@ describe("bundler", () => { }, compile: true, }); - // A "./" specifier required or imported at runtime from an embedded module - // is joined onto the module's /$bunfs/ directory in a path buffer and looked - // up in the embedded graph. One that does not fit the buffer used to abort the - // executable; it is not found, like it would be outside the executable. - // Specifiers past 1.5 times the buffer are rejected before resolution starts, - // so the lengths sit between the two bounds. - itBundled("compile/RelativeSpecifierLongerThanPathBuffer", { - compile: true, - files: { - "/entry.ts": /* js */ ` - const req = (x) => require(x); - const imp = (x) => import(x); - const length = process.platform === "win32" ? 100_000 : process.platform === "linux" ? 5000 : 1200; - const tooLong = "./" + Buffer.alloc(length, "w").toString(); - const out = []; - try { req(tooLong); out.push("require resolved"); } catch (e) { out.push("require: " + e.code); } - try { await imp(tooLong); out.push("import resolved"); } catch (e) { out.push("import: " + e.code); } - out.push(req("./embedded-sibling.js").value); - out.push((await imp("./embedded-sibling.js")).value); - console.log(out.join("\\n")); - `, - "/embedded-sibling.ts": /* js */ ` - export const value = "embedded sibling resolved from the graph"; - `, - }, - entryPointsRaw: ["./entry.ts", "./embedded-sibling.ts"], - outfile: "dist/out", - run: { - file: "dist/out", - stdout: [ - "require: MODULE_NOT_FOUND", - "import: ERR_MODULE_NOT_FOUND", - "embedded sibling resolved from the graph", - "embedded sibling resolved from the graph", - ].join("\n"), - }, - }); // see comment in `usePackageManager` for why this is a test itBundled("compile/NoAutoInstall", { files: { diff --git a/test/js/bun/resolve/fixtures/deep-directory-fixture.cjs b/test/js/bun/resolve/fixtures/deep-directory-fixture.cjs index d84d18bb17ce..7716d16efd5d 100644 --- a/test/js/bun/resolve/fixtures/deep-directory-fixture.cjs +++ b/test/js/bun/resolve/fixtures/deep-directory-fixture.cjs @@ -31,16 +31,19 @@ function inDirectory(dir, fn) { * enough to remove quickly. */ function makeDirectoryOfLength(parent, length) { - if (length < parent.length + 2) throw new Error(`${length} bytes is too short for a directory below ${parent}`); + // `parent` (a temp directory) need not be ASCII; the components added here are. + let bytes = Buffer.byteLength(parent); + if (length < bytes + 2) throw new Error(`${length} bytes is too short for a directory below ${parent}`); let dir = parent; - while (dir.length < length) { + while (bytes < length) { // Bytes left after the separator. When this is not the last component, // keep two of them for the separator and first byte of the next one. - const remaining = length - dir.length - 1; + const remaining = length - bytes - 1; const size = remaining <= 255 ? remaining : Math.min(255, remaining - 2); const name = Buffer.alloc(size, "d").toString(); inDirectory(dir, () => fs.mkdirSync(name)); dir = `${dir}/${name}`; + bytes += 1 + size; } return dir; } diff --git a/test/js/bun/resolve/resolve-error.test.ts b/test/js/bun/resolve/resolve-error.test.ts index 06c711bedd7c..dfd4066cea90 100644 --- a/test/js/bun/resolve/resolve-error.test.ts +++ b/test/js/bun/resolve/resolve-error.test.ts @@ -302,14 +302,14 @@ describe.concurrent("candidate paths that do not fit a path buffer", () => { * Runs main.cjs, which prints one JSON value, in a project. package.json and * node_modules/ keep the resolver from auto-installing the bare specifiers. */ - async function run(files: Record) { + async function run(files: Record, args: string[] = []) { using dir = tempDir("resolve-too-long", { "package.json": `{"name": "test", "version": "0.0.0"}`, "node_modules/.keep": "", ...files, }); await using proc = Bun.spawn({ - cmd: [bunExe(), "main.cjs"], + cmd: [bunExe(), ...args, "main.cjs"], env: bunEnv, cwd: String(dir), stdout: "pipe", @@ -364,6 +364,62 @@ describe.concurrent("candidate paths that do not fit a path buffer", () => { }); }); + // Every probe appends an extension to a name in a path buffer. The extension + // lists are user input too. + describe("an extension that does not fit", () => { + const extension = `.${longName}`; + + it("in require.extensions", async () => { + expect( + await run({ + "dir/.keep": "", + "main.cjs": `${helpers} + require.extensions[${JSON.stringify(extension)}] = () => {}; + console.log(JSON.stringify({ + file: outcome(() => require("./missing")), + directory: outcome(() => require("./dir")), + }));`, + }), + ).toEqual({ + result: { file: "MODULE_NOT_FOUND", directory: "MODULE_NOT_FOUND" }, + stderr: "", + exitCode: 0, + }); + }); + + // A Windows command line cannot carry a value this long. + it.skipIf(isWindows)("in --extension-order", async () => { + // --extension-order applies to the project's own files, so the wildcard + // probes are reached through "imports" rather than a package's "exports". + expect( + await run( + { + "package.json": JSON.stringify({ name: "test", version: "0.0.0", imports: { "#src/*": "./src/*" } }), + "dir/.keep": "", + "src/sub/index.js": "", + "main.cjs": `${helpers} + console.log(JSON.stringify({ + file: outcome(() => require("./missing")), + directory: outcome(() => require("./dir")), + wildcardFile: outcome(() => require("#src/missing")), + wildcardDirectory: outcome(() => require("#src/sub")), + }));`, + }, + ["--extension-order", extension, "--extension-order", ".js"], + ), + ).toEqual({ + result: { + file: "MODULE_NOT_FOUND", + directory: "MODULE_NOT_FOUND", + wildcardFile: "MODULE_NOT_FOUND", + wildcardDirectory: "MODULE_NOT_FOUND", + }, + stderr: "", + exitCode: 0, + }); + }); + }); + it("package.json browser field remapping the main field", async () => { expect( await run({ @@ -434,7 +490,7 @@ describe.concurrent("candidate paths that do not fit a path buffer", () => { "main.cjs": `${deepHelpers} const out = {}; for (const length of ${JSON.stringify(lengths)}) { - const file = cwd + "/" + name(length - cwd.length - 1, "f"); + const file = cwd + "/" + name(length - Buffer.byteLength(cwd) - 1, "f"); out[length] = [outcome(() => require(file)), outcome(() => require.resolve(file))]; } console.log(JSON.stringify(out));`, @@ -456,7 +512,7 @@ describe.concurrent("candidate paths that do not fit a path buffer", () => { (async () => { const out = {}; for (const length of ${JSON.stringify(lengths)}) { - const dir = mkdirIn(parent, name(length - parent.length - 1, "e")); + const dir = mkdirIn(parent, name(length - Buffer.byteLength(parent) - 1, "e")); out[length] = [ outcome(() => require(dir)), outcome(() => require.resolve(dir)), From 4c9accf0bc50939a27841091fc283af1369fa316 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:36:05 +0000 Subject: [PATCH 5/9] resolver: bound the browser map lookup of a specifier that does not fit a path buffer check_browser_map builds "./" and "/index" in path buffers before it reads the map. A specifier within six bytes of the buffer size overflowed the second, a longer one the first. Such a specifier is not remapped and falls through to ordinary resolution. --- src/resolver/resolver.rs | 13 ++++++--- test/js/bun/resolve/resolve-error.test.ts | 34 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 81bb017d5135..7bb1ea6c20b9 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -5076,6 +5076,11 @@ impl<'a> Resolver<'a> { return None; } + // Every candidate below is built in a path buffer; `/index` is the longest. + if input_path.len() + BROWSER_MAP_INDEX_SUFFIX.len() >= MAX_PATH_BYTES { + return None; + } + // Normalize the path so we can compare against it without getting confused by "./" let cleaned = self .fs_ref() @@ -6655,6 +6660,9 @@ pub enum BrowserMapPathKind { AbsolutePath, } +/// The longest thing the browser-map lookup appends to a candidate; sizes the guard in `check_browser_map`. +const BROWSER_MAP_INDEX_SUFFIX: &str = const_format::concatcp!(SEP_STR, "index"); + pub(crate) struct BrowserMapPath<'b> { pub(crate) remapped: &'static [u8], pub(crate) cleaned: &'b [u8], @@ -6709,10 +6717,7 @@ impl<'b> BrowserMapPath<'b> { let index_path: &[u8] = { let trimmed = strings::trim_right(path_to_check, &[SEP]); - let parts = [ - trimmed, - const_format::concatcp!(SEP_STR, "index").as_bytes(), - ]; + let parts = [trimmed, BROWSER_MAP_INDEX_SUFFIX.as_bytes()]; ResolvePath::join_string_buf( bufs!(tsconfig_base_url), &parts, diff --git a/test/js/bun/resolve/resolve-error.test.ts b/test/js/bun/resolve/resolve-error.test.ts index dfd4066cea90..db955f4d60d4 100644 --- a/test/js/bun/resolve/resolve-error.test.ts +++ b/test/js/bun/resolve/resolve-error.test.ts @@ -442,6 +442,40 @@ describe.concurrent("candidate paths that do not fit a path buffer", () => { }); }); + it("a bare specifier looked up in a package.json browser map", async () => { + // With a browser map in scope, each specifier gets "./" and "/index" + // variants built in path buffers before the map is read. Six bytes under + // the limit is the first length at which "/index" does not + // fit; one byte less still goes through the map. + const lengths = [MAX_PATH_BYTES - 7, MAX_PATH_BYTES - 6, MAX_PATH_BYTES + 300]; + expect( + await run({ + "package.json": JSON.stringify({ name: "test", version: "0.0.0", browser: { "./a.js": "./b.js" } }), + "main.cjs": ` + const fs = require("fs"); + (async () => { + const out = {}; + for (const length of ${JSON.stringify(lengths)}) { + const specifier = Buffer.alloc(length, "x").toString(); + fs.writeFileSync("entry.js", "import " + JSON.stringify(specifier) + ";"); + const build = await Bun.build({ entrypoints: ["./entry.js"], target: "browser", throw: false }); + out[length] = [build.success, build.logs.map(log => log.message.replace(specifier, ""))]; + } + console.log(JSON.stringify(out)); + })();`, + }), + ).toEqual({ + result: Object.fromEntries( + lengths.map(length => [ + length, + [false, ['Could not resolve: "". Maybe you need to "bun install"?']], + ]), + ), + stderr: "", + exitCode: 0, + }); + }); + it("tsconfig.json extends, baseUrl and paths", async () => { // Each use.cjs is governed by the tsconfig.json next to it. The one with // the unusable "extends" still loads; the other two resolve a bare From c71941de6bfd777a0b857a1045b986dd26941154 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:52:46 +0000 Subject: [PATCH 6/9] test: make the browser map case that fits depend on the map The specifier one byte under the bound is a key of the map and has to be bundled from the file it is remapped to. The longer ones are keys too and stay unresolved. --- test/js/bun/resolve/resolve-error.test.ts | 27 +++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/test/js/bun/resolve/resolve-error.test.ts b/test/js/bun/resolve/resolve-error.test.ts index db955f4d60d4..460d3796b9f1 100644 --- a/test/js/bun/resolve/resolve-error.test.ts +++ b/test/js/bun/resolve/resolve-error.test.ts @@ -446,11 +446,19 @@ describe.concurrent("candidate paths that do not fit a path buffer", () => { // With a browser map in scope, each specifier gets "./" and "/index" // variants built in path buffers before the map is read. Six bytes under // the limit is the first length at which "/index" does not - // fit; one byte less still goes through the map. + // fit. One byte less still goes through the map, which remaps it here; + // the longer ones are in the map too and are not remapped. + const specifier = (length: number) => Buffer.alloc(length, "x").toString(); const lengths = [MAX_PATH_BYTES - 7, MAX_PATH_BYTES - 6, MAX_PATH_BYTES + 300]; + const notResolved = ['Could not resolve: "". Maybe you need to "bun install"?']; expect( await run({ - "package.json": JSON.stringify({ name: "test", version: "0.0.0", browser: { "./a.js": "./b.js" } }), + "package.json": JSON.stringify({ + name: "test", + version: "0.0.0", + browser: Object.fromEntries(lengths.map(length => [specifier(length), "./shim.js"])), + }), + "shim.js": `console.log("remapped by the browser map");`, "main.cjs": ` const fs = require("fs"); (async () => { @@ -459,18 +467,19 @@ describe.concurrent("candidate paths that do not fit a path buffer", () => { const specifier = Buffer.alloc(length, "x").toString(); fs.writeFileSync("entry.js", "import " + JSON.stringify(specifier) + ";"); const build = await Bun.build({ entrypoints: ["./entry.js"], target: "browser", throw: false }); - out[length] = [build.success, build.logs.map(log => log.message.replace(specifier, ""))]; + out[length] = build.success + ? "bundled: " + ((await build.outputs[0].text()).includes("remapped by the browser map") ? "shim.js" : "something else") + : build.logs.map(log => log.message.replace(specifier, "")); } console.log(JSON.stringify(out)); })();`, }), ).toEqual({ - result: Object.fromEntries( - lengths.map(length => [ - length, - [false, ['Could not resolve: "". Maybe you need to "bun install"?']], - ]), - ), + result: { + [MAX_PATH_BYTES - 7]: "bundled: shim.js", + [MAX_PATH_BYTES - 6]: notResolved, + [MAX_PATH_BYTES + 300]: notResolved, + }, stderr: "", exitCode: 0, }); From d55804d8bdfd60886081949253fc1a80b7f97f5a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:00:14 +0000 Subject: [PATCH 7/9] Say when the unchecked abs_buf may be used --- src/resolver/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index a20b21c96fa7..668d93f971c0 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -349,7 +349,8 @@ pub mod fs { } /// Joins `parts` against `top_level_dir` into `buf`, returning the - /// absolute path slice. + /// absolute path slice. Unchecked: only for parts known to fit, such as the + /// `dir` and `base` of a listing entry (see `DirEntry::add_entry_with_store`). pub fn abs_buf<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> &'b [u8] { use bun_paths::resolve_path::{join_abs_string_buf, platform}; join_abs_string_buf::(self.top_level_dir, buf, parts) From 745f99661dd9130816cff6c236b2f783e4b987dd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:02:06 +0000 Subject: [PATCH 8/9] Keep the abs_buf doc to two lines --- src/resolver/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 668d93f971c0..8212248aa66f 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -348,9 +348,8 @@ pub mod fs { MAX_FD.load(Ordering::Relaxed) } - /// Joins `parts` against `top_level_dir` into `buf`, returning the - /// absolute path slice. Unchecked: only for parts known to fit, such as the - /// `dir` and `base` of a listing entry (see `DirEntry::add_entry_with_store`). + /// Joins `parts` against `top_level_dir` into `buf`. Unchecked: only for parts + /// known to fit, like the `dir` and `base` of a listing entry. pub fn abs_buf<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> &'b [u8] { use bun_paths::resolve_path::{join_abs_string_buf, platform}; join_abs_string_buf::(self.top_level_dir, buf, parts) From 4e1a5cf7e3d16482233300fbd90e2a71a6c615f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:04:30 +0000 Subject: [PATCH 9/9] Leave the abs_buf doc as it is on main --- src/resolver/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 8212248aa66f..a20b21c96fa7 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -348,8 +348,8 @@ pub mod fs { MAX_FD.load(Ordering::Relaxed) } - /// Joins `parts` against `top_level_dir` into `buf`. Unchecked: only for parts - /// known to fit, like the `dir` and `base` of a listing entry. + /// Joins `parts` against `top_level_dir` into `buf`, returning the + /// absolute path slice. pub fn abs_buf<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> &'b [u8] { use bun_paths::resolve_path::{join_abs_string_buf, platform}; join_abs_string_buf::(self.top_level_dir, buf, parts)