From 7d445dc3bc79073651e1f443793d2e6a6a743edf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:04:34 +0000 Subject: [PATCH] shell(windows): report ENAMETOOLONG for operands that do not fit the path buffer shell_get_path rewrites every builtin, redirect and [[ -f/-d ]] operand against the shell's cwd inside one PathBuffer before the open or stat. Both of its writes were unchecked: a POSIX-absolute operand was spliced after the drive root with a plain slice copy, and a relative operand was joined with join_z_buf, which indexes into the buffer without a bounds check. An operand of roughly MAX_PATH_BYTES (98302 bytes on Windows) or more therefore aborted the process with a slice index panic. Check the length before each write and return ENAMETOOLONG naming the operand, tagged with the syscall the caller is about to make. The relative join is now normalized on the heap first so that the check is against the normalized length: operands that are over-long as written but collapse through `..` segments keep working as before. --- src/runtime/shell/interpreter.rs | 44 ++++++++++++++------ test/js/bun/shell/bunshell.test.ts | 67 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 9ee2ebb91e77..d9f29974fc5c 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2169,15 +2169,22 @@ pub(crate) fn shell_dup(fd: Fd) -> bun_sys::Result { /// `dirfd`'s drive root, `/dev/null` maps to `NUL`, and relative paths are /// joined against `dirfd`'s real path. Returns a NUL-terminated slice that /// either borrows `buf` or is `to` itself. +/// +/// `to` is a command operand and can be arbitrarily long. `buf` fits every +/// path Windows can open, so a rewritten path that does not fit it gets the +/// `ENAMETOOLONG` that `syscall` would have returned for it, naming `to`. #[cfg(windows)] fn shell_get_path<'a>( dirfd: Fd, to: &'a bun_core::ZStr, buf: &'a mut bun_paths::PathBuffer, + syscall: bun_sys::Tag, ) -> bun_sys::Result<&'a bun_core::ZStr> { if to.as_bytes() == b"/dev/null" { return Ok(crate::shell::shell_body::WINDOWS_DEV_NULL); } + let name_too_long = + || bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, syscall).with_path(to.as_bytes()); if bun_paths::Platform::Posix.is_absolute(to.as_bytes()) { let source_root_len = { let dirpath = bun_sys::get_fd_path(dirfd, buf).map_err(|e| e.with_fd(dirfd))?; @@ -2188,6 +2195,9 @@ fn shell_get_path<'a>( // needed. Splice `to[1..]` after the root. let to_tail = &to.as_bytes()[1..]; let end = source_root_len + to_tail.len(); + if end >= buf.len() { + return Err(name_too_long()); + } buf[source_root_len..end].copy_from_slice(to_tail); buf[end] = 0; return Ok(bun_core::ZStr::from_buf(buf.as_slice(), end)); @@ -2195,16 +2205,24 @@ fn shell_get_path<'a>( if bun_paths::Platform::Windows.is_absolute(to.as_bytes()) { return Ok(to); } - // Relative: resolve dirfd → path, then join. - // Note: a single-buffer join would read `dirpath` (a slice of `buf`) - // while writing `buf`; copy `dirpath` - // out first so the mutable borrow on `buf` is exclusive. - let dirpath = bun_sys::get_fd_path(dirfd, buf) - .map_err(|e| e.with_fd(dirfd))? - .to_vec(); - Ok(bun_paths::resolve_path::join_z_buf::< - bun_paths::platform::Auto, - >(&mut buf[..], &[&dirpath, to.as_bytes()])) + // Relative: resolve dirfd → path, then join. `join_z_buf` straight into + // `buf` has no bounds check, so join outside it and copy the result in once + // its normalized length (`..` segments may have shrunk it) is known to fit. + let mut spill = Vec::new(); + let joined = { + let dirpath = bun_sys::get_fd_path(dirfd, buf).map_err(|e| e.with_fd(dirfd))?; + bun_paths::resolve_path::join_spill::( + &mut spill, + &[dirpath, to.as_bytes()], + ) + }; + let len = joined.len(); + if len >= buf.len() { + return Err(name_too_long()); + } + buf[..len].copy_from_slice(joined); + buf[len] = 0; + Ok(bun_core::ZStr::from_buf(buf.as_slice(), len)) } /// Windows: rewrite the path via `shell_get_path` then `bun_sys::stat`, tagging @@ -2215,7 +2233,7 @@ pub(crate) fn shell_statat(dir: Fd, path_: &bun_core::ZStr) -> bun_sys::Result { + using dir = tempDir("shell-long-operand", { "in.txt": "content\n" }); + const script = ` + import { $ } from "bun"; + $.nothrow(); + $.cwd(process.env.SHELL_CWD); + const long = Buffer.alloc(100_000, "a").toString(); + // Longer than the buffer as written, but normalizes back down to a name + // inside the cwd; this worked before and has to keep working. + const collapsing = Buffer.alloc(100_000, "a/../").toString(); + const show = buf => buf.toString().replaceAll(long, "").replaceAll(collapsing, ""); + const run = async promise => { + const { exitCode, stdout, stderr } = await promise.quiet(); + return { exitCode, stdout: show(stdout), stderr: show(stderr) }; + }; + console.log( + JSON.stringify({ + cat: await run($\`cat \${long}\`), + redirect: await run($\`echo hi > \${long}\`), + isFile: await run($\`[[ -f \${long} ]]\`), + rootedCat: await run($\`cat \${"/" + long}\`), + rootedLs: await run($\`ls \${"/" + long}\`), + rootedMvTarget: await run($\`mv in.txt \${"/" + long}\`), + rootedRedirect: await run($\`echo hi > \${"/" + long}\`), + rootedIsDir: await run($\`[[ -d \${"/" + long} ]]\`), + collapsingCat: await run($\`cat \${collapsing + "in.txt"}\`), + collapsingIsFile: await run($\`[[ -f \${collapsing + "in.txt"} ]]\`), + collapsingRedirect: await run($\`echo hi > \${collapsing + "out.txt"}\`), + }), + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, SHELL_CWD: String(dir) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const failed = (stderr: string) => ({ exitCode: 1, stdout: "", stderr }); + const succeeded = (stdout: string) => ({ exitCode: 0, stdout, stderr: "" }); + expect(JSON.parse(stdout)).toEqual({ + cat: failed("cat: : File name too long\n"), + redirect: failed("bun: File name too long: "), + isFile: failed(""), + rootedCat: failed("cat: /: File name too long\n"), + rootedLs: failed("ls: /: File name too long\n"), + rootedMvTarget: failed("mv: /: File name too long\n"), + rootedRedirect: failed("bun: File name too long: /"), + rootedIsDir: failed(""), + collapsingCat: succeeded("content\n"), + collapsingIsFile: succeeded(""), + collapsingRedirect: succeeded(""), + }); + expect(await Bun.file(join(String(dir), "in.txt")).text()).toBe("content\n"); + expect(await Bun.file(join(String(dir), "out.txt")).text()).toBe("hi\n"); + expect(exitCode).toBe(0); +});