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