shell: keep words after a redirection as arguments of the same command
Problem
- Bun Shell runs the words after a redirection as a new command.
echo x > log rm cachewritesx, then runsrm cache. echo x 2>&1 important.txtoverwritesimportant.txt.- Cause (
src/shell_parser/parse.rs):parse_simple_cmdstops at the firstRedirecttoken, andparse_redirectreads a file after every redirection.
Fix
parse_simple_cmdcollects words and one redirection in one loop. A second redirection reportsMultiple redirects are not supported yet.parse_redirectreads no file after2>&1or1>&2. The interpreter branches for that pair are deleted.- Verified:
test/js/bun/shell/file-io.test.tsandparse.test.ts. - Self-reviewed: 5 concerns raised, 5 addressed. All were about scope: the statement rule below is out of this PR.
Background
- POSIX allows a redirection anywhere among a command's words.
2>&1names no file. - Considered a rule in
parse_stmtthat a statement holds one command. It also stops(a) b, but a CRLF line that ends in)becomes a parse error until #29670 lands. It is a follow-up. - #45780 makes an interpolated array after
>an ambiguous redirect, as in #34324. That result wins when both are merged.
Downsides
cmd 2>&1 ${x}sent both streams tox. Now a string is an argument and a JS object is an error that names&>.- Leftover words still run after other constructs:
(a) bruns both. - Not measured: instructions and size. The build host could not finish a release build.
Notes
What changes for each input (main 1.4.3-canary release build, this PR, bash 5.2, each in an empty temp dir):
| input | main | this PR | bash |
|---|---|---|---|
echo x > log rm cache | log = x, runs rm cache | log = x rm cache | same as PR |
cat < f0 f1 | prints f0, command not found: f1 | prints f1 | same as PR |
echo x 2>&1 important.txt | overwrites the file | prints x important.txt | same as PR |
echo hi > ${buf} extra words | buf = hi, runs extra words | buf = hi extra words | n/a |
echo hi > ${["out", "touch", "INJECTED"]} | runs touch INJECTED | out = hi touch INJECTED | ambiguous redirect |
echo hi 2>&1 ${buf} | buf = hi | error that names &> | n/a |
echo a > f1 b > f2 | f1 = a, runs b | Multiple redirects are not supported yet. | f2 = a b |
echo "a"2>err b | prints an empty line, runs b | prints a b | err = a2 b |
(echo a) touch INJECTED | runs both | runs both | syntax error |
Cost. parse_simple_cmd calls parse_atom() first and tests for a redirection only when it returns None, so a command with no redirection makes the same parse_atom() calls as on main. After a redirection it stops at once when the command ends there (;, newline, end, |, &&, ||, &, or the closing token of a subshell). cmd a > f makes four parse_atom() calls on main and four here. The first version of this PR made five, and each call starts with ArenaVec::with_capacity_in(1, ..). These are counts from the code. Instruction counts and byte sizes from release builds are not done: the build host could not finish a build.
Deleted code. flags.duplicate_out() || in the ArrayBuffer arm of init_subproc_redirections (two places) and the duplicate_out arm of set_stdio_from_redirect, both in src/runtime/shell/states/Cmd.rs. parse_redirect is the only producer of a redirection, and it returns no file when DUPLICATE_OUT is set. Also the note in parse_redirect that asked for the multiple-redirect check, and the restated flag comment in Builtin.rs.
An fd-dup with a target. On the main release build cmd 2>&1 ${buf} and cmd &> ${buf} leave the same bytes in a Buffer in every case I ran (a command that writes stdout, stderr, or both), and a path or Bun.file() gets o\ne\n with both spellings. &> is not changed here, so it replaces the old form. A JS object after 2>&1 or 1>&2 reports: `2>&1` and `1>&2` take no target. Use `&>` to send stdout and stderr to a JS object.
Word next to an fd redirection. echo "a"2>err b dropped the word a: the lexer emits no delimiter between a closing quote and an fd-digit redirection, and parse_atom returned None with the word already collected. parse_atom now returns the word. bash reads "a"2 as one word: that is the fd-digit rule of the lexer (#12602), not changed here.
The statement rule (follow-up). A design review picked a wider fix: parse_stmt parses one expression and then requires ;, a newline or the end, so leftover tokens can never start a second command. It stops (a) b, [[ x ]] b, fi b, and the arguments that bun run --shell=bun appends to a script that ends in ). It was built on this branch (commits d6d51b564e to 7865c7df62, still in the branch history, with tests). It is not in this PR because it turns every place where the lexer emits no line end into a parse error. On main (echo a) + CRLF + echo b prints a, b and one command not found line for the \r. With the rule the script does not run. #29670 makes comment, CRLF and tab line ends a Newline token and allows a newline after &&, || and |. The rule belongs on top of it, with the closer of command substitutions from #38479.
Existing inputs that change. Every $ and TestBuilder.command template in test/, docs/, scripts/, packages/, bench/ and src/js (1,563 templates) was parsed on main and on a build with this word loop. The only templates that exist on main and change because of the word loop are the two copies of the invalid input in shell-leak-args.test.ts: a parse error on both builds, with a different message.
Still not supported, same as main. A redirection before the command name (> out echo hi), two redirections in one command (related to #9455), and redirections on ( ) and if ... fi.
Local verification. The word loop, the fd-dup change and the interpreter deletions were built and tested in a debug (ASAN) build at an earlier head that also had the statement rule: parse.test.ts, lex.test.ts, file-io.test.ts, bunshell.test.ts and run-shell.test.ts passed, and the new cases failed on the main release build. All 43 files in test/js/bun/shell ran: the failures were timeouts in leak.test.ts, shell-hang.test.ts and shell-load.test.ts on an overloaded host, and the two ls permission cases that fail as root. The current head had only cargo check -p bun_shell_parser locally: the host could not finish a build.
Found on the way, not in this PR. TestBuilder.error() in test/js/bun/shell/test_builder.ts passes when the command does not throw. expect(fn).toThrow() waits forever when fn returns a promise that never settles. A Buffer target keeps only one stream when the command writes both: each stream writes from offset 0. [[ and ]] are tokens in any position (echo a [[ -n b ]] prints a, grep [[:space:]] f is a parse error), which has its own change in progress.
[review] gate passed · iteration 1 · 3 files touched
fails on main (without fix)
ASAN without fix: 7 failed, 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/file-io.test.ts test/js/bun/shell/parse.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (5cdecf6ca)
test/js/bun/shell/file-io.test.ts:
(pass) IOWriter file output redirection > basic file redirection > simple echo to file [57.70ms]
(pass) IOWriter file output redirection > basic file redirection > empty output to file [7.41ms]
(pass) IOWriter file output redirection > basic file redirection > zero-length write should trigger onIOWriterChunk callback [6.13ms]
228 | } else if (typeof this.expected_exit_code === "function") this.expected_exit_code(exitCode);
229 |
230 | for (const [filename, expected_raw] of Object.entries(this.file_equals)) {
231 | const expected = typeof expected_raw === "string" ? expected_raw : await expected_raw();
232 | const actual = awai
... (truncated)
release without fix: 4 skipped
bun test v1.4.0-canary.1 (781213480)
test/js/bun/shell/file-io.test.ts:
(pass) IOWriter file output redirection > basic file redirection > simple echo to file [5.61ms]
(pass) IOWriter file output redirection > basic file redirection > empty output to file [0.28ms]
(pass) IOWriter file output redirection > basic file redirection > zero-length write should trigger onIOWriterChunk callback [0.16ms]
(pass) IOWriter file output redirection > redirection between arguments > words after > file stay as arguments (do not run as a command) [0.22ms]
(pass) IOWriter file output redirection > redirection between arguments > printf argument after > file is passed to printf [1.33ms]
b0
(pass) IOWriter file output redirection > redirection between arguments > word after < file is a file operand, not a new command [1.14ms]
A B C
(pass) IOWriter file output redirection > redirection between arguments > words after 2>&1 stay as arguments (fd-dup takes no file operand) [0.08ms]
A B
(pass) IOWriter file output redirection > redirection between arguments > words after 1>&2 stay as arguments [0.07ms]
(pass) IOWriter file output redirection > redirection between arguments > second redirec
... (truncated)
passes on PR (with fix)
ASAN with fix: 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/file-io.test.ts test/js/bun/shell/parse.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (5cdecf6ca)
test/js/bun/shell/file-io.test.ts:
(pass) IOWriter file output redirection > basic file redirection > simple echo to file [58.99ms]
(pass) IOWriter file output redirection > basic file redirection > empty output to file [19.68ms]
(pass) IOWriter file output redirection > basic file redirection > zero-length write should trigger onIOWriterChunk callback [7.60ms]
(pass) IOWriter file output redirection > redirection between arguments > words after > file stay as arguments (do not run as a command) [13.46ms]
(pass) IOWriter file output redirection > redirection between arguments > printf argument after > file is passed to printf [9.40ms]
b0
(pass) IOWriter file output redirection > redirectio
... (truncated)
release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 716ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date
info: checking for self-update (current version: 1.29.0)
nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)
^[[1m^[[92m Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl
... (truncated)
diff hotspot
src/shell_parser/parse.rs | 30 ++++++++++++++++++++-----
test/js/bun/shell/file-io.test.ts | 46 +++++++++++++++++++++++++++++++++++++++
test/js/bun/shell/parse.test.ts | 39 +++++++++++++++++++++++++++++++++
3 files changed, 109 insertions(+), 6 deletions(-)
gate history · 3 passed · 0 rejected · iteration 1
evidence per changed file
file reads edits tests
src/shell_parser/parse.rs 7 2 0
test/js/bun/shell/file-io.test.ts 2 2 0
test/js/bun/shell/parse.test.ts 2 3 0