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); + }); + } + }); });