From 4dcbf80c2bed8ab1d4bca8b2ceb0eeaa9afa3338 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:52:42 +0000 Subject: [PATCH] shell: remove only trailing line ends from a quoted command substitution A quoted command substitution in a command word, a redirect target or a `[[ ]]` operand lost every trailing space, tab and CR of the output. The shell now removes only the trailing newlines there, and the CRs directly before them. An assignment value keeps the old trim, because an unquoted variable is not split into fields. `exit` accepts spaces and tabs around its number, as bash does, and no other byte. --- docs/runtime/shell.mdx | 2 + src/runtime/shell/builtin/exit.rs | 5 ++- src/runtime/shell/states/Expansion.rs | 24 ++++++++++-- test/js/bun/shell/bunshell.test.ts | 49 +++++++++++++++++++++++++ test/js/bun/shell/commands/exit.test.ts | 17 +++++++++ test/js/bun/shell/exec.test.ts | 5 +++ 6 files changed, 96 insertions(+), 6 deletions(-) diff --git a/docs/runtime/shell.mdx b/docs/runtime/shell.mdx index 4cc6ed402917..40927db29c35 100644 --- a/docs/runtime/shell.mdx +++ b/docs/runtime/shell.mdx @@ -269,6 +269,8 @@ await $` `; ``` +Bun Shell removes the newlines at the end of the output, and any `\r` directly before them. A quoted substitution in a command (`"$(...)"`) keeps the rest of the output. An unquoted substitution also drops trailing spaces, tabs and `\r`. A variable assignment drops them too, also when the substitution is quoted. + Because Bun internally uses the special [`raw`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#raw_strings) property on the input template literal, using the backtick syntax for command substitution doesn't work: diff --git a/src/runtime/shell/builtin/exit.rs b/src/runtime/shell/builtin/exit.rs index cce673241ec4..b2c6432999ef 100644 --- a/src/runtime/shell/builtin/exit.rs +++ b/src/runtime/shell/builtin/exit.rs @@ -55,6 +55,7 @@ impl Exit { } fn parse_exit_code(s: &[u8]) -> Option { - // %256 is bash semantics — keep wrapper fn. - bun_core::fmt::parse_decimal::(s).map(|n| (n % 256) as crate::shell::ExitCode) + // Blanks around the number and %256 are bash semantics — keep wrapper fn. + bun_core::fmt::parse_decimal::(bun_core::strings::trim(s, b" \t")) + .map(|n| (n % 256) as crate::shell::ExitCode) } diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index 12b615773815..6d3a8af4c20a 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -553,6 +553,16 @@ impl Expansion { me.meta_offsets.clear(); } + /// POSIX 2.6.3: a command substitution ends before its trailing newlines. + fn strip_trailing_line_ends(stdout: &mut Vec) { + if stdout.last() == Some(&b'\n') { + // bash keeps a CR before a trailing LF. It goes here so CRLF output gives a clean word. + while matches!(stdout.last(), Some(b'\n' | b'\r')) { + stdout.pop(); + } + } + } + /// Newlines→spaces, trim, then split on whitespace runs into separate /// argv words. fn post_subshell_expansion(me: &mut Expansion, mut stdout: Vec) { @@ -654,11 +664,17 @@ impl Expansion { me.out_exit_code = exit_code; } if no_split { - let mut hi = stdout.len(); - while hi > 0 && matches!(stdout[hi - 1], b' ' | b'\n' | b'\r' | b'\t') { - hi -= 1; + if me.assign_ctx { + // `$X` is never field split, so a value still loses these trailing bytes. + let mut hi = stdout.len(); + while hi > 0 && matches!(stdout[hi - 1], b' ' | b'\n' | b'\r' | b'\t') { + hi -= 1; + } + stdout.truncate(hi); + } else { + Self::strip_trailing_line_ends(&mut stdout); } - me.current_out.extend_from_slice(&stdout[..hi]); + me.current_out.extend_from_slice(&stdout); // A quoted substitution that printed only NUL bytes expands to one empty word. if had_nul && me.cmd_subst_quoted { me.has_quoted_empty = true; diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 4093aeb1c6f8..204625283719 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -410,6 +410,42 @@ describe("bunshell", () => { .ensureTempDir() .runAsTest("make sure its two separate arguments"); }); + + // POSIX 2.6.3: only the newlines at the end of the output are removed. + describe("quoted substitution keeps trailing whitespace", () => { + TestBuilder.command`echo "[$(echo 'a ')]"`.stdout("[a ]\n").runAsTest("space"); + TestBuilder.command`echo "[$(echo -e 'a\t')]"`.stdout("[a\t]\n").runAsTest("tab"); + TestBuilder.command`echo "[$(echo -n -e 'a\r')]"`.stdout("[a\r]\n").runAsTest("CR with no LF after it"); + TestBuilder.command`echo "a$(echo -n ' ')b"`.stdout("a b\n").runAsTest("only spaces"); + TestBuilder.command`echo "[$(echo a; echo ' ')]"`.stdout("[a\n ]\n").runAsTest("space on the last line"); + TestBuilder.command`echo "$(echo 'a ')$(echo 'b ')c"` + .stdout("a b c\n") + .runAsTest("two substitutions in one word"); + TestBuilder.command`echo "[$(echo "$(echo 'a ')b")]"`.stdout("[a b]\n").runAsTest("nested substitution"); + TestBuilder.command`[[ -n "$(echo -n ' ')" ]] && echo yes`.stdout("yes\n").runAsTest("[[ -n ]] operand"); + + TestBuilder.command`${BUN} run ./code.ts a "$(echo -n ' ')" b` + .ensureTempDir() + .file("code.ts", `console.log(JSON.stringify(process.argv.slice(2)))`) + .stdout('["a"," ","b"]\n') + .runAsTest("output of one space is one argument"); + + // Windows removes a trailing space from a file name. + test.skipIf(isWindows)("redirect target", async () => { + await TestBuilder.command`echo hi > "$(echo 'out ')"`.ensureTempDir().fileEquals("out ", "hi\n").run(); + }); + }); + + // A CR directly before a trailing LF goes with it on every platform. bash keeps that CR. + describe("quoted substitution removes trailing line ends", () => { + TestBuilder.command`echo "[$(echo a; echo; echo)]"`.stdout("[a]\n").runAsTest("every LF"); + TestBuilder.command`echo "[$(echo -n -e 'a\r\n\r\n')]"`.stdout("[a]\n").runAsTest("every CRLF"); + TestBuilder.command`echo "[$(echo -n -e 'a\r\r\n')]"`.stdout("[a]\n").runAsTest("every CR before the LF"); + TestBuilder.command`echo "[$(echo -n -e 'a \r\n')]"`.stdout("[a ]\n").runAsTest("CRLF after a space"); + TestBuilder.command`echo "[$(echo -n -e 'a \r\r\n\r\r\n')]"` + .stdout("[a ]\n") + .runAsTest("two line ends after a space"); + }); }); describe("unicode", () => { @@ -1645,6 +1681,19 @@ describe("deno_task", () => { TestBuilder.command`VAR=$(echo a && echo b) && echo "$VAR"` .stdout("a\nb\n") .runAsTest("assignment keeps command substitution newlines"); + + // An unquoted $VAR is not split into fields, so an assignment value drops the trailing whitespace. + TestBuilder.command`A=$(echo 'f '); B="$(echo 'g ')"; export C="$(echo 'h ')"; ${BUN} run ./code.ts $A $B $C` + .ensureTempDir() + .file("code.ts", `console.log(JSON.stringify(process.argv.slice(2)))`) + .stdout('["f","g","h"]\n') + .runAsTest("unquoted variable assigned a command substitution with a trailing space"); + + // bash keeps the space in the value and drops it when it splits an unquoted $VAR. + TestBuilder.command`VAR="$(echo 'a ')" && echo "[$VAR]"` + .stdout("[a ]\n") + .todo("an unquoted variable is not split into fields") + .runAsTest("assignment keeps the trailing space of a command substitution"); }); describe("env variables", async () => { diff --git a/test/js/bun/shell/commands/exit.test.ts b/test/js/bun/shell/commands/exit.test.ts index 5a3239d0f49d..f57747a6e367 100644 --- a/test/js/bun/shell/commands/exit.test.ts +++ b/test/js/bun/shell/commands/exit.test.ts @@ -15,6 +15,23 @@ describe("exit", async () => { TestBuilder.command`exit 62757836`.exitCode(204).runAsTest("exit code wraps u8"); + // bash accepts a space or a tab around the number, and no other byte after it. + describe("blanks around the number", () => { + for (const arg of ["3 ", " 3", "3\t"]) { + TestBuilder.command`exit ${arg}`.exitCode(3).runAsTest(`accepts ${JSON.stringify(arg)}`); + } + + for (const arg of ["3\r", "3\n", "3\f"]) { + TestBuilder.command`exit ${arg}` + .exitCode(1) + .stderr("exit: numeric argument required\n") + .runAsTest(`rejects ${JSON.stringify(arg)}`); + } + + // A quoted substitution keeps its trailing space, so the status reaches exit with it. + TestBuilder.command`exit "$(echo '3 ')"`.exitCode(3).runAsTest("accepts a quoted command substitution"); + }); + // prettier-ignore TestBuilder.command`exit abc`.exitCode(1).stderr("exit: numeric argument required\n").runAsTest("numeric argument required"); }); diff --git a/test/js/bun/shell/exec.test.ts b/test/js/bun/shell/exec.test.ts index b7c272197124..973d9148afdc 100644 --- a/test/js/bun/shell/exec.test.ts +++ b/test/js/bun/shell/exec.test.ts @@ -42,6 +42,11 @@ describe("bun exec", () => { ) .runAsTest("write a lot of data"); + TestBuilder.command`${BUN} exec ${`echo "[$(echo 'a ')]"`}` + .env(bunEnv) + .stdout("[a ]\n") + .runAsTest("quoted command substitution keeps a trailing space"); + describe("--help works", () => { // prettier-ignore const programs = [