shell: keep words after a redirection as arguments of the same command

RO
19d ago
+237-22

Problem

  • Bun Shell runs the words after a redirection as a new command. echo x > log rm cache writes x, then runs rm cache.
  • echo x 2>&1 important.txt overwrites important.txt.
  • Cause (src/shell_parser/parse.rs): parse_simple_cmd stops at the first Redirect token, and parse_redirect reads a file after every redirection.

Fix

  • parse_simple_cmd collects words and one redirection in one loop. A second redirection reports Multiple redirects are not supported yet.
  • parse_redirect reads no file after 2>&1 or 1>&2. The interpreter branches for that pair are deleted.
  • Verified: test/js/bun/shell/file-io.test.ts and parse.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>&1 names no file.
  • Considered a rule in parse_stmt that 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 to x. Now a string is an argument and a JS object is an error that names &>.
  • Leftover words still run after other constructs: (a) b runs 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):

inputmainthis PRbash
echo x > log rm cachelog = x, runs rm cachelog = x rm cachesame as PR
cat < f0 f1prints f0, command not found: f1prints f1same as PR
echo x 2>&1 important.txtoverwrites the fileprints x important.txtsame as PR
echo hi > ${buf} extra wordsbuf = hi, runs extra wordsbuf = hi extra wordsn/a
echo hi > ${["out", "touch", "INJECTED"]}runs touch INJECTEDout = hi touch INJECTEDambiguous redirect
echo hi 2>&1 ${buf}buf = hierror that names &>n/a
echo a > f1 b > f2f1 = a, runs bMultiple redirects are not supported yet.f2 = a b
echo "a"2>err bprints an empty line, runs bprints a berr = a2 b
(echo a) touch INJECTEDruns bothruns bothsyntax 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