Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 88 additions & 9 deletions src/paths/resolve_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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::<P>(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.
Comment thread
robobun marked this conversation as resolved.
pub fn join_abs_string_spill<'a, P: PlatformT>(
cwd: &'a [u8],
spill: &'a mut Vec<u8>,
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::<P>(cwd, parts);
}
if spill.len() < needed {
spill.resize(needed, 0);
}
join_abs_string_buf::<P>(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
Expand Down Expand Up @@ -1591,6 +1613,14 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[&
normalize_string_node_t::<T, P>(&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.
Comment thread
robobun marked this conversation as resolved.
#[inline]
fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize {
parts.iter().map(|p| p.len() + 1).sum::<usize>() + 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
Expand All @@ -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 {
Expand Down Expand Up @@ -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::<P>(cwd, buf, parts));
}
Expand Down Expand Up @@ -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::<platform::Posix>(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::<platform::Posix>(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::<platform::Posix>(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::<platform::Posix>(&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::<platform::Posix>(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
Expand Down
26 changes: 12 additions & 14 deletions src/runtime/cli/Arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -837,9 +837,10 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result<api::Tra
let len = bun_sys::getcwd(&mut *outbuf)?;
&outbuf[..len]
};
let out = resolve_path::join_abs::<platform::Loose>(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::<platform::Loose>(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) {
Expand Down Expand Up @@ -965,17 +966,14 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result<api::Tra
});
}

opts.tsconfig_override = if let Some(ts) = args.option(b"--tsconfig-override") {
Some(
resolve_path::join_abs_string::<platform::Auto>(
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::<platform::Auto>(
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.
Expand Down
52 changes: 51 additions & 1 deletion test/cli/install/bun-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_,
Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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": `
Expand Down
29 changes: 28 additions & 1 deletion test/cli/run/tsconfig-override.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
}
});
});
Loading