From 81519b2d036b33a03791a9de3d4099c713eb6a6c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:00:01 +0000 Subject: [PATCH 1/8] paths: size join_abs_string/join output buffers to MAX_PATH_BYTES on Windows The thread-local output buffers behind join_abs_string / join_abs_string_z / join / join_z were a platform-agnostic [u8; 4096]. On Windows MAX_PATH_BYTES is 98302, so any caller of FileSystem::abs() (or a direct join_abs_string call) with a path whose normalized length landed in [4096, 98302) aborted the process with a slice-index panic in normalize_string_generic_tz. Size the buffers to max(MAX_PATH_BYTES, 4096) and back them with a lazily heap-allocated pointer (the existing LazyPathBuf pattern) so the Windows .tls section stays at 8 bytes per buffer instead of ~96KB of zeros. Also guard the unbounded FileSystem::abs() fallback in bun:ffi dlopen, which reached the same primitive with no upstream length check on any platform. --- src/paths/resolve_path.rs | 97 ++++++++++++++++--- src/runtime/ffi/ffi_body.rs | 54 +++++------ test/js/bun/ffi/ffi-error-messages.test.ts | 32 +++++- .../bun/http/bun-serve-html-manifest.test.ts | 29 ++++++ 4 files changed, 170 insertions(+), 42 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 416ff9096931..48beddd1ec20 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -12,13 +12,59 @@ use bun_core::{ZStr, strings}; // SAFETY invariant: each buffer has at most one live mutable borrow per thread; // callers must not re-enter the accessor while a previous borrow is alive. thread_local! { - static PARSER_JOIN_INPUT_BUFFER: UnsafeCell<[u8; 4096]> = const { UnsafeCell::new([0u8; 4096]) }; + static PARSER_JOIN_INPUT_BUFFER: LazyJoinBuf = const { LazyJoinBuf::NEW }; static PARSER_BUFFER: UnsafeCell<[u8; 1024]> = const { UnsafeCell::new([0u8; 1024]) }; } +/// Output capacity of [`join_abs_string`] / [`join`]. Must hold any valid host +/// path: on Windows `MAX_PATH_BYTES` is 98 302, so a hard-coded 4096 rejects +/// paths in `[4096, MAX_PATH_BYTES)` with a slice-index panic. On POSIX +/// `MAX_PATH_BYTES <= 4096`; keep 4096 there so callers that pre-date per-part +/// length validation see no behaviour change. +pub(crate) const TL_JOIN_BUF_LEN: usize = if MAX_PATH_BYTES > 4096 { + MAX_PATH_BYTES +} else { + 4096 +}; + +/// Lazily heap-backed `[u8; TL_JOIN_BUF_LEN]` thread-local. 8 bytes in `.tls` +/// instead of `TL_JOIN_BUF_LEN` zeros (PE/COFF has no TLS-BSS; see +/// [`LazyPathBuf`]). +struct LazyJoinBuf(core::cell::Cell<*mut [u8; TL_JOIN_BUF_LEN]>); + +impl LazyJoinBuf { + const NEW: Self = Self(core::cell::Cell::new(core::ptr::null_mut())); + + /// Borrow the thread-local buffer, allocating on first use. Same + /// single-live-borrow-per-thread contract as [`tl_buf_mut`]. + #[inline] + fn get(&self) -> &'static mut [u8; TL_JOIN_BUF_LEN] { + let mut p = self.0.get(); + if p.is_null() { + // SAFETY: `new_zeroed` writes every byte to `0`, a valid `u8`, so + // the value is fully initialized before `assume_init`. + p = bun_core::heap::into_raw(unsafe { + Box::<[u8; TL_JOIN_BUF_LEN]>::new_zeroed().assume_init() + }); + self.0.set(p); + } + // SAFETY: non-null after the init branch; thread-local ⇒ sole accessor. + unsafe { &mut *p } + } +} + +impl Drop for LazyJoinBuf { + fn drop(&mut self) { + let p = self.0.get(); + if !p.is_null() { + // SAFETY: `p` came from `heap::into_raw` in `get()`; sole accessor. + unsafe { drop(bun_core::heap::take(p)) }; + } + } +} + /// Project `&'static mut` into a thread-local `UnsafeCell<[u8; N]>` scratch -/// buffer. One `unsafe` site for all `PARSER_BUFFER` / `PARSER_JOIN_INPUT_BUFFER` -/// / `JOIN_BUF` accessors (nonnull-asref reduction: 6 sites → 1). +/// buffer (`PARSER_BUFFER`). /// /// The `'static` output lifetime is the honest contract: the buffer is /// thread-local storage that lives for the thread's lifetime, and the returned @@ -1354,7 +1400,7 @@ pub fn join_abs<'a, P: PlatformT>(cwd: &'a [u8], part: &[u8]) -> &'a [u8] { // result borrows the thread-local buffer ('static) OR returns `cwd` // directly when `parts.is_empty()`. Return tied to `cwd`'s lifetime ('static: 'a). pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a [u8] { - PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::

