diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs
index 6cd439d8eea6..2720213d2014 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,24 @@ 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 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,
+ 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 +1613,14 @@ 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 `_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
+}
+
/// 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 +1634,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 +1672,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 +2566,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 486f92a04d5b..b5384d1f9ae7 100644
--- a/src/runtime/cli/Arguments.rs
+++ b/src/runtime/cli/Arguments.rs
@@ -837,9 +837,10 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result(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
+ 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) {
@@ -965,17 +966,14 @@ 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| {
+ 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/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": `
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);
+ });
+ }
+ });
});