From 4c143f0f384a577f5abf09ae3a27efe0815dae27 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:16:52 +0000 Subject: [PATCH 1/3] cli: report ENAMETOOLONG for a --cwd value that does not fit a path buffer instead of aborting --- src/runtime/cli/Arguments.rs | 46 +++++++++++++++++++--------- test/cli/install/bun-run.test.ts | 52 +++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 486f92a04d5b..3b90ac33f039 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -35,6 +35,15 @@ fn slice_to_owned(input: &[&[u8]]) -> Vec> { input.iter().map(|s| Box::<[u8]>::from(*s)).collect() } +fn exit_could_not_change_directory(cwd_arg: &[u8], err: bun_sys::Error) -> ! { + Output::err( + err, + "Could not change directory to \"{}\"\n", + format_args!("{}", BStr::new(cwd_arg)), + ); + Global::exit(1) +} + pub(crate) fn loader_resolver(input: &[u8]) -> crate::Result { let option_loader = bun_ast::Loader::from_string(input).ok_or(crate::Error::InvalidLoader)?; Ok(option_loader.to_api()) @@ -828,27 +837,34 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result>`, // so we dupe into a plain `Box<[u8]>`. let cwd: Box<[u8]> = if let Some(cwd_arg) = args.option(b"--cwd") { - let mut outbuf = PathBuffer::uninit(); + let mut base_buf = PathBuffer::uninit(); // An absolute --cwd needs no base; a relative one still requires a // live cwd (an exe-dir base would silently chdir somewhere else). let base: &[u8] = if bun_paths::is_absolute(cwd_arg) { b"/" } else { - let len = bun_sys::getcwd(&mut *outbuf)?; - &outbuf[..len] + let len = bun_sys::getcwd(&mut *base_buf)?; + &base_buf[..len] }; - let out = resolve_path::join_abs::(base, cwd_arg); - // `chdir` wants a NUL-terminated path; `join_abs` returns a borrowed - // slice into a threadlocal buffer, so dupe-Z once and reuse for both - // the `chdir` arg and the stored `absolute_working_dir`. - let out_z = bun_core::ZBox::from_bytes(out); - if let bun_sys::Result::Err(err) = bun_sys::chdir(&out_z) { - Output::err( - err, - "Could not change directory to \"{}\"\n", - format_args!("{}", BStr::new(cwd_arg)), - ); - Global::exit(1); + // argv can hold a path far longer than any the OS accepts; the unchecked + // joins (`join_abs` & co.) abort when the result overflows their buffer. + let mut out = bun_paths::path_buffer_pool::get(); + let out_cap = out.len() - 1; + let out_len = match resolve_path::join_abs_string_buf_checked::( + base, + &mut out[..out_cap], + &[cwd_arg], + ) { + Some(joined) => joined.len(), + None => exit_could_not_change_directory( + cwd_arg, + bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::chdir), + ), + }; + out[out_len] = 0; + let out_z = bun_core::ZStr::from_buf(&out[..], out_len); + if let bun_sys::Result::Err(err) = bun_sys::chdir(out_z) { + exit_could_not_change_directory(cwd_arg, err); } // Store the post-chdir physical path (mirrors process.chdir) so // process.cwd(), path.resolve, and the resolver agree on one form. diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index 2dac7325e97c..9ea1f91edb2c 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -2,7 +2,7 @@ import { $ } from "bun"; import { describe, expect, it } from "bun:test"; import { chmodSync } from "fs"; import { bunEnv as bunEnv_, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness"; -import { join } from "path"; +import { basename, join } from "path"; const bunEnv = { ...bunEnv_, @@ -329,6 +329,56 @@ describe.concurrent("bun run", () => { expect(exitCode).toBe(0); }); + describe("--cwd longer than the OS path limit", () => { + // Longer than PATH_MAX on every platform (4096 on Linux, 1024 on macOS). + const tooLong = Buffer.alloc(5000, "a").toString(); + + for (const [kind, cwdArg] of [ + ["absolute", "/" + tooLong], + ["relative", tooLong], + ] as const) { + it(`${kind} value is reported as an error instead of crashing`, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cwd", cwdArg, "-e", "console.log('ran')"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain(`Could not change directory to "${cwdArg}"`); + // Windows leaves the verdict on a path this long to SetCurrentDirectoryW. + if (!isWindows) expect(stderr).toContain("ENAMETOOLONG"); + expect(stdout).toBe(""); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(1); + }); + } + + it("value that only normalizes down to a path that fits is honored", async () => { + using dir = tempDir("bun-run-cwd-normalize", { + "subdir/.keep": "", + }); + // 6006 bytes before normalization, "subdir" after it. + const cwdArg = "subdir" + Buffer.alloc(6000, "/../subdir").toString(); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cwd", cwdArg, "-e", "console.log(process.cwd())"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(basename(stdout.trim())).toBe("subdir"); + expect(exitCode).toBe(0); + }); + }); + it("DCE annotations are respected", async () => { using dir = tempDir("test", { "index.ts": ` From f972ed32a63be72e21b8e6f2b6267baabc796ba2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:58:21 +0000 Subject: [PATCH 2/3] cli: resolve --cwd and --tsconfig-override through a spilling join so over-long values reach the syscall instead of aborting --- src/paths/resolve_path.rs | 99 +++++++++++++++++++++++--- src/runtime/cli/Arguments.rs | 70 ++++++++---------- test/cli/run/tsconfig-override.test.ts | 29 +++++++- 3 files changed, 146 insertions(+), 52 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 6cd439d8eea6..dc6ef572b445 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -12,11 +12,15 @@ 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: UnsafeCell<[u8; PARSER_JOIN_INPUT_BUFFER_LEN]> = + const { UnsafeCell::new([0u8; PARSER_JOIN_INPUT_BUFFER_LEN]) }; static PARSER_BUFFER: UnsafeCell<[u8; PARSER_BUFFER_LEN]> = const { UnsafeCell::new([0u8; PARSER_BUFFER_LEN]) }; } +/// Output capacity of [`join_abs_string`] / [`join_abs_string_z`]. +const PARSER_JOIN_INPUT_BUFFER_LEN: usize = 4096; + /// Output capacity of [`normalize_string`]. const PARSER_BUFFER_LEN: usize = 1024; @@ -1379,6 +1383,25 @@ pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::

