From a7714dcb1a241569388997e265fb00cabb85ed3b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:09:34 +0000 Subject: [PATCH 1/2] shell(cp): report ENAMETOOLONG instead of panicking on operands longer than the path buffers The cp builtin joined cwd + operand (and target + basename when copying into a directory) into fixed-size buffers with unchecked joins, so an operand whose resolved path did not fit aborted the process with "range end index N out of range for slice of length 4094". Add shell_join_path / shell_resolve_operand next to the other shared builtin helpers: they join through join_spill into an owned ZBox and fail with ENAMETOOLONG when the normalized result is not shorter than MAX_PATH_BYTES, the bound the node:fs cp implementation requires of the paths it is handed. cp resolves its three paths through them and keeps the resulting ZBoxes as src_absolute / tgt_absolute instead of copying them into Vecs. --- src/runtime/shell/builtin/cp.rs | 55 ++++++------- src/runtime/shell/interpreter.rs | 31 +++++++ test/js/bun/shell/commands/cp.test.ts | 113 +++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 34 deletions(-) diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index bb84b01e0054..4d0d93675b9c 100644 --- a/src/runtime/shell/builtin/cp.rs +++ b/src/runtime/shell/builtin/cp.rs @@ -3,7 +3,8 @@ use bun_paths::resolve_path; use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind}; use crate::shell::interpreter::{ EventLoopHandle, FlagParser, Interpreter, NodeId, OutputSrc, OutputTask, OutputTaskVTable, - ParseFlagResult, ShellTask, parse_flags, unsupported_flag, + ParseFlagResult, ShellTask, parse_flags, shell_join_path, shell_resolve_operand, + unsupported_flag, }; use crate::shell::io_writer::{ChildPtr, WriterTag}; use crate::shell::yield_::Yield; @@ -397,8 +398,8 @@ pub struct ShellCpTask { pub(crate) operands: usize, pub(crate) src: Vec, pub(crate) tgt: Vec, - pub(crate) src_absolute: Option>, - pub(crate) tgt_absolute: Option>, + pub(crate) src_absolute: Option, + pub(crate) tgt_absolute: Option, pub(crate) cwd_path: Vec, /// `cp_on_copy` is invoked from work-pool threads (concurrently per /// copied file) while the directory walk is still fanning out, so the @@ -586,26 +587,16 @@ impl ShellCpTask { /// (), then hands off to /// the node:fs async cp implementation. fn run_from_thread_pool_impl(&mut self) -> Option { - use resolve_path::{Platform, platform}; - - let mut buf2 = bun_paths::PathBuffer::uninit(); - let mut buf3 = bun_paths::PathBuffer::uninit(); // We have to give an absolute path to our cp implementation for it to // work with cwd. - let src: &bun_core::ZStr = if Platform::AUTO.is_absolute(&self.src) { - // `self.src` is the bare argv bytes (no NUL); re-terminate via - // the thread-local join buffer. - resolve_path::join_z::(&[&self.src]) - } else { - resolve_path::join_z::(&[&self.cwd_path, &self.src]) + let src = match shell_resolve_operand(bun_sys::Tag::copyfile, &self.cwd_path, &self.src) { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), }; - let mut tgt: &bun_core::ZStr = if Platform::AUTO.is_absolute(&self.tgt) { - resolve_path::join_z_buf::(buf2.as_mut_slice(), &[&self.tgt]) - } else { - resolve_path::join_z_buf::( - buf2.as_mut_slice(), - &[&self.cwd_path, &self.tgt], - ) + let mut tgt = match shell_resolve_operand(bun_sys::Tag::copyfile, &self.cwd_path, &self.tgt) + { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), }; // Cases: @@ -616,7 +607,7 @@ impl ShellCpTask { // folder -> folder // We need to check dest to see what it is; if it doesn't exist we // need to create it. - let src_is_dir = match Self::is_dir(src) { + let src_is_dir = match Self::is_dir(&src) { Ok(x) => x, Err(e) => return Some(ShellErr::new_sys(&e)), }; @@ -641,7 +632,7 @@ impl ShellCpTask { )); } - let (tgt_is_dir, tgt_exists) = match Self::is_dir(tgt) { + let (tgt_is_dir, tgt_exists) = match Self::is_dir(&tgt) { Ok(is_dir) => (is_dir, true), Err(e) if e.get_errno() == bun_sys::E::ENOENT => { // If it has a trailing directory separator, it's a directory. @@ -659,10 +650,10 @@ impl ShellCpTask { // 2nd synopsis: -R source_files... -> target. if tgt_exists { let basename = resolve_path::basename(src.as_bytes()); - tgt = resolve_path::join_z_buf::( - buf3.as_mut_slice(), - &[tgt.as_bytes(), basename], - ); + tgt = match shell_join_path(bun_sys::Tag::copyfile, &[tgt.as_bytes(), basename]) { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), + }; } else if self.operands == 2 { // source_dir -> new_target_dir. } else { @@ -690,15 +681,15 @@ impl ShellCpTask { )); } let basename = resolve_path::basename(src.as_bytes()); - tgt = resolve_path::join_z_buf::( - buf3.as_mut_slice(), - &[tgt.as_bytes(), basename], - ); + tgt = match shell_join_path(bun_sys::Tag::copyfile, &[tgt.as_bytes(), basename]) { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), + }; _copying_many = true; } - self.src_absolute = Some(src.as_bytes().to_vec()); - self.tgt_absolute = Some(tgt.as_bytes().to_vec()); + self.src_absolute = Some(src); + self.tgt_absolute = Some(tgt); let args = crate::node::fs::args::Cp { src: bun_jsc::node::PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 9ee2ebb91e77..9472b0cdbc26 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2165,6 +2165,37 @@ pub(crate) fn shell_dup(fd: Fd) -> bun_sys::Result { } } +/// Joins argv-sized `parts` into an owned, normalized path. The fs layers the +/// builtins hand their paths to copy them into `MAX_PATH_BYTES` buffers, so a +/// result that would not fit one is `ENAMETOOLONG` (tagged `syscall`, naming +/// the path) here instead. +pub(crate) fn shell_join_path( + syscall: bun_sys::Tag, + parts: &[&[u8]], +) -> bun_sys::Result { + let mut spill = Vec::new(); + let joined = + bun_paths::resolve_path::join_spill::(&mut spill, parts); + if joined.len() >= bun_paths::MAX_PATH_BYTES { + return Err(bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, syscall).with_path(joined)); + } + Ok(bun_core::ZBox::from_bytes(joined)) +} + +/// A builtin's path `operand` made absolute against the shell's `cwd`, via +/// [`shell_join_path`]. +pub(crate) fn shell_resolve_operand( + syscall: bun_sys::Tag, + cwd: &[u8], + operand: &[u8], +) -> bun_sys::Result { + if bun_paths::Platform::AUTO.is_absolute(operand) { + shell_join_path(syscall, &[operand]) + } else { + shell_join_path(syscall, &[cwd, operand]) + } +} + /// Windows-only: rewrite shell paths so POSIX-absolute `/foo` resolves onto /// `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 diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts index 80a28d2c7e2c..afc08f506915 100644 --- a/test/js/bun/shell/commands/cp.test.ts +++ b/test/js/bun/shell/commands/cp.test.ts @@ -1,7 +1,7 @@ import { $ } from "bun"; import { shellInternals } from "bun:internal-for-testing"; -import { describe, expect } from "bun:test"; -import { tempDirWithFiles } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, isWindows, tempDir, tempDirWithFiles } from "harness"; import { bunExe, createTestBuilder } from "../test_builder"; import { sortedShellOutput } from "../util"; const { builtinDisabled } = shellInternals; @@ -173,6 +173,115 @@ describe.if(!builtinDisabled("cp"))("bunshell cp", async () => { }); }); +// The builtin resolved each operand (and `target/basename` when copying into a +// directory) into fixed-size path buffers without checking that the result fit, +// so an operand longer than the buffer aborted the whole process. Runs in a +// child process so the abort shows up as a failed assertion; on POSIX the cp +// builtin is experimental and must be enabled explicitly. +test("operands longer than the path buffers are reported as ENAMETOOLONG", async () => { + using dir = tempDir("cp-long-operands", { + "f": "source", + "target": {}, + "cp-long-operands-fixture.ts": /* ts */ ` + import { $ } from "bun"; + import { existsSync, mkdirSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + + $.nothrow(); + const cwd = process.cwd(); + // Longer than the path buffer on every platform (1024 bytes on macOS, + // 4096 on Linux, 98302 on Windows). + const over = Buffer.alloc(100_000, "a").toString(); + // Applied in order; the deep directory below goes in front because its + // path starts with cwd. + const placeholders: [string, string][] = [[over, ""], [cwd, ""]]; + + async function cp(...args: string[]) { + const { exitCode, stderr } = await $\`cp \${args}\`.quiet(); + let message = stderr.toString(); + for (const [value, placeholder] of placeholders) message = message.replaceAll(value, placeholder); + return { exitCode, stderr: message }; + } + + const names: Record = {}; + const results: Record = {}; + results.longSource = await cp(over, "target"); + results.longTarget = await cp("f", over); + results.longAbsoluteTarget = await cp("f", join(cwd, over)); + results.longSourceAmongOthers = { ...(await cp("f", over, "target")), otherCopied: existsSync("target/f") }; + + // Copying into a directory appends the basename to the directory's path, + // which needs an existing directory just below the limit. Not reachable + // on Windows, where the buffer holds more than any directory path NT + // can address. + if (process.platform !== "win32") { + // A path of PATH_MAX - 1 bytes is the longest one the OS accepts. + const PATH_MAX = process.platform === "linux" ? 4096 : 1024; + // Nest directories until fewer than ~200 bytes remain below that, so + // the basenames straddling the limit are short enough to exist in the + // cwd (NAME_MAX is 255). + const segment = Buffer.alloc(200, "d").toString(); + // Length of the name that makes \`dir/name\` exactly PATH_MAX - 1 bytes. + const atLimitNameLength = (dir: string) => PATH_MAX - 1 - (dir.length + 1); + let deep = join(cwd, segment); + while (atLimitNameLength(join(deep, segment)) >= 1) deep = join(deep, segment); + mkdirSync(deep, { recursive: true }); + placeholders.unshift([deep, ""]); + + const atLimitLength = atLimitNameLength(deep); + names.atLimit = Buffer.alloc(atLimitLength, "s").toString(); + names.onePast = Buffer.alloc(atLimitLength + 1, "t").toString(); + names.onePastDir = Buffer.alloc(atLimitLength + 1, "u").toString(); + writeFileSync(names.atLimit, "at limit"); + writeFileSync(names.onePast, "one past"); + mkdirSync(names.onePastDir); + + results.deepPathLengths = { + atLimit: join(deep, names.atLimit).length, + onePast: join(deep, names.onePast).length, + }; + results.intoDeepDirAtLimit = { + ...(await cp(names.atLimit, deep)), + copied: existsSync(join(deep, names.atLimit)), + }; + results.intoDeepDirOnePast = await cp(names.onePast, deep); + results.recursiveIntoDeepDir = await cp("-R", names.onePastDir, deep); + } + + console.log(JSON.stringify({ names, results })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "cp-long-operands-fixture.ts"], + env: { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" }, + 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 { names, results } = JSON.parse(stdout); + + const tooLong = (path: string) => ({ exitCode: 1, stderr: `cp: File name too long: ${p(path)}\n` }); + const PATH_MAX = process.platform === "linux" ? 4096 : 1024; + expect(results).toEqual({ + longSource: tooLong("/"), + longTarget: tooLong("/"), + longAbsoluteTarget: tooLong("/"), + longSourceAmongOthers: { ...tooLong("/"), otherCopied: true }, + ...(isWindows + ? {} + : { + // The fixture placed deep/ exactly at the OS limit and one byte past it. + deepPathLengths: { atLimit: PATH_MAX - 1, onePast: PATH_MAX }, + intoDeepDirAtLimit: { exitCode: 0, stderr: "", copied: true }, + intoDeepDirOnePast: tooLong(`/${names.onePast}`), + recursiveIntoDeepDir: tooLong(`/${names.onePastDir}`), + }), + }); + expect(exitCode).toBe(0); +}); + function expectSortedOutput(expected: string) { return (stdout: string, tempdir: string) => expect(sortedShellOutput(stdout).join("\n")).toEqual( From 6ff124e40da4d58c4740be29e9ccb4938df68b17 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:49:10 +0000 Subject: [PATCH 2/2] ci: retrigger