Repository navigation
shell: take the command-substitution capture and drop its NUL bytes #44752
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,8 @@ | |
|
|
||
| use crate::shell::ast; | ||
| use crate::shell::interpreter::{ | ||
| EventLoopHandle, Interpreter, Node, NodeId, ShellExecEnv, ShellExecEnvKind, StateKind, log, | ||
| Bufio, EventLoopHandle, Interpreter, Node, NodeId, ShellExecEnv, ShellExecEnvKind, StateKind, | ||
| log, | ||
| }; | ||
| use crate::shell::io::{IO, OutKind}; | ||
| use crate::shell::states::base::Base; | ||
|
|
@@ -43,7 +44,7 @@ pub(crate) struct Expansion { | |
| /// The atom is an assignment value (`A=v`, `export A=v`), so | ||
| /// command-substitution output is not field split (POSIX 2.9.1). | ||
| pub(crate) assign_ctx: bool, | ||
| /// Set when a `""`/`''` literal | ||
| /// Set when a `""`/`''` literal (or a quoted `$(...)` whose output was only NUL bytes) | ||
| /// was seen so an *empty* expansion is still pushed as an argv word. | ||
| /// Without this, `$unset` and `""` are indistinguishable in | ||
| /// [`ExpansionOut`] (both → `buf=[], bounds=[]`) and Cmd would push an | ||
|
|
@@ -598,7 +599,7 @@ impl Expansion { | |
| exit_code: ExitCode, | ||
| ) -> Yield { | ||
| // Child is a Script (command substitution). Its captured stdout lives | ||
| // in the duped `ShellExecEnv` it owns; read it before deinit. | ||
| // in the duped `ShellExecEnv` it owns; take it before deinit. | ||
| debug_assert!(matches!(interp.node(child).kind(), StateKind::Script)); | ||
| if interp.failed() { | ||
| // The script failed: the rest of the word is not expanded. | ||
|
|
@@ -611,16 +612,22 @@ impl Expansion { | |
| let parent = interp.as_expansion(this).base.parent; | ||
| return interp.child_done(parent, this, 1); | ||
| } | ||
| // SAFETY: single trampoline frame; the child script's env (and its | ||
| // parent buffer in the `Borrowed` case) has no other live borrow. | ||
| let stdout = unsafe { | ||
| interp | ||
| .as_script_mut(child) | ||
| .base | ||
| .shell_mut() | ||
| .buffered_stdout_mut() | ||
| let mut stdout = match &mut interp | ||
| .as_script_mut(child) | ||
| .base | ||
| .shell_mut() | ||
| ._buffered_stdout | ||
| { | ||
| Bufio::Owned(buf) => core::mem::take(buf), | ||
| Bufio::Borrowed(_) => unreachable!( | ||
| "dupe_for_subshell gives a command substitution an owned stdout buffer" | ||
| ), | ||
| }; | ||
| // NUL bytes are dropped as in bash and dash, before the trim and the field split. | ||
| let had_nul = bun_core::strings::contains_char(&stdout, 0); | ||
| if had_nul { | ||
| stdout.retain(|&b| b != 0); | ||
| } | ||
| .clone(); | ||
|
|
||
| // Propagate the exit code if the *whole* atom was a single `$(...)` | ||
| // (so `$(false)` as argv0 fails the command). | ||
|
|
@@ -643,7 +650,16 @@ impl Expansion { | |
| 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); | ||
| // 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; | ||
| } | ||
|
Comment on lines
+654
to
+657
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Why this was flaggedA user runs Verification: Not a regression: no input changes result versus base. src/runtime/shell/states/Expansion.rs:655-657 sets |
||
| if me.current_out.is_empty() { | ||
| me.current_out = stdout; | ||
| } else { | ||
| me.current_out.extend_from_slice(&stdout); | ||
| } | ||
| } else { | ||
| Self::post_subshell_expansion(me, stdout); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1497,6 +1497,70 @@ describe("deno_task", () => { | |
| TestBuilder.command`echo $(echo 1)`.stdout("1\n").runAsTest("nested echo cmd subst"); | ||
| TestBuilder.command`echo $(echo 1 && echo 2)`.stdout("1 2\n").runAsTest("nested echo cmd subst with conditional"); | ||
| // TODO Sleep tests | ||
|
|
||
| // bash and dash drop NUL bytes from the output of a command substitution. | ||
| // A NUL left in the word ends an argument, a builtin operand, an exported | ||
| // value and a redirect target there. | ||
| describe("drops NUL bytes from the output", () => { | ||
| const printArgs = /* ts */ `console.log(JSON.stringify(process.argv.slice(2)))`; | ||
|
|
||
| TestBuilder.command`echo "$(echo -e 'a\0b\0c')"`.stdout("abc\n").runAsTest("quoted"); | ||
| TestBuilder.command`echo $(echo -e 'a\0b c\0d')`.stdout("ab cd\n").runAsTest("unquoted"); | ||
| TestBuilder.command`echo x$(${BUN} -e ${"process.stdout.write('a\\0b')"})y` | ||
| .stdout("xaby\n") | ||
| .runAsTest("written by a subprocess"); | ||
| TestBuilder.command`echo "$( (echo -e 'a\0b'; echo -e 'c\0d') | cat)"` | ||
| .stdout("ab\ncd\n") | ||
| .runAsTest("written by a subshell and a pipeline"); | ||
| TestBuilder.command`echo "[$(echo -e 'a\n\0')]"` | ||
| .stdout("[a]\n") | ||
| .runAsTest("before the trailing newlines are trimmed"); | ||
| TestBuilder.command`echo $(echo -e 'a\0b'){1,2}`.stdout("ab1 ab2\n").runAsTest("before brace expansion"); | ||
|
|
||
| TestBuilder.command`${BUN} run ./code.ts x $(echo -e '\0') y $(echo -e 'a \0 b') "$(echo -e '\0')" z` | ||
| .ensureTempDir() | ||
| .file("code.ts", printArgs) | ||
| .stdout(out => expect(JSON.parse(out)).toEqual(["x", "y", "a", "b", "", "z"])) | ||
| .runAsTest("a NUL is not a field, and a quoted NUL is one empty argument"); | ||
|
|
||
| // bash passes ["a", "", "b"]. Here an empty quoted variable is no word | ||
| // at all, and the NUL that hid this is gone. | ||
| TestBuilder.command`X=$(echo -e '\0'); ${BUN} run ./code.ts a "$X" b` | ||
| .ensureTempDir() | ||
| .file("code.ts", printArgs) | ||
| .stdout(out => expect(JSON.parse(out)).toEqual(["a", "", "b"])) | ||
| .todo("an empty quoted variable yields no word") | ||
| .runAsTest("a quoted variable assigned only NUL bytes is one empty argument"); | ||
|
|
||
| TestBuilder.command`X=$(echo -e 'a\0b'); echo "$X"; export Y=$(echo -e 'c\0d'); ${BUN} -e ${"console.log(process.env.Y)"}` | ||
| .stdout("ab\ncd\n") | ||
| .runAsTest("assignment"); | ||
|
|
||
| TestBuilder.command`[[ "$(echo -e 'a\0b')" == "ab" ]] && echo yes || echo no; [[ -z "$(echo -e '\0')" ]] && echo yes || echo no` | ||
| .stdout("yes\nyes\n") | ||
| .runAsTest("[[ ]] operands"); | ||
|
|
||
| TestBuilder.command`touch rmf rmfzz; rm $(echo -e 'rmf\0zz'); ls` | ||
| .ensureTempDir() | ||
| .stdout("rmf\n") | ||
| .runAsTest("rm removes the file that the output names"); | ||
|
|
||
| // A debug build aborted on these two words ("interior NUL would truncate | ||
| // the C view"). `bun exec` runs them in a child process. | ||
| TestBuilder.command`${BUN} exec ${`echo hi > "$(echo -e 'f\\0zz')"; touch t1; [[ -f $(echo -e 't1\\0zzz') ]] && echo yes || echo no`}` | ||
| .ensureTempDir() | ||
| .stdout("no\n") | ||
| .fileEquals("fzz", "hi\n") | ||
| .doesNotExist("f") | ||
|
Comment on lines
+1551
to
+1554
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): the Why this was flaggedThe cell at test/js/bun/shell/bunshell.test.ts:1545-1554 runs Verification: nit. Triggering condition: any future regression that leaves the |
||
| .runAsTest("redirect target and file test, through bun exec"); | ||
|
|
||
| // Only the word loses the NUL bytes. The output that the shell itself | ||
| // captures keeps them, and a substitution takes nothing out of it. | ||
| TestBuilder.command`echo before; echo "$(echo -e 'm\0id')"; echo -e 'a\0b' | cat; echo -e 'c\0d'; echo after` | ||
| .quiet() | ||
| .stdout("before\nmid\na\0b\nc\0d\nafter\n") | ||
| .runAsTest("the captured output of the shell keeps its NUL bytes"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("shell variables", async () => { | ||
|
|
||
There was a problem hiding this comment.
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 ')"yieldsa, bash yieldsa. The trim loop at src/runtime/shell/states/Expansion.rs:650 stripsb' ' | 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 existingnested echo cmd substtests 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 givingxawhere bash givesxa.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 ')orecho "[$(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 inb' ' | b'\n' | b'\r' | b'\t'andtruncate(hi)commits it, so the word isa. bash and dash remove only trailing newlines in a quoted or assignment command substitution, so they yielda/[a\t]. The base branch has the same loop (it usedextend_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')"toxawhere the base happened to keepxabecause the NUL protected the blank. No guard elsewhere restores the blanks: Cmd and Assign consumeout.bufas 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, soX=$(printf 'a ')yieldsaversusain bash. The base has the same predicate at line 643.There was a problem hiding this comment.
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-whitespaceIt 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.