(cwd, tl_buf_mut(b), parts)) } +/// [`join_abs_string`] (thread-local buffer) when the result is sure to fit, +/// otherwise into `spill` (grown as needed), for `parts` of arbitrary length +/// such as argv. `spill` is untouched in the common case. +pub fn join_abs_string_spill<'a, P: PlatformT>( + cwd: &'a [u8], + spill: &'a mut Vec, + parts: &[&[u8]], +) -> &'a [u8] { + debug_assert!(!matches!(P::P, Platform::Nt)); + let needed = join_abs_needed(cwd.len(), parts); + if needed <= PARSER_JOIN_INPUT_BUFFER_LEN { + return join_abs_string::

(cwd, parts); + } + if spill.len() < needed { + spill.resize(needed, 0); + } + join_abs_string_buf::

(cwd, &mut spill[..], parts) +} + /// Convert parts of potentially invalid file paths into a single valid filpeath /// without querying the filesystem /// This is the equivalent of path.resolve @@ -1591,6 +1614,15 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& normalize_string_node_t::(&temp_buf[0..written], buf) } +/// Buffer length that holds both `_join_abs_string_buf`'s unnormalized +/// concatenation (`cwd`, the separator a bare Windows root gains, and each part +/// behind a separator) and its normalized output, which on Windows can be one +/// byte longer than its input (see [`normalize_string_spill`]). +#[inline] +fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { + parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 +} + /// Scratch buffer for `_join_abs_string_buf`'s unnormalized concatenation. /// Draws from the /// thread-local `path_buffer_pool` for the common case and only heap-allocates @@ -1604,10 +1636,7 @@ enum JoinScratch { impl JoinScratch { #[inline] fn init(base: usize, parts: &[&[u8]]) -> Self { - let mut total = base + 2; - for p in parts { - total += p.len() + 1; - } + let total = join_abs_needed(base, parts); if total <= MAX_PATH_BYTES { JoinScratch::Pooled(crate::path_buffer_pool::get()) } else { @@ -1645,10 +1674,7 @@ pub fn join_abs_string_buf_checked<'a, P: PlatformT>( debug_assert!(!matches!(P::P, Platform::Nt)); // Fast path: size check only — don't allocate a JoinScratch here since the // inner join_abs_string_buf already has its own (avoids doubling stack usage). - let mut total: usize = cwd.len() + 2; - for p in parts { - total += p.len() + 1; - } + let total = join_abs_needed(cwd.len(), parts); if total < buf.len() { return Some(join_abs_string_buf::

