Skip to content

shell: take the command-substitution capture and drop its NUL bytes - #44752

Open
robobun wants to merge 1 commit into
mainfrom
robobun/53f4200c/shell-cmdsubst-drop-nul
Open

robobun wants to merge 1 commit into
mainfrom
robobun/53f4200c/shell-cmdsubst-drop-nul

Conversation

@robobun

@robobun robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A NUL byte in $(...) output stays in the word, and each C-string consumer ends the word there. rm $(printf 'rmf\0zz') removes rmf, exit 0. bash removes rmfzz.
  • A debug build aborts on [[ -f $(printf 't\0z') ]]: panic: ZStr::as_cstr: interior NUL would truncate the C view.
  • Cause: Expansion::child_done (src/runtime/shell/states/Expansion.rs:616-649) copies the captured stdout into the word unchecked.

Fix

  • child_done drops the NUL bytes before the trim and the field split, as bash and dash do.
  • It takes the capture from the child env without a clone, so unsafe fn buffered_stdout_mut has no caller and is removed.
  • Verified: test/js/bun/shell/bunshell.test.ts (12 new cells) and exec.test.ts (1). All fail on main. Also all of test/js/bun/shell/.
  • Self-reviewed: 6 concerns raised, 6 addressed.

Background

Downsides

  • Behaviour change: a word from output with a NUL was cut at the NUL and is now all the other bytes. X=$(printf '\0'); cmd "$X" now passes no argument (main and bash: an empty one), because an empty quoted variable is no word in Bun.
  • Still open: a NUL from .env(), process.env, .cwd(), a .env file, .sh text or Worker argv still ends the word. That needs a reject-or-cut decision.
  • Cost: one scan per substitution, +5617 instructions at a 64 KiB capture. .text +512 bytes.
Notes

Credit. The drop is the child_done hunk of #40204 (4b7c7b9, b9ae552). The core::mem::take is in #40228. This PR carries that part alone, with tests. After it, #40204 has 66 conflict regions against main (65 before), and #40228 has 126 (124 before).

What changes for a word. Before, a word from output with a NUL was cut at the first NUL by each C-string consumer, and [[ ]] string tests and assignments kept the NUL. Now the word is all the bytes that are not NUL. The drop comes first, so "$(printf 'a\n\0')" is a, and x $(printf '\0') y is two arguments.

bash cells. rm, mkdir, touch, cd, [[ ]], redirect, assignment, export, glob and brace cells, each beside bash 5.2. 28-cell set: 1 differs (main 24). 8-cell set: 0 differ (main 4). The one left is command not found, exit 1 here and 127 in bash. These sets have no unquoted [[ ]] operand with a blank (see below).

Cells that move away from bash. The NUL hid four bugs that main has without a NUL. Their NUL twins now match their NUL-free twins:

Cell main this PR bash
"x$(printf 'a \0')" xa xa xa
"$(printf 'a ')" (twin) a a a
$(printf 'a \0')b two words ab a, b
$(printf 'a ')b (twin) ab ab a, b
[[ $(echo -e 'a\0 b') == ab ]] no yes no
[[ $(echo a b) == ab ]] (twin) yes yes no
X=$(printf '\0'); cmd "$X" one empty argument no argument one empty argument
X=; cmd "$X" (twin) no argument no argument one empty argument
~"$(printf '\0')" ~ $HOME ~
~"" (twin) $HOME $HOME ~

With X=$(printf '\0'), cd "$X" now goes to $HOME, as cd "$EMPTY" does on main. "$(printf '\0')" used directly stays one empty argument: a quoted substitution whose output held a NUL sets has_quoted_empty. A todo cell in bunshell.test.ts records the variable case with the result of bash. The four causes (quoted trim of blanks, blank next to text, [[ ]] field split, empty quoted expansion) are separate work.

NUL channels that stay open. Each was checked on a release build with this change. With the value rmf\0zz, rm removes rmf, and .cwd() runs in the directory before the NUL:

Channel Existing work
$X from .env(), $.env() or process.env #34735 (closed, rejects in the setters), #40571 (draft)
$X from a .env file under bun exec none
.cwd() with a NUL #34735
A raw NUL in a .sh file none
Worker argv read as $N none

JS template text and ${...} values are already refused (#25698). For these caller values the choice is reject or cut, and it is not made here.

Measured on release builds of main bbdc5a519e with and without this change (same toolchain, built back to back). The comments changed after the measurement, the code did not.

  • allocations per $(...) statement (mi_malloc hits, gdb, (2000 - 1000) / 1000 lines, main vs PR): X=$(..) 26 -> 24, "$(..)" 30 -> 28, unquoted 38 -> 37. echo hi control 10 -> 10. mi_free: 32 -> 30, 36 -> 34, 46 -> 45, control 13 -> 13.
  • allocations per $(...) whose output holds a NUL (mi_malloc hits, gdb): no-split 26 (NUL-free 26), split 34 (NUL-free 34).
  • instructions per NUL-free $(...): valgrind and perf are not available here (no hardware counters), so these are gdb single-steps from the capture read until Interpreter::child_done returns, 5 steady-state hits, all equal. A rep movsb counts once. X=$(cat F): 17 B 1492 -> 1312, 4 KiB 1676 -> 1687, 64 KiB 1830 -> 7447. echo $(cat F): 17 B 1802 -> 1782, 4 KiB 68110 -> 68359. With each rep movsb byte as one step: X=$(cat F) 4 KiB 9866 -> 1687, 64 KiB 132900 -> 7447.
  • peak RSS, one 256 MiB quoted capture (ru_maxrss, 5 runs, min-max): main 779.7-780.0 MiB, PR 545.5-571.6 MiB.
  • release .text: +512 bytes (size, 88695343 vs 88695855). Interpreter::child_done 8436 -> 8651 bytes (nm -S).
  • syscalls per $(...) statement: 2 -> 2 (gdb catch syscall, (2000 - 1000) / 1000 lines).

Not done here. push_current_out still copies each finished word into out.buf (Vec::append), so one copy of the capture remains on this path. bash prints a warning for a dropped NUL. An Expansion has no stderr route, and dash prints none.

Tests. The cells make the NUL with the echo -e builtin and with a subprocess. The redirect target and [[ -f ]] cells run through bun exec, because a debug build without the fix aborts the process on them. One cell checks that the output the shell itself captures keeps its NUL bytes and loses nothing to a substitution.

Suites. Debug build: bunshell.test.ts, exec.test.ts, yield.test.ts, assignments-in-pipeline.test.ts, pipeline_stack.test.ts, shell-cmdsub-crash.test.ts, test/internal/source-lints/byte-search.test.ts. In bunshell.test.ts the seven stdin redirect from a zero-length buffer cells take about 5 s each on this debug build and time out at the 5 s limit with and without this change. Release build: all of test/js/bun/shell/, with leak.test.ts and shell-leak-args.test.ts. The two ls permission tests fail as root on main too.


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/bunshell.test.ts

A NUL byte in the output of `$(...)` stayed in the word. An argument, a
builtin operand, an exported value and a redirect target end at the
first NUL, so `rm $(printf 'rmf\0zz')` removed `rmf`, not `rmfzz`.

`Expansion::child_done` now moves the capture out of the env of the
child script and does not clone it. It drops the NUL bytes before the
trim and the field split, as bash and dash do. The drop comes from
#40204, and the move from #40228.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
@robobun

robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced with (release build of main, bash 5.2 beside it):

mkdir t && cd t && touch rmf rmfzz t1
bun exec "rm \$(printf 'rmf\0zz'); ls"
# main: rmfzz t1 (it removed rmf). bash and this PR: rmf t1
bun exec "[[ -f \$(printf 't1\0zzz') ]] && echo yes || echo no"
# main: yes, and a debug build aborts. bash and this PR: no

PR: #44752

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 596abd2c-2098-40bc-89bc-193cc90a230a
📥 Commits

Reviewing files that changed from the base of the PR and between 620b50f and d66d352.

📒 Files selected for processing (5)
  • src/runtime/shell/interpreter.rs
  • src/runtime/shell/states/Cmd.rs
  • src/runtime/shell/states/Expansion.rs
  • test/js/bun/shell/bunshell.test.ts
  • test/js/bun/shell/exec.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


Walkthrough

Command-substitution processing now takes ownership of captured stdout and removes NUL bytes before further processing. Quoted substitutions that contain only NUL bytes are recorded as empty quoted words. New tests cover substitution output across shell contexts and distinguish it from shell-captured output.

Changes

Shell command substitution

Layer / File(s) Summary
Consume and normalize substitution stdout
src/runtime/shell/states/Expansion.rs, src/runtime/shell/interpreter.rs, src/runtime/shell/states/Cmd.rs
Expansion::child_done takes owned stdout and removes NUL bytes before processing. Quoted substitutions containing only NUL bytes are recorded as empty quoted words. The mutable stdout accessor is removed, buffer documentation is corrected, and an empty-expansion comment is updated.
Validate substitution behavior
test/js/bun/shell/bunshell.test.ts, test/js/bun/shell/exec.test.ts
Tests cover NUL removal in substitution words, assignments, operands, and redirect targets. They also check that shell-captured output retains NUL bytes. One quoted-NUL assignment case is marked TODO.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to d66d3

Command substitution now drops NUL bytes from its output, matching bash and dash. The change is narrow and comes with tests covering the new behavior. No merge-blocking risk is evident.

🚥 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 describes the main change: command-substitution output is captured and NUL bytes are removed.
Description check ✅ Passed The description explains the problem, fix, behavior changes, limitations, and verification results. It does not use the template headings exactly, but it provides the required content, including how t…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the new unreachable! on the Bufio::Borrowed arm (src/runtime/shell/states/Expansion.rs:622): the command-substitution child is always duped with OutKind::Pipe and ShellExecEnvKind::CmdSubst (Expansion.rs:205-213), which dupe_for_subshell maps to Bufio::Owned, so the arm is not reachable. The mem::take leaves the child env's buffer empty until deinit_node(child) a few lines later with no reader in between, and the retain closure is not a form flagged by the byte-search source lint.

Extended reasoning...

The change replaces an unsafe clone of the command-substitution capture with a mem::take on the owned buffer, strips NUL bytes before trimming and field splitting, and removes the now-unused unsafe fn buffered_stdout_mut; it touches no auth, crypto or injection surface, though it does feed shell words into C-string consumers. The invariant behind the new unreachable! is provable from the dup site, and the ownership transfer has no later reader. The posted findings are a test-strength nit, a pre-existing trim bug and a bash-divergence nit around has_quoted_empty, so a human should weigh the deliberate behaviour deviation rather than this being approved outright.

Comment on lines +1551 to +1554
.ensureTempDir()
.stdout("no\n")
.fileEquals("fzz", "hi\n")
.doesNotExist("f")

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.

🟡 nit (optional): the [[ -f ]] half of this cell only proves the operand names a file that does not exist, not that the NUL was dropped. At test/js/bun/shell/bunshell.test.ts:1551 the expected "no\n" is also what any mangled or truncated-to-garbage operand yields, so a regression that keeps the word wrong in a new way still passes. Fix: create the file the NUL-free word names (touch t1zzz) and assert yes, so only the dropped-NUL path can satisfy it, keeping the existing t1 precondition so the base-branch C-string truncation still produces a different answer.

Why this was flagged

The cell at test/js/bun/shell/bunshell.test.ts:1545-1554 runs touch t1; [[ -f $(echo -e 't1\\0zzz') ]] && echo yes || echo no through bun exec and asserts stdout "no\n". On the base branch the operand is t1\0zzz, which the C-string stat sees as t1, so it prints yes (debug builds abort), and the assertion fails. After the change the word is t1zzz, which does not exist, so no is printed. But no is also printed if a future change leaves the operand as any other nonexistent string (for example dropping the byte after the NUL, or replacing the NUL with another byte), so the assertion cannot distinguish the NUL-dropped word from a wrongly-built word. The redirect half of the same cell is strong (fileEquals("fzz", "hi\n") and doesNotExist("f")); only the -f half is weak. REVIEW.md asks every assertion to assert the strongest invariant.

Verification: nit. Triggering condition: any future regression that leaves the [[ -f ]] operand as some nonexistent string other than t1 passes this cell unchanged. Mechanism verified at test/js/bun/shell/bunshell.test.ts:1550-1555: the cell asserts .stdout("no\n"). The only precondition created is t1; t1zzz is never created, so no is what every nonexistent operand produces.

Comment on lines 650 to +653
while hi > 0 && matches!(stdout[hi - 1], b' ' | b'\n' | b'\r' | b'\t') {
hi -= 1;
}
me.current_out.extend_from_slice(&stdout[..hi]);
stdout.truncate(hi);

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.

🟣 pre-existing, not blocking: pre-existing: Users of a quoted or assigned command substitution lose trailing spaces, tabs and CRs that bash keeps; X=$(printf 'a ') or "$(printf 'a ')" yields a, bash yields a . The trim loop at src/runtime/shell/states/Expansion.rs:650 strips b' ' | b'\n' | b'\r' | b'\t' in the no_split branch, but POSIX 2.6.3 removes only trailing newlines from $(...) output. Fix: in the no_split branch (quoted and assign_ctx) trim only trailing \n, leaving space, tab and CR in place, while keeping the newline trim the existing nested echo cmd subst tests rely on. The PR text lists this as separate work; the diff rewrites this hunk (truncate(hi)) and its NUL table shows "x$(printf 'a \0')" now giving xa where bash gives xa .
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A script runs X=$(printf 'a ') or echo "[$(printf 'a\t')]" through Bun.$ or bun exec; the capture reaches Expansion::child_done at src/runtime/shell/states/Expansion.rs:615 with no_split true (src/runtime/shell/states/Expansion.rs:639-642). The loop at src/runtime/shell/states/Expansion.rs:650-653 drops every trailing byte in b' ' | b'\n' | b'\r' | b'\t' and truncate(hi) commits it, so the word is a. bash and dash remove only trailing newlines in a quoted or assignment command substitution, so they yield a / [a\t]. The base branch has the same loop (it used extend_from_slice(&stdout[..hi])), so this is pre-existing; the diff touches the hunk and, with the NUL drop, now routes "x$(printf 'a \0')" to xa where the base happened to keep xa because the NUL protected the blank. No guard elsewhere restores the blanks: Cmd and Assign consume out.buf as produced.

Verification: pre-existing. Any quoted or assignment-context command substitution whose output ends in a space, tab or CR. The no_split branch strips b' ' | b'\n' | b'\r' | b'\t' (src/runtime/shell/states/Expansion.rs:650-653). POSIX 2.6.3 removes only trailing newlines, so X=$(printf 'a ') yields a versus a in bash. The base has the same predicate at line 643.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The follow-up for this trim is ready on the branch robobun/6d873e57/shell-cmdsubst-keep-trailing-whitespace. It is one commit on top of this PR: robobun/53f4200c/shell-cmdsubst-drop-nul...robobun/6d873e57/shell-cmdsubst-keep-trailing-whitespace

It removes only the trailing line ends (each LF, and a CR directly before it) and keeps trailing spaces and tabs. It rewrites the same hunk as this PR, so it opens as its own PR after this PR merges.

Comment on lines +654 to +657
// A quoted substitution that printed only NUL bytes is one empty word, as in bash.
if had_nul && me.cmd_subst_quoted {
me.has_quoted_empty = true;
}

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.

🟣 pre-existing, not blocking: nit: Users get a quoted command substitution whose argv result depends on whether the discarded bytes happened to be NUL. cmd "$(printf '\0')" passes one empty argument while cmd "$(true)" and cmd "$(printf '\n')" pass none; bash passes one empty argument in all three. The rule at src/runtime/shell/states/Expansion.rs:655 keys has_quoted_empty on had_nul, a property of bytes the word no longer contains. Fix: set has_quoted_empty for every quoted $(...) (the me.cmd_subst_quoted branch), not only when a NUL was seen, so the empty-quoted-word rule matches "" at Expansion.rs:481; or leave the flag out and treat the quoted-empty case as one fix. The doc at Expansion.rs:47 also says "only NUL bytes" but the flag is set whenever any NUL was present.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user runs cmd "$(printf '\0')" and cmd "$(true)" through Bun.$ or bun exec. At src/runtime/shell/states/Expansion.rs:655-657 the first sets me.has_quoted_empty = true because had_nul is true, so Cmd.rs:390 pushes one empty argv word; the second leaves the flag false and pushes nothing. On the base branch the first case also yields one empty argument (the NUL stayed in the word and C-string consumers truncated it), and the second yields none, so this is not a regression, but the new code now encodes that outcome as an explicit content-dependent heuristic rather than the structural rule bash uses (a quoted substitution is always a word). The doc comment at Expansion.rs:47 describes the flag as set for output that 'was only NUL bytes', but the condition at line 655 does not check that the remaining buffer is empty; it is harmless only because the flag is read solely when the word is empty (Cmd.rs:390, Expansion.rs:238).

Verification: Not a regression: no input changes result versus base. src/runtime/shell/states/Expansion.rs:655-657 sets has_quoted_empty only if had_nul && me.cmd_subst_quoted, and nothing else sets it for a quoted $(...). Cmd.rs:390 then pushes the empty word for cmd "$(printf '\0')" but not for cmd "$(true)"; bash passes one empty argument in both.

This branch has not been deployed

No deployments
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.

2 participants