Skip to content

shell: fail the command when a > ${buf} redirect overflows the target Buffer - #34698

Open
robobun wants to merge 6 commits into
mainfrom
claude/farm/ada0ba20/shell-arraybuf-enospc
Open

shell: fail the command when a > ${buf} redirect overflows the target Buffer#34698
robobun wants to merge 6 commits into
mainfrom
claude/farm/ada0ba20/shell-arraybuf-enospc

Conversation

@robobun

@robobun robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Redirecting shell output into a JS Buffer/TypedArray that is too small to hold it silently truncates the data and exits 0. The script has no way to detect that its capture was cut short.

Repro

const buf = Buffer.alloc(4);
const r = await Bun.$`echo hello world > ${buf}`.nothrow().quiet();
console.log(r.exitCode, JSON.stringify(buf.toString("latin1")));
// 0 "hell"   <- 12 bytes into a 4-byte buffer, exit 0, no error

Same for the external-command path (/bin/echo hello world > ${buf} -> exit 0, "hell").

For comparison, an fd target already fails the command:

await Bun.$`echo hello world > /dev/full`.nothrow();  // nonzero exit

Cause

Builtin path: BuiltinIO::write_no_io_to returned Ok(short_len) for a partial write into an ArrayBuf target (only returning Err(ENOSPC) once the buffer was already full on entry), and every builtin call site discards the result (let _ = Builtin::write_no_io(...)) before done(interp, cmd, 0).

