Skip to content

shell(windows): report ENAMETOOLONG for operands that do not fit the path buffer - #38205

Open
robobun wants to merge 1 commit into
mainfrom
farm/a068debb/shell-get-path-enametoolong
Open

shell(windows): report ENAMETOOLONG for operands that do not fit the path buffer#38205
robobun wants to merge 1 commit into
mainfrom
farm/a068debb/shell-get-path-enametoolong

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Bounds-check before both writes. A rewritten path that does not fit the buffer returns ENAMETOOLONG naming the operand, tagged with the syscall the caller is about to make (shell_get_path takes a Tag; shell_openat passes open, shell_statat passes fstatat, matching what the POSIX branches' errors carry).
  • The relative branch joins through join_spill (normalizes outside the buffer, on the heap when needed) and copies the result in once its normalized length is known to fit. Operands that are over-long as written but collapse through .. segments resolve today and keep resolving; a check on the raw length would have broken them.
  • Why ENAMETOOLONG is the right answer: PathBuffer is sized to hold the UTF-8 form of any path Windows can open (see Background), so a rewritten path that does not fit is longer than any openable path, and the consumers of this function (bun_sys::open via libuv, open_dir_at_windows_a, bun_sys::stat) already return ENAMETOOLONG for it, which is what the 40 KB to 98 KB operands show today. The check returns the same error one layer earlier instead of overflowing. Everything that resolved before still resolves: the POSIX-absolute check is exact, and the relative branch now checks the final normalized length, which is never more than what join_z_buf needed.
  • User-visible result for the crashing operands becomes what shorter over-long operands and POSIX already produce: cat: <operand>: File name too long, ls: …, mv: …, bun: File name too long: <operand> for redirects, exit 1; [[ -f ]] / [[ -d ]] evaluate false.
  • Verification: new Windows-only test at the end of test/js/bun/shell/bunshell.test.ts drives both branches through cat, ls, mv, builtin redirects and [[ -f ]] / [[ -d ]] in a child process, plus the collapsing operands. On Windows x64 (Server 2019):
    • USE_SYSTEM_BUN=1 bun test test/js/bun/shell/bunshell.test.ts -t "operands longer" with the canary at b7a0431: fails, the child aborts with panic: range end index 100064 out of range for slice of length 98301.
    • bun bd test test/js/bun/shell/bunshell.test.ts -t "operands longer": passes.
    • bun bd test on the rest of bunshell.test.ts, commands/{ls,mv,rm}.test.ts, shell-seq-condexpr, file-io, bunshell-file, bunshell-default: pass (the five tilde_expansion cases in bunshell.test.ts fail identically with the unmodified canary on that machine; they read an env var it does not set).
    • On Linux the test skips; every changed line is inside cfg(windows), so the Linux binary is unchanged.

Background

  • shell_get_path: Windows has no *at() syscalls, so the shell emulates openat(cwd_fd, operand) by asking for the cwd handle's real path (GetFinalPathNameByHandle) and building an absolute path from it. Relative operands are joined under that path, a /x operand is placed on that path's drive root so POSIX-style absolute paths mean something on Windows, and /dev/null becomes NUL. Its output goes straight to bun_sys::open, open_dir_at_windows_a or bun_sys::stat.
  • PathBuffer / MAX_PATH_BYTES: bun's fixed-size UTF-8 scratch buffer for one path. On Windows it is 32767 (the NT limit on a path, in UTF-16 units) times 3 (the most UTF-8 bytes one UTF-16 unit can take) plus one byte for the NUL, which is why "does not fit the buffer" implies "longer than any path the OS accepts".
  • join_z_buf vs join_spill (src/paths/resolve_path.rs): both concatenate path parts and normalize the result (., .., repeated separators). join_z_buf writes into a caller-supplied buffer with no bounds check; join_spill writes into a thread-local buffer, or into a caller-supplied Vec that it grows, so the result can be measured before it is copied into anything fixed-size.
  • bun_sys::Tag: the syscall field of bun_sys::Error, naming the operation the error belongs to. Shell error messages do not print it; it is diagnostic metadata.
Operand length probes on the canary (b7a0431) and on this branch, Windows x64

Relative operand (echo hi > a…, cwd C:\workspace, 12 bytes) and rooted operand (echo hi > /a…, root C:\, 3 bytes):

length canary, relative canary, rooted this branch, both
300 to 30000 No such file or directory No such file or directory unchanged
40000, 98000 File name too long File name too long File name too long
98290 panic (98303 vs 98301) File name too long File name too long
98299, 98300 panic panic File name too long
100000 panic panic File name too long

cat, ls, mv (rooted target) and [[ -f ]] / [[ -d ]] behave the same way as the redirect in each row. a/../ repeated to 100000 bytes followed by an existing file name works on both the canary and this branch for cat, a redirect and [[ -f ]].

…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.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4505dd4a-224e-4158-83ae-abc560f0f15a

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 7d445dc.

📒 Files selected for processing (2)
  • src/runtime/shell/interpreter.rs
  • test/js/bun/shell/bunshell.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on Windows x64 with the canary at b7a0431: cat, ls, mv (rooted target), builtin redirects and [[ -f ]] / [[ -d ]] with a 100000-byte operand abort with panic: range end index ... out of range for slice of length 98302 (rooted operand) or ... of length 98301 (relative operand).
  • Fix in shell_get_path (src/runtime/shell/interpreter.rs): bounds-check both writes and return ENAMETOOLONG naming the operand; the relative join is measured after normalization so ..-collapsing operands keep working.
  • Test: Windows-only case at the end of test/js/bun/shell/bunshell.test.ts. Fails with the canary (child panics), passes with the debug build on Windows; skips on Linux, where none of the changed code is compiled.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — targeted Windows-only crash fix that turns an unchecked buffer write into a proper ENAMETOOLONG.

What was reviewed:

  • Both write paths in shell_get_path now bounds-check against buf.len() with the correct >= (leaves room for the trailing NUL); the relative branch's switch to join_spill writes into JOIN_BUF/spill, never buf, so the borrow of dirpath (a slice of buf) is released before the copy-back and there is no aliasing.
  • All three call sites of shell_get_path (the only ones in the tree) pass the new Tag argument; every changed line is inside #[cfg(windows)].
  • Verified join_spill semantics in resolve_path.rs — it sizes off the raw parts and normalizes into a separate buffer, so the collapsing-.. case the test covers keeps resolving; spill is a local Vec that drops at scope end.
  • Test runs in a child process, drains pipes concurrently, covers both branches × several builtins/redirects/[[ ]], asserts exact messages, and checks the mv-source is untouched and the collapsing redirect actually wrote its file.
Extended reasoning...

Overview

The PR fixes a Windows-only panic in shell_get_path (src/runtime/shell/interpreter.rs), the helper that rewrites shell operands against the tracked cwd before open/stat. Two unchecked writes into a fixed-size PathBuffer (~98 KB) could overflow: a slice copy_from_slice in the POSIX-absolute branch and join_z_buf in the relative branch. Both now return ENAMETOOLONG (tagged with the caller's syscall and the original operand) when the rewritten path would not fit. The relative branch was reworked to join via join_spill (thread-local buffer or a heap Vec) so the normalized length can be checked before copying into buf; this preserves the existing behavior for over-long-as-written operands that collapse via .. to something that fits. The function grew a bun_sys::Tag parameter and all three (Windows-only) call sites were updated. A Windows-only subprocess test in bunshell.test.ts exercises cat/ls/mv/redirects/[[ -f/-d ]] on both branches plus the collapsing case.

Security risks

None. This replaces a user-reachable panic (a DoS on user input) with a recoverable error identical to what shorter over-long operands already produce, and identical to what the downstream syscalls would have returned. No new inputs are accepted; the collapsing-.. path already worked and continues to. All changes are gated to Windows.

Level of scrutiny

Moderate. The change is small (~35 lines of Rust), local to one #[cfg(windows)] helper and its three private call sites, and turns a crash into an error that matches existing behavior one length-bracket down. I checked the off-by-one on both >= guards (one byte reserved for the NUL), confirmed join_spill writes into storage disjoint from buf so dropping the old .to_vec() copy of dirpath is sound, confirmed spill is dropped, and grepped for other callers of shell_get_path (none). I read join_spill/join_z_buf/join_needed in resolve_path.rs to confirm the "normalizes outside the buffer" claim.

Other factors

The test follows the repo's harness conventions: tempDir, Buffer.alloc(n, fill) instead of .repeat, concurrent pipe drain, exact-message assertions, exit-code asserted last, subprocess isolation so a regression fails the test rather than the runner, and side-effect checks (in.txt intact, out.txt written). The PR description documents USE_SYSTEM_BUN=1 failing and bun bd passing on Windows, plus the length probe table showing the boundary. Neither non-Windows platforms nor any code path outside the shell's Windows operand-rewrite is touched.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:04 PM PT - Aug 13th, 2026

@robobun, your commit 7d445dc3bc79073651e1f443793d2e6a6a743edf passed in Build #94780! 🎉


🧪   To try this PR locally:

bunx bun-pr 38205

That installs a local version of the PR into your bun-38205 executable, so you can run:

bun-38205 --bun

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant