shell(mv): report ENAMETOOLONG instead of panicking when a source name does not fit the path buffers - #37527
shell(mv): report ENAMETOOLONG instead of panicking when a source name does not fit the path buffers#37527robobun wants to merge 1 commit into
Conversation
…e does not fit the path buffers When the target is a directory, move_in_dir normalized basename(src) into a PATH_MAX buffer before checking its length, and on failure joined target/basename through the fixed-size thread-local join buffer. A source name longer than either buffer aborted the process. Check the length before normalizing and join the error path with join_spill.
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: reproduced on the release build (1.4.0-canary.1, Linux x64) with the three shapes in the description; each one aborts the process with |
There was a problem hiding this comment.
LGTM — focused crash fix that moves the length check before the unchecked buffer write and swaps join_z for join_spill on the error path.
What was reviewed:
- Verified
normalize_buf_zandjoin_spillsignatures/semantics inresolve_path.rs;with_pathboxes the slice immediately so the spillVeclifetime is contained to the closure. - Traced the three test shapes (5000-byte name, 3700-byte name into 481-byte dir, multi-operand) through both the Linux 4096-byte and macOS 1024-byte
MAX_PATH_BYTESbranches — each hits the intended check and the join-buffer spill. - Checked the bound
base.len() + 1 >= MAX_PATH_BYTESmatches the old post-check and leaves room fornormalize_buf_z's trailing NUL. - Test follows harness conventions (subprocess isolation for the abort,
Buffer.alloc,tempDir, concurrent pipe drain, exit code asserted last).
Extended reasoning...
Overview
The PR fixes two panic sites in ShellMvBatchedTask::move_in_dir (src/runtime/shell/builtin/mv.rs) reached when mv is given a source name longer than the fixed-size path buffers, and adds a subprocess-isolated regression test in test/js/bun/shell/commands/mv.test.ts.
The fix is a small reordering plus a helper swap: (1) check basename(src).len() against MAX_PATH_BYTES before calling normalize_buf_z (which writes into the buffer with unchecked slice indexing), and (2) build the target/basename string in the error message with resolve_path::join_spill (falls back to a caller-owned Vec) instead of join_z (fixed 4096-byte thread-local buffer). Both helpers already exist and are used the same way elsewhere (rm, Bun.Glob).
Security risks
None. This turns a user-reachable panic (DoS on the Bun process) into a recoverable ENAMETOOLONG error, which is strictly a hardening. No new I/O, no new trust boundaries, no path traversal surface — the only path handling added is for the error message string, which is boxed into bun_sys::Error and printed.
Level of scrutiny
Medium. Shell builtins are user-facing runtime code and the change touches a size-bounds check on argv-sized input, so I traced the bound arithmetic carefully: the pass condition base.len() + 1 < MAX_PATH_BYTES guarantees normalize_buf_z has room for the normalized name plus its NUL terminator (normalization never grows a bare basename beyond +1, and the old code enforced the identical bound post-hoc). The error-path change is confined to a map_err closure; with_path immediately clones the joined slice into a Box, so the spill Vec and thread-local buffer are both safe to drop/reuse afterward. Behavior for every name that fits is byte-identical to before — same normalize call, same do_rename, same error format.
Other factors
- The test is well-constructed per REVIEW.md: runs the crashing input in a spawned child so the unfixed abort fails the assertion instead of killing the runner; covers all three crash shapes plus the multi-operand path; asserts the exact POSIX error string and a positive side-effect (
shortMoved: true); loosens only the Windows errno text; usesBuffer.alloc(n, fill)over.repeat(),tempDir,{...bunEnv}, concurrent pipe drain, and assertsexitCodelast. - The 240-byte directory segments were chosen to stay under
NAME_MAX(255) and keep the nested path creatable under macOS's 1024-bytePATH_MAX, while still overflowing the 4096-byte join buffer when combined with the 3700-byte name — I walked the arithmetic for both platforms. - PR description documents
USE_SYSTEM_BUN=1failure andbun bd testpass, and explicitly scopes out the siblingmkdir/touchpattern and the errno-as-exit-code cleanup (#32278) as tracked separately. - No prior human or bot reviews on this PR to consider.
…tead of aborting (#38379) ### Problem - `Bun.$` `mkdir <operand>` with a relative operand longer than the join buffer, and `touch <operand>` with any operand longer than a path buffer, abort the process: `panic: range end index 5004 out of range for slice of length 4094` (cwd `/tmp`, 5000-byte operand; same for `mkdir -p` and for an absolute `touch` operand). - `mkdir` with an absolute operand that does not fit a path buffer reports the wrong error: `mkdir: /aaa...: No such file or directory` instead of `File name too long`. - Cause, crash: `ShellMkdirTask::run_from_thread_pool` (`src/runtime/shell/builtin/mkdir.rs`) joins a relative operand onto the cwd with `resolve_path::join_z`, which writes into a fixed 4096-byte thread-local buffer; `ShellTouchTask::run_from_thread_pool` (`touch.rs`) joins both kinds of operand with `join_z_buf` into a stack `PathBuffer`. Neither join bounds-checks its output (`normalize_string_generic_tz` in `src/paths/resolve_path.rs`), and the operand comes straight from the user. - Cause, wrong error: mkdir hands the joined path to the node:fs mkdir implementation as a `PathLike`. For JS callers, `Valid::path_string_length` (`src/runtime/node/types.rs`) rejects paths of `MAX_PATH_BYTES` or more up front; for anything longer, `PathLike::slice_z` returns `""` and `mkdir("")` fails with ENOENT. The shell builds the `PathLike` itself and skipped that check. ### Fix - Both builtins join through `join_z_spill`, the existing variant that falls back to a caller-owned `Vec` when the parts would not fit (the same helper `fs.readdir`, `Bun.Glob` and the open `rm` fix #37521 use). The result is the same normalized path as before for every operand that used to work. - `mkdir` then reports `ENAMETOOLONG` itself, naming the path, when the path it is about to create is `MAX_PATH_BYTES` or longer, i.e. the bound node:fs assumes. For a relative operand that is the joined, normalized path, so a long `././.../x` spelling is still created; an absolute operand is passed on as written (as before: normalizing it would change what `..` means across a symlink), so it is bounded as written, which is also what the kernel and coreutils do with such an operand. Both cases are in the test. - `touch` needs no check of its own: it calls `bun_sys::utimens` / `open` with the string as is, and the OS reports `ENAMETOOLONG` for it like for any other operand. It also no longer puts a `PathBuffer` on the worker's stack (about 96 KB on Windows). - Verified with `test/js/bun/shell/commands/mkdir.test.ts` and `touch.test.ts` (the `commands/` directory has one file per builtin; these two had none). Each runs the builtin in a child bun and covers a 5000-byte relative and absolute operand, `mkdir -p`, a 100000-byte operand (longer than the buffer on Windows too), a long operand next to a normal one that must still be created, and a 6000-byte `./` spelling that must still succeed (for mkdir also the absolute form of it, which must be refused as written; POSIX only, since on Windows it fits the buffer). - `USE_SYSTEM_BUN=1 bun test test/js/bun/shell/commands/mkdir.test.ts test/js/bun/shell/commands/touch.test.ts`: both fail, the child aborts with the panic above. Without the new mkdir check, the mkdir cases fail on the ENOENT message instead. - `bun bd test test/js/bun/shell/commands/mkdir.test.ts test/js/bun/shell/commands/touch.test.ts`: pass. `bunshell.test.ts`, `file-io.test.ts`, `commands/rm.test.ts` pass as well; `cargo check` for the Windows and macOS targets is clean. - Windows: the released build aborts on the 5000-byte relative `mkdir` operand there too (the join buffer is 4096 bytes on every platform), and creates the directory for the absolute `././.../x` spelling (the branch this change does not touch below 98302 bytes), which is what the Windows side of the test asserts; both checked on a Windows x64 machine. The Windows lanes of this PR's CI runs pass both test files. - Related open PRs: `rm` (#37521), `mv` (#37527) and `cp` (#38162) fix the same pattern in their builtins; #38162 adds `shell_join_path` helpers in `interpreter.rs` that touch could switch to in one line once either PR lands. #38002 (empty operands) also creates `commands/mkdir.test.ts` and `touch.test.ts`; whichever lands second appends its test to the other's file. `ls -R` joins real directory entries through the same thread-local buffer, but that needs an on-disk tree within PATH_MAX whose entries join past 4096 bytes rather than a long operand, and is left alone here. ### Background - Shell builtins run in-process; `mkdir` and `touch` schedule one task per operand on the worker pool. A task either succeeds or stores a `bun_sys::Error`, which the builtin prints as `<cmd>: <path>: <coreutils message>` and turns into exit code 1 once every task has finished, so one failing operand does not stop the others. - The shell has its own cwd (`$.cwd()`, `cd`), which can differ from the process cwd, so these builtins make relative operands absolute by joining them onto the shell cwd as strings before calling an implementation that takes a path. - `resolve_path::join_z` and `join_z_buf` concatenate and normalize their parts (`.`, `..`, repeated separators) into a fixed buffer without checking that the result fits; the `*_spill` variants take a `Vec` to grow into when the unnormalized length would not fit, and otherwise behave identically. - `PathBuffer` is `[u8; MAX_PATH_BYTES]`: 4096 bytes on Linux, 1024 on macOS, 98302 on Windows. The node:fs layer copies every path it receives into one, which is why its JS entry points reject longer paths before calling it. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/shell/commands/mkdir.test.ts test/js/bun/shell/commands/touch.test.ts <!-- robobun:evidence:end -->
Reproduction
The process aborts (exit 134). Expected:
mv: target/aaaa...: File name too long, a non-zero exit code frommv, and the script keeps running. On Linux the name has to be longer than 4096 bytes, on macOS longer than 1024.A second shape needs a much shorter name. With a 3700-byte source name (the kernel rejects it,
NAME_MAXis 255) and a target directory whose path is about 480 bytes, the abort moves to the error reporting instead:mv a b dir/with one over-long operand hits the same code, so it aborts too.Cause
When the target is an existing directory,
ShellMvBatchedTask::move_in_dir(src/runtime/shell/builtin/mv.rs) does two things with unbounded argv-sized input and fixed-size buffers:basename(src)into aPathBuffer(MAX_PATH_BYTES, 4096 on Linux, 1024 on macOS) and only checks the resulting length afterwards.normalize_bufcopies with plain slice indexing, so a name that does not fit panics before the check runs.renameatfails, it builds thetarget/basenameshown in the error message withresolve_path::join_z, which writes into the 4096-byte thread-local join buffer on every platform. A target path plus source name that do not fit together panic there, even when each one fits the path buffer on its own. Because of this, on Linux every name from 4094 bytes up crashed one way or the other.move_multiple_into_dirgoes through the same function, which is the multi-operand case.Fix
move_in_dirnow checksbasename(src)against the buffer before normalizing. The bound is the same one the old post-check enforced (len + 1 >= MAX_PATH_BYTES): normalizing never grows a name by more than one byte (""becomes"."), so a name that passes still leaves room for the NUL. Names that fail get the sameENAMETOOLONGerror the old check produced, and that error now goes through the same path as a failedrenameat, so it is reported asmv: target/<name>: File name too longlike every other length rather than the path-lessmv: File name too longthe old check printed for names that happened to land exactly on the boundary.The error path joins
targetand the name withjoin_spill, the existing helper that falls back to a caller-ownedVecwhen the thread-local buffer is too small (Bun.Globand thermfix in #37521 use it the same way). The join result is boxed into the error right away, so the spill vector is only live inside the closure and is never allocated when the path fits.The limit enforced here is only the size of
mv's own scratch buffer; whether a shorter name is acceptable to the filesystem (NAME_MAX) is still left to the kernel, as before. Behavior for every name that fits is unchanged: the old and new binaries print identical messages and exit codes for"",.,..,/,dir/, nested and plain files, a missing source and a 1100-byte name. The exit status of a failingmvis still the raw errno; #32278 changes that across builtins, so it is left alone here and the new test only asserts thatmvfailed. The same scratch-buffer pattern inmkdirandtouchis tracked separately.Verification
New case in
test/js/bun/shell/commands/mv.test.ts. It runsmvin a child process (so the abort shows up as a failed assertion instead of taking the runner down) and covers the three shapes above: a 5000-byte name into a directory, a 3700-byte name into a two-level 481-byte directory (fits the Linux path buffer, overflows the join buffer; the same sizes stay creatable under macOS's 1024-bytePATH_MAX), andmv short.txt <5000-byte name> target, which additionally checks thatshort.txtwas moved before the bad operand was reported. Each of the three crashes the unfixed binary on its own. On POSIX it asserts the exactmv: <target>/<name>: File name too longmessage; on Windows, where the errno for such a name is up to the OS, it only asserts thatmvreported a failure.USE_SYSTEM_BUN=1 bun test test/js/bun/shell/commands/mv.test.ts -t "path buffers": fails, the child aborts with the first panic above.bun bd test test/js/bun/shell/commands/mv.test.ts: 13 pass.