Subprocess path: BufferedOutput::append silently clipped to the remaining space (with a // TODO: We should probably throw error here? comment) and nothing propagated to the Cmd's exit code.

Fix

Builtin path: write_no_io_to now returns Err(ENOSPC) when not all bytes fit (after writing what does fit). Builtin::write_no_io latches the first such error on the Builtin, and Builtin::done turns an otherwise-0 exit into 1 with a "<cmd>: No space left on device" stderr message (when stderr is captured). This covers every builtin that writes via write_no_io (echo, seq, pwd, which, basename, dirname, ls, mkdir, touch, cat, cp, rm, export) without touching each call site.

Subprocess path: BufferedOutput::ArrayBuffer tracks overflow in append; PipeReader::try_signal_done_to_cmd surfaces it via the existing SystemError path in Cmd::buffered_output_close, which sets the exit code to the errno (ENOSPC).

Verification

# before
(fail) builtin                     # exit 0, expected 1
(fail) builtin: `|| rhs` runs      # rhs never ran
(fail) subprocess                  # exit 0
(fail) subprocess: `|| rhs` runs   # rhs never ran

# after
5 pass, 0 fail

The separate > /dev/full exit-code oddity (65508 instead of a small positive value) is the fd IOWriter path and is handled by #32278; not touched here.


no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/shell/bunshell.test.ts test/js/bun/shell/leak.test.ts

Redirecting into a JS Buffer/TypedArray that is too small to hold the
output (`echo hello world > ${Buffer.alloc(4)}`) silently truncated
the output and exited 0, for both builtins and external commands. The
ENOSPC error was computed and then discarded.

Builtin path: `BuiltinIO::write_no_io_to` now returns ENOSPC on a
short write (after writing what fits), `Builtin::write_no_io` latches
the first such error on the `Builtin`, and `Builtin::done` turns an
otherwise-0 exit into 1 with a "<cmd>: No space left on device"
stderr message (when stderr is captured).

Subprocess path: `BufferedOutput::ArrayBuffer` tracks overflow in
`append`, and `PipeReader::try_signal_done_to_cmd` surfaces it via
the existing SystemError path in `Cmd::buffered_output_close`, which
sets the exit code to the errno.
@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status: tests in test/js/bun/shell/bunshell.test.ts (describe redirect into a too-small Buffer fails the command). 6 fail on main, 9 pass with this change. Full bunshell.test.ts + yield.test.ts + leak.test.ts green on all CI lanes.

CI (build 77906): all shell tests pass on every lane. Remaining red is unrelated: test-net-connect-memleak.js (ubuntu x64) and test-gc-http-client-connaborted.js (debian x64) are Node-compat net/http tests failing on main, plus a step-failed-outside-runner infra failure on darwin 26 aarch64. Review bot LGTM on 9bc535c. Ready for a maintainer.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:13 AM PT - Jul 22nd, 2026

@robobun, your commit 9bc535c has 3 failures in Build #77906 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34698

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

bun-34698 --bun

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The shell now detects output truncation for fixed-size buffers, preserves partial writes, reports ENOSPC, and propagates failures to builtin and subprocess exit status. Tests cover builtin redirection, fallbacks, exact-fit buffers, and subprocess output.

Buffer overflow errors

Layer / File(s) Summary
Builtin partial writes and error latching
src/runtime/shell/Builtin.rs
Builtin writes copy available capacity, retain the first write error, and force exit code 1 when output failure would otherwise produce success.
Subprocess buffer overflow propagation
src/runtime/shell/subproc.rs
Array-buffer output tracks truncation and reports overflow as an ENOSPC system error.
Buffer redirection coverage
test/js/bun/shell/bunshell.test.ts
Tests verify undersized and exact-fit buffers, fallback execution, partial contents, stderr, and exit codes.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: failing overflowing Buffer redirects instead of silently succeeding.
Description check ✅ Passed The PR description covers purpose, repro, cause, fix, and verification, with only minor heading mismatches from the template.

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

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/shell/bunshell.test.ts`:
- Around line 466-469: Extend the “redirect into a too-small Buffer fails the
command” tests to cover standalone Uint8Array redirects, using a view with a
nonzero byte offset. Add both overflow and exact-fit cases, asserting overflow
returns a nonzero exit code while exact-fit succeeds with the expected bytes,
and preserve the existing Buffer coverage.
- Around line 503-508: Strengthen the assertions in both subprocess tests at
test/js/bun/shell/bunshell.test.ts:503-508 and
test/js/bun/shell/bunshell.test.ts:510-517 to verify the specific ENOSPC-derived
exit result and expected stderr error, replacing the generic nonzero exit check.
Ensure the fallback case asserts the same ENOSPC failure while preserving its
existing stderr behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9d9922be-7e74-469f-a41f-af083e14cba5

📥 Commits

Reviewing files that changed from the base of the PR and between 98f6649 and 2a0c3c8.

📒 Files selected for processing (3)
  • src/runtime/shell/Builtin.rs
  • src/runtime/shell/subproc.rs
  • test/js/bun/shell/bunshell.test.ts

Comment thread test/js/bun/shell/bunshell.test.ts
Comment thread test/js/bun/shell/bunshell.test.ts
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. shell: fold write_no_io ENOSPC into builtin exit codes #34699 - Also folds write_no_io ENOSPC into builtin exit codes when redirecting to a too-small Buffer, modifying the same write_no_io_to code path in Builtin.rs with tests for ArrayBuffer overflow

🤖 Generated with Claude Code

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the duplicate-finder flag for #34699.

Both PRs fix the same write_no_io_to short-write defect in Builtin.rs, but they diverge from there:

this PR (#34698) #34699
opened 12:16:18Z 12:18:31Z
builtin coverage centralized in Builtin::done (latches first write_no_io error), so every builtin that calls write_no_io is covered including ls/mkdir -v/touch -v/cp -v/rm -v per-call-site helper, covers echo/seq/pwd/basename/dirname/export/which/cat-stdin; verbose paths left unfixed by design
subprocess path (/bin/echo hi > ${buf}) fixed via BufferedOutput::ArrayBuffer overflow tracking not addressed
stderr diagnostic "<cmd>: No space left on device" when stderr is captured none
unrelated riders none loop.c timespec zero-init + SigintWatcher null guard

The original report explicitly calls out both the builtin and the external-command path ("Same for the external-command path"), which only this PR addresses. Happy to defer to whichever shape reviewers prefer for the builtin side; the subprocess fix here is independent either way.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

This supersedes #34699 (same audit item, opened in parallel); the latch-on-Builtin approach here covers every builtin plus the subprocess path, so I'm closing that one in favor of this.

Two other cheap hardening items from the same audit that #34699 carried and aren't here, in case you want to fold them in (neither has a fail-before test on its own):

--- a/packages/bun-usockets/src/loop.c
+++ b/packages/bun-usockets/src/loop.c
@@ -75,3 +75,3 @@
 uint64_t us_internal_monotonic_ns(void) {
-    struct timespec ts;
+    struct timespec ts = {0, 0};
     clock_gettime(CLOCK_MONOTONIC, &ts);

--- a/src/jsc/bindings/vm/SigintWatcher.cpp
+++ b/src/jsc/bindings/vm/SigintWatcher.cpp
@@ -87,3 +87,3 @@
         WTF::Thread* currentThread = WTF::Thread::currentMayBeNull();
-        ASSERT(!currentThread || m_thread->uid() != currentThread->uid());
+        ASSERT(!currentThread || !m_thread || m_thread->uid() != currentThread->uid());
@@ -102,3 +102,5 @@
         m_semaphore.signal();
-        m_thread->waitForCompletion();
+        if (m_thread) {
+            m_thread->waitForCompletion();
+        }

(m_thread is a RefPtr; Thread::create itself never returns null because it RELEASE_ASSERTs on pthread_create failure, but install() sets m_installed = true before assigning m_thread.)

Comment thread src/runtime/shell/Builtin.rs Outdated
Comment thread src/runtime/shell/subproc.rs Outdated
Comment thread src/runtime/shell/Builtin.rs
- leak.test.ts: the ArrayBuffer/Buffer memleak cases intentionally overflow
  the target; accept the now-nonzero exit and stderr since the test is about
  the Strong ref being released.
- Builtin::done: when stderr is an fd, tee the diagnostic into the captured
  buffer so r.stderr carries it on the non-.quiet() path (the terminal write
  stays skipped to keep done() synchronous). Covered by a new non-.quiet()
  test.
- subproc: use to_shell_system_error() (static fields for a from_code error)
  so the synthesized ENOSPC does not leak a WTFStringImpl.
- Cmd::buffered_output_close_*: do not overwrite an already-nonzero exit
  code with a pipe error, and deref() the SystemError.
- Builtin::write_no_io: update the doc comment to match the new short-write
  contract.
Comment thread src/runtime/shell/subproc.rs Outdated

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

All three rounds of my earlier findings (fd-stderr tee, to_shell_system_error() + e.deref() at the sink, exit-code clobber guard, doc comment, and the leak.test.ts update) are addressed in 42b4510. No new issues found on this pass. Deferring to a maintainer for the user-facing behavior change and the unsafe { fd.captured_mut() } write in Builtin::done.

What was reviewed:

  • write_no_io_to / BufferedOutput::append short-write semantics and the write_err latch → done() fold; verified builtin's own nonzero exit is preserved via (code, _) => code.
  • Subprocess ENOSPC path: to_shell_system_error() is all-static for a from_code error, and the new e.deref() in buffered_output_close_{stdout,stderr} also fixes the pre-existing PipeReaderState::Err / captured_writer.err leak.
  • captured_mut() tee in done(): single-threaded, no other borrow of the env's stderr bytelist live at that point; matches the existing pattern in buffered_output_close_stdout.
  • Test coverage: builtin/subprocess, exact-fit boundary, offset Uint8Array view with out-of-window bytes untouched, || rhs fallback, non-.quiet() capture; leak.test.ts overflow sites now ignore exit/stderr.
Extended reasoning...

Overview

Changes > ${buf} redirect semantics so overflowing a fixed-size Buffer/TypedArray target fails the command (exit 1 for builtins with a "No space left on device" diagnostic; ENOSPC-derived exit for subprocesses) instead of silently truncating and exiting 0. Touches Builtin.rs (short-write ENOSPC + latch + done() fold with fd-stderr tee), subproc.rs (BufferedOutput::ArrayBuffer overflow flag surfaced via try_signal_done_to_cmd), Cmd.rs (guard against clobbering an already-nonzero subprocess exit; e.deref() at the sink), plus tests in bunshell.test.ts and updates to leak.test.ts.

Security risks

None. No untrusted-input parsing, no auth/crypto, no path handling. The change tightens error reporting on a JS-only redirect target.

Level of scrutiny

Medium-high. This is a user-facing behavior change to Bun.$ (previously exit 0, now nonzero) and touches the shell interpreter's unsafe Rust: captured_mut() ((&self) -> &mut Vec<u8>), SystemError refcount handling, and the Cmd/PipeReader completion ordering. The mechanics look correct across the paths I traced, but the API-contract decision (overflow is a hard failure, mirroring > /dev/full) and the choice to skip the terminal write in the fd-stderr case (documented in the code comment) are design calls a maintainer should sign off on.

Other factors

I've reviewed this across three prior passes; the author addressed each finding (fd-stderr diagnostic reaching r.stderr in the non-.quiet() case, the WTFStringImpl leak via to_shell_system_error() + sink deref(), the subprocess exit-code clobber, the stale doc comment, and the leak.test.ts breakage). The added e.deref() in buffered_output_close_* also fixes a pre-existing leak on the PipeReaderState::Err path — I checked fill_system_error_common to confirm static/empty-string deref() is a no-op so this is safe for the new to_shell_system_error() path too. Test coverage is solid (both entry points, exact-fit boundary, offset view, || fallback, non-quiet capture). #34699 was closed in favor of this PR. CI build #75752 was in flight at review time.

Comment thread src/runtime/shell/states/Cmd.rs
…code

Stash the ArrayBuffer-overflow flag on SubprocExec and fold it into the
exit code at CmdState::Done, where the process's own code is known. This
makes the subprocess path deterministic and symmetric with Builtin::done:
a subprocess that both overflows the redirect target and exits nonzero
keeps its own exit code, regardless of whether pipe-EOF or process-exit
arrives first.

buffered_output_close_{stdout,stderr} keep the e.deref() for the
pre-existing PipeReaderState::Err path.

@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 — the latch-on-SubprocExec rework in 9bc535c resolves the last ordering concern; all prior review feedback is addressed and the bug hunter found nothing new on this revision.

What was reviewed:

  • redirect_overflow fold at CmdState::Done is order-independent (fires only after both process exit and all pipes closed via has_finished()); symmetric with Builtin::done's (code, _) => code guard.
  • Overflow path no longer synthesizes a SystemError, so the earlier WTFStringImpl leak is gone; the retained e.deref() / unwrap_or(0) == 0 in buffered_output_close_* are strict improvements to the pre-existing pipe-read-error path.
  • New "own nonzero exit wins over overflow" test pins the precedence; non-.quiet() diagnostic tee, exact-fit, offset-view, and || rhs cases all covered.
Extended reasoning...

Overview

Fixes silent truncation when redirecting shell output into a too-small Buffer/TypedArray (> ${buf}): both the builtin path (Builtin.rs) and subprocess path (subproc.rs / Cmd.rs) now fail the command instead of exiting 0 with clipped data. Builtins latch the first write_no_io ENOSPC on Builtin::write_err and fold it into a nonzero exit + stderr diagnostic in Builtin::done; subprocesses set SubprocExec::redirect_overflow from BufferedOutput::append and fold it into ENOSPC at CmdState::Done only when the process itself exited 0. leak.test.ts updated for the new nonzero exit; ~110 lines of new tests in bunshell.test.ts.

Security risks

None. No untrusted-input parsing, no auth/crypto/permissions. The change tightens behavior (fail loud instead of silently truncate).

Level of scrutiny

Medium-high: shell interpreter internals with raw-pointer / Arc interior-mutability patterns. Mitigated by: this PR has been through four rounds of my own review (stale doc comment, non-.quiet() diagnostic gap, to_system_error() leak + exit-code clobber ordering A, exit-code clobber ordering B), each addressed with a targeted fix plus a covering test. The final latch-at-Done shape is the cleanest of the iterations — no SystemError synthesis, deterministic regardless of pipe-EOF vs. process-exit ordering, and symmetric with the builtin path's precedence rule.

Other factors

  • Test coverage hits the variant matrix REVIEW.md asks for: builtin + subprocess, .quiet() + default fd stderr, exact-fit + overflow, Buffer + offset Uint8Array view (with out-of-window bytes asserted untouched), || rhs short-circuit, and the new subprocess-nonzero-exit-wins-over-overflow test that pins the ordering fix.
  • Known design asymmetry (builtin → exit 1 + "<cmd>: No space left on device"; subprocess → exit ENOSPC, no stderr) was surfaced in the PR description and earlier review and is consistent with the respective sibling error paths (on_io_writer_chunk vs. buffered_output_close errno).
  • Terminal-write skip in the non-.quiet() builtin case is documented in the code comment and covered by a test asserting r.stderr still carries the diagnostic.
  • The e.deref() addition fixes a pre-existing SystemError string leak on the real-read-error path — a rider improvement, not a regression risk.
  • CodeRabbit's two comments (TypedArray-view coverage, ENOSPC-specific assertion) were addressed; the outstanding CodeRabbit note on subprocess ENOSPC assertion strength is now satisfied by the os.constants.errno.ENOSPC exact-match assertion.

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