(cwd, buf, parts)); } @@ -2542,6 +2568,61 @@ mod tests { ); } + #[test] + fn join_abs_string_spill_leaves_spill_untouched_when_the_result_fits() { + let mut spill = Vec::new(); + let out = join_abs_string_spill::(b"/work", &mut spill, &[b"a/../b.json"]); + assert_eq!(out, b"/work/b.json"); + assert!(spill.is_empty()); + } + + #[test] + fn join_abs_string_spill_spills_a_part_longer_than_the_thread_local_buffer() { + let name = vec![b'a'; PARSER_JOIN_INPUT_BUFFER_LEN + 1]; + let mut expected = b"/work/".to_vec(); + expected.extend_from_slice(&name); + + let mut spill = Vec::new(); + let out = join_abs_string_spill::(b"/work", &mut spill, &[&name]); + assert_eq!(out, &expected[..]); + assert!(!spill.is_empty()); + } + + #[test] + fn join_abs_string_spill_spills_an_absolute_part_and_a_long_cwd_alike() { + let mut abs = b"/".to_vec(); + abs.resize(PARSER_JOIN_INPUT_BUFFER_LEN * 2, b'a'); + let mut spill = Vec::new(); + assert_eq!( + join_abs_string_spill::(b"/", &mut spill, &[&abs]), + &abs[..] + ); + + let mut cwd = b"/".to_vec(); + cwd.resize(PARSER_JOIN_INPUT_BUFFER_LEN, b'c'); + let mut expected = cwd.clone(); + expected.extend_from_slice(b"/x"); + let mut spill = Vec::new(); + assert_eq!( + join_abs_string_spill::(&cwd, &mut spill, &[b"./x"]), + &expected[..] + ); + } + + #[test] + fn join_abs_string_spill_normalizes_a_long_part_that_collapses() { + // `sub/../` repeated past the buffer size resolves back to the cwd. + let mut part = Vec::new(); + while part.len() <= PARSER_JOIN_INPUT_BUFFER_LEN { + part.extend_from_slice(b"sub/../"); + } + part.extend_from_slice(b"sub"); + + let mut spill = Vec::new(); + let out = join_abs_string_spill::(b"/work", &mut spill, &[&part]); + assert_eq!(out, b"/work/sub"); + } + #[test] fn normalize_string_spill_accounts_for_outputs_that_grow_by_one_byte() { // A bare UNC volume exactly as long as the thread-local buffer diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 3b90ac33f039..b31e4ace7b5c 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -35,15 +35,6 @@ fn slice_to_owned(input: &[&[u8]]) -> Vec> { input.iter().map(|s| Box::<[u8]>::from(*s)).collect() } -fn exit_could_not_change_directory(cwd_arg: &[u8], err: bun_sys::Error) -> ! { - Output::err( - err, - "Could not change directory to \"{}\"\n", - format_args!("{}", BStr::new(cwd_arg)), - ); - Global::exit(1) -} - pub(crate) fn loader_resolver(input: &[u8]) -> crate::Result { let option_loader = bun_ast::Loader::from_string(input).ok_or(crate::Error::InvalidLoader)?; Ok(option_loader.to_api()) @@ -837,34 +828,30 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result>`, // so we dupe into a plain `Box<[u8]>`. let cwd: Box<[u8]> = if let Some(cwd_arg) = args.option(b"--cwd") { - let mut base_buf = PathBuffer::uninit(); + let mut outbuf = PathBuffer::uninit(); // An absolute --cwd needs no base; a relative one still requires a // live cwd (an exe-dir base would silently chdir somewhere else). let base: &[u8] = if bun_paths::is_absolute(cwd_arg) { b"/" } else { - let len = bun_sys::getcwd(&mut *base_buf)?; - &base_buf[..len] - }; - // argv can hold a path far longer than any the OS accepts; the unchecked - // joins (`join_abs` & co.) abort when the result overflows their buffer. - let mut out = bun_paths::path_buffer_pool::get(); - let out_cap = out.len() - 1; - let out_len = match resolve_path::join_abs_string_buf_checked::( - base, - &mut out[..out_cap], - &[cwd_arg], - ) { - Some(joined) => joined.len(), - None => exit_could_not_change_directory( - cwd_arg, - bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::chdir), - ), + let len = bun_sys::getcwd(&mut *outbuf)?; + &outbuf[..len] }; - out[out_len] = 0; - let out_z = bun_core::ZStr::from_buf(&out[..], out_len); - if let bun_sys::Result::Err(err) = bun_sys::chdir(out_z) { - exit_could_not_change_directory(cwd_arg, err); + // argv may not fit the thread-local buffer behind `join_abs`; `chdir` + // itself rejects a path longer than the OS limit with ENAMETOOLONG. + let mut spill = Vec::new(); + let out = + resolve_path::join_abs_string_spill::(base, &mut spill, &[cwd_arg]); + // `chdir` wants a NUL-terminated path, so dupe-Z once and reuse for both + // the `chdir` arg and the stored `absolute_working_dir`. + let out_z = bun_core::ZBox::from_bytes(out); + if let bun_sys::Result::Err(err) = bun_sys::chdir(&out_z) { + Output::err( + err, + "Could not change directory to \"{}\"\n", + format_args!("{}", BStr::new(cwd_arg)), + ); + Global::exit(1); } // Store the post-chdir physical path (mirrors process.chdir) so // process.cwd(), path.resolve, and the resolver agree on one form. @@ -981,17 +968,16 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result( - ctx.args.absolute_working_dir.as_deref().unwrap(), - &[ts], - ) - .into(), - ) - } else { - None - }; + opts.tsconfig_override = args.option(b"--tsconfig-override").map(|ts| { + // argv may not fit the thread-local buffer behind `join_abs_string`; + // the resolver reports an over-long path when it fails to open it. + let mut spill = Vec::new(); + Box::from(resolve_path::join_abs_string_spill::( + ctx.args.absolute_working_dir.as_deref().unwrap(), + &mut spill, + &[ts], + )) + }); opts.main_fields = slice_to_owned(args.options(b"--main-fields")); // we never actually supported inject. diff --git a/test/cli/run/tsconfig-override.test.ts b/test/cli/run/tsconfig-override.test.ts index f5e81aa68fdc..43c7052a8882 100644 --- a/test/cli/run/tsconfig-override.test.ts +++ b/test/cli/run/tsconfig-override.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import path from "node:path"; describe("bun run --tsconfig-override", () => { @@ -302,4 +302,31 @@ describe("bun run --tsconfig-override", () => { } expect(exitCode).toBe(0); }); + + describe.concurrent("path longer than the OS path limit", () => { + // Longer than PATH_MAX on every platform (4096 on Linux, 1024 on macOS). + const tooLong = Buffer.alloc(5000, "a").toString(); + + for (const [kind, tsconfigArg] of [ + ["absolute", "/" + tooLong], + ["relative", tooLong], + ] as const) { + test(`${kind} path is reported as unreadable instead of crashing`, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--tsconfig-override", tsconfigArg, "-e", "console.log('ran')"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Windows leaves the verdict on a path this long to the file system. + if (!isWindows) expect(stderr).toContain(`Cannot read file "${path.resolve(tsconfigArg)}": ENAMETOOLONG`); + expect(stdout).toBe("ran\n"); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); + }); + } + }); }); From ed5e9d0b0e1c2b9d6946f827d2e3d9dd47d65c16 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:05:05 +0000 Subject: [PATCH 3/3] paths: shorten the join_abs_string_spill and join_abs_needed docs; drop call-site comments --- src/paths/resolve_path.rs | 12 +++++------- src/runtime/cli/Arguments.rs | 4 ---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index dc6ef572b445..2720213d2014 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1383,9 +1383,8 @@ pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::