(cwd, tl_buf_mut(b), parts)) + PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::

(cwd, b.get(), parts)) } /// Convert parts of potentially invalid file paths into a single valid filpeath @@ -1363,22 +1409,19 @@ pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a /// /// Returned path is stored in a temporary buffer. It must be copied if it needs to be stored. pub fn join_abs_string_z<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a ZStr { - PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf_z::

(cwd, tl_buf_mut(b), parts)) + PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf_z::

(cwd, b.get(), parts)) } -const JOIN_BUF_LEN: usize = 4096; - thread_local! { - pub(crate) static JOIN_BUF: UnsafeCell<[u8; JOIN_BUF_LEN]> = - const { UnsafeCell::new([0u8; JOIN_BUF_LEN]) }; + static JOIN_BUF: LazyJoinBuf = const { LazyJoinBuf::NEW }; } pub fn join(parts: &[&[u8]]) -> &'static [u8] { - JOIN_BUF.with(|b| join_string_buf::

(tl_buf_mut(b), parts)) + JOIN_BUF.with(|b| join_string_buf::

(b.get(), parts)) } pub fn join_z(parts: &[&[u8]]) -> &'static ZStr { - JOIN_BUF.with(|b| join_z_buf::

(tl_buf_mut(b), parts)) + JOIN_BUF.with(|b| join_z_buf::

(b.get(), parts)) } #[inline] @@ -1409,7 +1452,7 @@ pub fn join_z_buf_spill<'a, P: PlatformT>( /// `spill` (grown as needed). `spill` is untouched in the common case. pub fn join_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) -> &'a [u8] { let needed = join_needed(parts); - if needed <= JOIN_BUF_LEN { + if needed <= TL_JOIN_BUF_LEN { return join::

(parts); } if spill.len() < needed { @@ -1422,7 +1465,7 @@ pub fn join_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) -> /// `spill` (grown as needed). `spill` is untouched in the common case. pub fn join_z_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) -> &'a ZStr { let needed = join_needed(parts); - if needed <= JOIN_BUF_LEN { + if needed <= TL_JOIN_BUF_LEN { return join_z::