(cwd, tl_buf_mut(b), parts)) } -/// [`join_abs_string`] (thread-local buffer) when the result is sure to fit, -/// otherwise into `spill` (grown as needed), for `parts` of arbitrary length -/// such as argv. `spill` is untouched in the common case. +/// [`join_abs_string`] (thread-local buffer) when the result fits, otherwise +/// into `spill` (grown as needed). `spill` is untouched in the common case. pub fn join_abs_string_spill<'a, P: PlatformT>( cwd: &'a [u8], spill: &'a mut Vec, @@ -1614,10 +1613,9 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& normalize_string_node_t::(&temp_buf[0..written], buf) } -/// Buffer length that holds both `_join_abs_string_buf`'s unnormalized -/// concatenation (`cwd`, the separator a bare Windows root gains, and each part -/// behind a separator) and its normalized output, which on Windows can be one -/// byte longer than its input (see [`normalize_string_spill`]). +/// Buffer length that holds `_join_abs_string_buf`'s concatenation of `cwd` and +/// `parts` (one separator each, plus the one a bare Windows root gains) as well +/// as its normalized output. #[inline] fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index b31e4ace7b5c..b5384d1f9ae7 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -837,8 +837,6 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result(base, &mut spill, &[cwd_arg]); @@ -969,8 +967,6 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result( ctx.args.absolute_working_dir.as_deref().unwrap(),