(parts); } if spill.len() < needed { @@ -2434,3 +2477,31 @@ pub fn posix_to_platform_in_place(path_buffer: &mut [T]) { // `PathChar` is now canonical at `crate::path_char`; re-export for callers // that still path through `resolve_path::PathChar`. pub use crate::PathChar; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tl_join_buf_len_holds_max_path_bytes() { + assert!(TL_JOIN_BUF_LEN >= MAX_PATH_BYTES); + assert!(TL_JOIN_BUF_LEN >= 4096); + } + + #[test] + fn join_abs_string_accepts_path_buffer_length() { + // A part just under MAX_PATH_BYTES must round-trip through the + // thread-local output buffer without panicking. This is the Windows + // `[4096, MAX_PATH_BYTES)` window: valid host path, previously aborted. + let long = vec![b'a'; MAX_PATH_BYTES - 8]; + let cwd: &[u8] = if cfg!(windows) { b"C:\\d" } else { b"/d" }; + let abs: &[u8] = if cfg!(windows) { b"C:\\" } else { b"/" }; + let r = join_abs_string::(cwd, &[abs, &long]); + assert_eq!(r.len(), abs.len() + long.len()); + let r = join_abs_string_z::(cwd, &[abs, &long]); + assert_eq!(r.as_bytes().len(), abs.len() + long.len()); + // And the non-absolute `join` sibling backed by `JOIN_BUF`. + let r = join::(&[abs, &long]); + assert_eq!(r.len(), abs.len() + long.len()); + } +} diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 72dc9e03d333..d5139b4b9a18 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1493,36 +1493,34 @@ impl FFI { let dylib: bun_sys::DynLib = 'brk: { // First try using the name directly - match bun_sys::DynLib::open(name) { - Ok(d) => break 'brk d, - Err(_) => { - let backup_name = Fs::FileSystem::instance().abs(&[name]); - // if that fails, try resolving the filepath relative to the current working directory - match bun_sys::DynLib::open(backup_name) { - Ok(d) => break 'brk d, - Err(_) => { - // Then, if that fails, report an error with the library name and system error - let dlerror_msg = get_dl_error(); - - let mut msg = Vec::new(); - write!( - &mut msg, - "Failed to open library \"{}\": {}", - BStr::new(name), - BStr::new(&dlerror_msg) - ) - .ok(); - let system_error = SystemError { - code: bun_core::String::clone_utf8(b"ERR_DLOPEN_FAILED").into(), - message: bun_core::String::clone_utf8(&msg).into(), - syscall: bun_core::String::clone_utf8(b"dlopen").into(), - ..Default::default() - }; - return system_error.to_error_instance(global); - } - } + if let Ok(d) = bun_sys::DynLib::open(name) { + break 'brk d; + } + // if that fails, try resolving the filepath relative to the current working directory + if name.len() < bun_paths::MAX_PATH_BYTES { + let backup_name = Fs::FileSystem::instance().abs(&[name]); + if let Ok(d) = bun_sys::DynLib::open(backup_name) { + break 'brk d; } } + // Then, if that fails, report an error with the library name and system error + let dlerror_msg = get_dl_error(); + + let mut msg = Vec::new(); + write!( + &mut msg, + "Failed to open library \"{}\": {}", + BStr::new(name), + BStr::new(&dlerror_msg) + ) + .ok(); + let system_error = SystemError { + code: bun_core::String::clone_utf8(b"ERR_DLOPEN_FAILED").into(), + message: bun_core::String::clone_utf8(&msg).into(), + syscall: bun_core::String::clone_utf8(b"dlopen").into(), + ..Default::default() + }; + return system_error.to_error_instance(global); }; let mut size = symbols.values().len(); diff --git a/test/js/bun/ffi/ffi-error-messages.test.ts b/test/js/bun/ffi/ffi-error-messages.test.ts index 23cf35c76454..b83f41c920ac 100644 --- a/test/js/bun/ffi/ffi-error-messages.test.ts +++ b/test/js/bun/ffi/ffi-error-messages.test.ts @@ -1,6 +1,6 @@ import { dlopen, linkSymbols } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { isMusl } from "harness"; +import { bunEnv, bunExe, isMusl } from "harness"; describe("FFI error messages", () => { test("dlopen shows library name when library cannot be opened", () => { @@ -20,6 +20,36 @@ describe("FFI error messages", () => { } }); + // dlopen falls back to FileSystem::abs() when the direct open fails; abs() + // writes into a thread-local buffer that was 4096 bytes on every platform. + // A library path longer than that used to abort with + // panic: range end index 5003 out of range for slice of length 4095 + // instead of reporting the ordinary dlopen error. + test.concurrent.each([5000, 100_000])( + "dlopen with a %d-byte library path reports an error instead of aborting", + async len => { + const prefix = process.platform === "win32" ? "C:\\" : "/"; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { dlopen } = require("bun:ffi");` + + `const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` + + `try { dlopen(name, { f: { args: [], returns: "void" } }); }` + + `catch (e) { console.log("CAUGHT", e.code || e.name, String(e.message).slice(0, 30)); }`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/), + stderr: "", + }); + expect(exitCode).toBe(0); + }, + ); + test("dlopen shows which symbol is missing when symbol not found", () => { // Use appropriate system library for the platform const libName = diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts index a1e5dbe29720..853859ac4ea8 100644 --- a/test/js/bun/http/bun-serve-html-manifest.test.ts +++ b/test/js/bun/http/bun-serve-html-manifest.test.ts @@ -266,6 +266,35 @@ describe("Bun.serve HTML manifest", () => { expect(out).toContain("SUCCESS: Manifest validation failed as expected"); }); + it("rejects a manifest file path longer than the join buffer without aborting", async () => { + // Windows MAX_PATH_BYTES (98302) >> 4096, so a 5000-byte path passes the + // ENAMETOOLONG guard and reaches FileSystem::abs() whose output buffer was + // 4096 bytes: process used to abort with a slice-index panic. On POSIX + // MAX_PATH_BYTES <= 4096, so the guard rejects it with ENAMETOOLONG first. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const long = (process.platform === "win32" ? "C:\\\\" : "/") + Buffer.alloc(5000, "a").toString();` + + `try {` + + ` const s = Bun.serve({ port: 0, routes: { "/": {` + + ` index: "./index.html",` + + ` files: [{ input: "index.html", path: long, loader: "html", isEntry: true,` + + ` headers: { etag: "x", "content-type": "text/html" } }],` + + ` } } });` + + ` s.stop();` + + ` console.log("CAUGHT no-throw");` + + `} catch (e) { console.log("CAUGHT", e.code || e.name); }`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toMatch(/^CAUGHT (ENOENT|ENAMETOOLONG|ERR_FILE_NOT_FOUND|no-throw)$/); + expect(exitCode).toBe(0); + }); + it("serves manifest with proper headers", async () => { await using dir = tempDir("serve-html-headers", { "server.ts": ` From 8883df202247a6bf3862cb50594173cc4319f637 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:06:39 +0000 Subject: [PATCH 2/8] test: accept ERR_INVALID_ARG_TYPE on Windows for long manifest file path --- test/js/bun/http/bun-serve-html-manifest.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts index 853859ac4ea8..82e84767ebac 100644 --- a/test/js/bun/http/bun-serve-html-manifest.test.ts +++ b/test/js/bun/http/bun-serve-html-manifest.test.ts @@ -291,7 +291,9 @@ describe("Bun.serve HTML manifest", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout.trim()).toMatch(/^CAUGHT (ENOENT|ENAMETOOLONG|ERR_FILE_NOT_FOUND|no-throw)$/); + // ENAMETOOLONG on POSIX (the per-part guard fires); ERR_INVALID_ARG_TYPE + // on Windows (abs() succeeds; route setup then rejects the missing file). + expect(stdout.trim()).toMatch(/^CAUGHT (ENAMETOOLONG|ERR_INVALID_ARG_TYPE)$/); expect(exitCode).toBe(0); }); From a11869f241f35f54311431c425aae8389cad67f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:07:53 +0000 Subject: [PATCH 3/8] test: drop stderr assertion for debug_warn noise on Windows --- test/js/bun/http/bun-serve-html-manifest.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts index 82e84767ebac..20734f07881e 100644 --- a/test/js/bun/http/bun-serve-html-manifest.test.ts +++ b/test/js/bun/http/bun-serve-html-manifest.test.ts @@ -290,9 +290,9 @@ describe("Bun.serve HTML manifest", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); // ENAMETOOLONG on POSIX (the per-part guard fires); ERR_INVALID_ARG_TYPE // on Windows (abs() succeeds; route setup then rejects the missing file). + if (!stdout.startsWith("CAUGHT")) console.error(stderr); expect(stdout.trim()).toMatch(/^CAUGHT (ENAMETOOLONG|ERR_INVALID_ARG_TYPE)$/); expect(exitCode).toBe(0); }); From 04a8f090a4d93333efa6bee95a8c98e1efcbc62a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:53:30 +0000 Subject: [PATCH 4/8] ffi: report ENAMETOOLONG instead of stale dlerror when the fallback is skipped --- src/runtime/ffi/ffi_body.rs | 22 +++++++++++++++------- test/js/bun/ffi/ffi-error-messages.test.ts | 7 ++++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index d5139b4b9a18..1ecd00bb80cd 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1493,18 +1493,26 @@ impl FFI { let dylib: bun_sys::DynLib = 'brk: { // First try using the name directly - if let Ok(d) = bun_sys::DynLib::open(name) { - break 'brk d; - } + let mut last_err = match bun_sys::DynLib::open(name) { + Ok(d) => break 'brk d, + Err(e) => e, + }; // if that fails, try resolving the filepath relative to the current working directory if name.len() < bun_paths::MAX_PATH_BYTES { let backup_name = Fs::FileSystem::instance().abs(&[name]); - if let Ok(d) = bun_sys::DynLib::open(backup_name) { - break 'brk d; + match bun_sys::DynLib::open(backup_name) { + Ok(d) => break 'brk d, + Err(e) => last_err = e, } } - // Then, if that fails, report an error with the library name and system error - let dlerror_msg = get_dl_error(); + // Then, if that fails, report an error with the library name and + // system error. `DynLib::open` returns ENAMETOOLONG without calling + // the loader, so dlerror()/GetLastError() would be stale there. + let dlerror_msg = if last_err == bun_errno::SystemErrno::ENAMETOOLONG { + Box::<[u8]>::from(b"file name too long".as_slice()) + } else { + get_dl_error() + }; let mut msg = Vec::new(); write!( diff --git a/test/js/bun/ffi/ffi-error-messages.test.ts b/test/js/bun/ffi/ffi-error-messages.test.ts index b83f41c920ac..219c23296d7a 100644 --- a/test/js/bun/ffi/ffi-error-messages.test.ts +++ b/test/js/bun/ffi/ffi-error-messages.test.ts @@ -36,7 +36,8 @@ describe("FFI error messages", () => { `const { dlopen } = require("bun:ffi");` + `const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` + `try { dlopen(name, { f: { args: [], returns: "void" } }); }` + - `catch (e) { console.log("CAUGHT", e.code || e.name, String(e.message).slice(0, 30)); }`, + `catch (e) { const m = String(e.message);` + + ` console.log("CAUGHT", e.code || e.name, m.slice(0, 30), "|", m.slice(-25)); }`, ], env: bunEnv, stderr: "pipe", @@ -46,6 +47,10 @@ describe("FFI error messages", () => { stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/), stderr: "", }); + // 100k exceeds every platform's MAX_PATH_BYTES: the fallback is skipped + // and the reported reason is the ENAMETOOLONG from DynLib::open, not a + // stale dlerror()/GetLastError() ("unknown error" / "error code 0"). + if (len === 100_000) expect(stdout).toContain("file name too long"); expect(exitCode).toBe(0); }, ); From 6f4498b2d634691b7dd926fb8d484f0dfc778f8c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:56:35 +0000 Subject: [PATCH 5/8] docs: tighten comments flagged by the comment linter --- src/runtime/ffi/ffi_body.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 1ecd00bb80cd..557955887a0b 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1505,9 +1505,7 @@ impl FFI { Err(e) => last_err = e, } } - // Then, if that fails, report an error with the library name and - // system error. `DynLib::open` returns ENAMETOOLONG without calling - // the loader, so dlerror()/GetLastError() would be stale there. + // ENAMETOOLONG never reached the loader: dlerror()/GetLastError() is stale. let dlerror_msg = if last_err == bun_errno::SystemErrno::ENAMETOOLONG { Box::<[u8]>::from(b"file name too long".as_slice()) } else { From cffce93a31b093eea8221c73b131d3a4641b4aef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:57:22 +0000 Subject: [PATCH 6/8] docs: tighten TL_JOIN_BUF_LEN comment --- src/paths/resolve_path.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 48beddd1ec20..513fb3a29431 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -16,11 +16,8 @@ thread_local! { static PARSER_BUFFER: UnsafeCell<[u8; 1024]> = const { UnsafeCell::new([0u8; 1024]) }; } -/// Output capacity of [`join_abs_string`] / [`join`]. Must hold any valid host -/// path: on Windows `MAX_PATH_BYTES` is 98 302, so a hard-coded 4096 rejects -/// paths in `[4096, MAX_PATH_BYTES)` with a slice-index panic. On POSIX -/// `MAX_PATH_BYTES <= 4096`; keep 4096 there so callers that pre-date per-part -/// length validation see no behaviour change. +/// Output capacity of [`join_abs_string`] / [`join`]: at least `MAX_PATH_BYTES` +/// (98 302 on Windows) so any valid host path fits; floored at 4096 on POSIX. pub(crate) const TL_JOIN_BUF_LEN: usize = if MAX_PATH_BYTES > 4096 { MAX_PATH_BYTES } else { @@ -2490,9 +2487,7 @@ mod tests { #[test] fn join_abs_string_accepts_path_buffer_length() { - // A part just under MAX_PATH_BYTES must round-trip through the - // thread-local output buffer without panicking. This is the Windows - // `[4096, MAX_PATH_BYTES)` window: valid host path, previously aborted. + // A valid host path just under MAX_PATH_BYTES round-trips through the TL buffer. let long = vec![b'a'; MAX_PATH_BYTES - 8]; let cwd: &[u8] = if cfg!(windows) { b"C:\\d" } else { b"/d" }; let abs: &[u8] = if cfg!(windows) { b"C:\\" } else { b"/" }; From a6ff4956012cc360429f784843e1ce0782bd991a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:03:58 +0000 Subject: [PATCH 7/8] ffi: use abs_buf_checked for the dlopen fallback; cover relative library names The previous name.len() guard ignored top_level_dir, so a relative library name whose cwd+name exceeded the join buffer still reached the slice-index panic. abs_buf_checked returns None on overflow regardless of how the length is reached. Also swap the open-coded Box::new_zeroed().assume_init() for the existing bun_core::boxed_zeroed helper. --- src/paths/resolve_path.rs | 11 ++-- src/runtime/ffi/ffi_body.rs | 6 ++- test/js/bun/ffi/ffi-error-messages.test.ts | 63 +++++++++++----------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 513fb3a29431..8fbfd5ee54b2 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -32,20 +32,15 @@ struct LazyJoinBuf(core::cell::Cell<*mut [u8; TL_JOIN_BUF_LEN]>); impl LazyJoinBuf { const NEW: Self = Self(core::cell::Cell::new(core::ptr::null_mut())); - /// Borrow the thread-local buffer, allocating on first use. Same - /// single-live-borrow-per-thread contract as [`tl_buf_mut`]. + /// Same single-live-borrow-per-thread contract as [`tl_buf_mut`]. #[inline] fn get(&self) -> &'static mut [u8; TL_JOIN_BUF_LEN] { let mut p = self.0.get(); if p.is_null() { - // SAFETY: `new_zeroed` writes every byte to `0`, a valid `u8`, so - // the value is fully initialized before `assume_init`. - p = bun_core::heap::into_raw(unsafe { - Box::<[u8; TL_JOIN_BUF_LEN]>::new_zeroed().assume_init() - }); + p = bun_core::heap::into_raw(bun_core::boxed_zeroed::<[u8; TL_JOIN_BUF_LEN]>()); self.0.set(p); } - // SAFETY: non-null after the init branch; thread-local ⇒ sole accessor. + // SAFETY: non-null after init; thread-local ⇒ sole accessor. unsafe { &mut *p } } } diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 557955887a0b..59f89503a115 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1498,8 +1498,10 @@ impl FFI { Err(e) => e, }; // if that fails, try resolving the filepath relative to the current working directory - if name.len() < bun_paths::MAX_PATH_BYTES { - let backup_name = Fs::FileSystem::instance().abs(&[name]); + let mut backup_buf = bun_paths::path_buffer_pool::get(); + if let Some(backup_name) = + Fs::FileSystem::instance().abs_buf_checked(&[name], &mut backup_buf[..]) + { match bun_sys::DynLib::open(backup_name) { Ok(d) => break 'brk d, Err(e) => last_err = e, diff --git a/test/js/bun/ffi/ffi-error-messages.test.ts b/test/js/bun/ffi/ffi-error-messages.test.ts index 219c23296d7a..61bdce506e29 100644 --- a/test/js/bun/ffi/ffi-error-messages.test.ts +++ b/test/js/bun/ffi/ffi-error-messages.test.ts @@ -24,36 +24,39 @@ describe("FFI error messages", () => { // writes into a thread-local buffer that was 4096 bytes on every platform. // A library path longer than that used to abort with // panic: range end index 5003 out of range for slice of length 4095 - // instead of reporting the ordinary dlopen error. - test.concurrent.each([5000, 100_000])( - "dlopen with a %d-byte library path reports an error instead of aborting", - async len => { - const prefix = process.platform === "win32" ? "C:\\" : "/"; - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const { dlopen } = require("bun:ffi");` + - `const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` + - `try { dlopen(name, { f: { args: [], returns: "void" } }); }` + - `catch (e) { const m = String(e.message);` + - ` console.log("CAUGHT", e.code || e.name, m.slice(0, 30), "|", m.slice(-25)); }`, - ], - env: bunEnv, - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr }).toEqual({ - stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/), - stderr: "", - }); - // 100k exceeds every platform's MAX_PATH_BYTES: the fallback is skipped - // and the reported reason is the ENAMETOOLONG from DynLib::open, not a - // stale dlerror()/GetLastError() ("unknown error" / "error code 0"). - if (len === 100_000) expect(stdout).toContain("file name too long"); - expect(exitCode).toBe(0); - }, - ); + // instead of reporting the ordinary dlopen error. The "relative" row joins + // against cwd, so the overflow point is roughly MAX_PATH_BYTES - cwd.len(). + test.concurrent.each([ + ["absolute", 5000], + ["absolute", 100_000], + ["relative", 4090], + ["relative", 100_000], + ] as const)("dlopen with a %s %d-byte library path reports an error instead of aborting", async (kind, len) => { + const prefix = kind === "relative" ? "" : process.platform === "win32" ? "C:\\" : "/"; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { dlopen } = require("bun:ffi");` + + `const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` + + `try { dlopen(name, { f: { args: [], returns: "void" } }); }` + + `catch (e) { const m = String(e.message);` + + ` console.log("CAUGHT", e.code || e.name, m.slice(0, 30), "|", m.slice(-25)); }`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/), + stderr: "", + }); + // 100k exceeds every platform's MAX_PATH_BYTES: the fallback is skipped + // and the reported reason is the ENAMETOOLONG from DynLib::open, not a + // stale dlerror()/GetLastError() ("unknown error" / "error code 0"). + if (len === 100_000) expect(stdout).toContain("file name too long"); + expect(exitCode).toBe(0); + }); test("dlopen shows which symbol is missing when symbol not found", () => { // Use appropriate system library for the platform From cd9b94943ca3c7ba00a820b1afda943a8c286532 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:22:15 +0000 Subject: [PATCH 8/8] ffi: key the stale-dlerror fallback on name length, not last_err When the first DynLib::open reached the loader (name.len() < MAX_PATH_BYTES), dlerror()/GetLastError() is fresh regardless of whether the fallback short-circuited. Keying on last_err could discard a rich dlerror on macOS/BSD when cwd + a short library name exceeded 1024 bytes. --- src/runtime/ffi/ffi_body.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 59f89503a115..96744a35fa0c 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1493,22 +1493,20 @@ impl FFI { let dylib: bun_sys::DynLib = 'brk: { // First try using the name directly - let mut last_err = match bun_sys::DynLib::open(name) { - Ok(d) => break 'brk d, - Err(e) => e, - }; + if let Ok(d) = bun_sys::DynLib::open(name) { + break 'brk d; + } // if that fails, try resolving the filepath relative to the current working directory let mut backup_buf = bun_paths::path_buffer_pool::get(); if let Some(backup_name) = Fs::FileSystem::instance().abs_buf_checked(&[name], &mut backup_buf[..]) + && let Ok(d) = bun_sys::DynLib::open(backup_name) { - match bun_sys::DynLib::open(backup_name) { - Ok(d) => break 'brk d, - Err(e) => last_err = e, - } + break 'brk d; } - // ENAMETOOLONG never reached the loader: dlerror()/GetLastError() is stale. - let dlerror_msg = if last_err == bun_errno::SystemErrno::ENAMETOOLONG { + // `DynLib::open` short-circuits ENAMETOOLONG without calling the + // loader, so dlerror()/GetLastError() is stale iff `name` was too long. + let dlerror_msg = if name.len() >= bun_paths::MAX_PATH_BYTES { Box::<[u8]>::from(b"file name too long".as_slice()) } else { get_dl_error()