Compare commits

...

3 Commits

Author SHA1 Message Date
Jarred Sumner
2f03dab658 Merge branch 'main' into claude/fix-empty-string-shell-args 2026-02-19 20:22:23 -08:00
Jarred Sumner
0ee3b7215e Merge branch 'main' into claude/fix-empty-string-shell-args 2026-02-19 19:27:49 -08:00
Claude Bot
333970dc2f fix(shell): preserve empty string arguments in Bun shell
Empty string arguments (`""`, `''`, `${''}`) were silently dropped instead
of being passed as arguments. This affected commands like `ssh-keygen -N ""`
where the empty passphrase argument was lost.

Root causes fixed:
- `appendBunStr`: empty interpolated values now emit `""` in the script text
  so the lexer can see them
- Single quote lexing: added `break_word` calls matching double quote behavior,
  so `''` produces `SingleQuotedText` tokens (previously lost quote context)
- `isImmediatelyEscapedQuote`: now handles `''` in addition to `""`
- New `quoted_empty` AST atom preserves empty quoted strings through parsing
- `pushCurrentOut` in expansion no longer drops empty results from quoted contexts

Closes #17294

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 03:23:31 +00:00
4 changed files with 78 additions and 6 deletions

View File

@@ -1020,6 +1020,9 @@ pub const AST = struct {
Var: []const u8,
VarArgv: u8,
Text: []const u8,
/// An empty string from a quoted context (e.g. "", '', or ${''}). Preserved as an
/// explicit empty argument during expansion, unlike unquoted empty text which is dropped.
quoted_empty,
asterisk,
double_asterisk,
brace_begin,
@@ -1042,6 +1045,7 @@ pub const AST = struct {
.Var => false,
.VarArgv => false,
.Text => false,
.quoted_empty => false,
.asterisk => true,
.double_asterisk => true,
.brace_begin => false,
@@ -1845,6 +1849,9 @@ pub const Parser = struct {
if (txt.len > 0) {
try atoms.append(.{ .Text = txt });
}
} else if (txt.len == 0 and (peeked == .SingleQuotedText or peeked == .DoubleQuotedText)) {
// Preserve empty quoted strings ("", '') as explicit empty arguments
try atoms.append(.quoted_empty);
} else {
try atoms.append(.{ .Text = txt });
}
@@ -2789,10 +2796,12 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
comptime assertSpecialChar('\'');
if (self.chars.state == .Single) {
try self.break_word(false);
self.chars.state = .Normal;
continue;
}
if (self.chars.state == .Normal) {
try self.break_word(false);
self.chars.state = .Single;
continue;
}
@@ -2888,9 +2897,12 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
}
inline fn isImmediatelyEscapedQuote(self: *@This()) bool {
return (self.chars.state == .Double and
return ((self.chars.state == .Double and
(self.chars.current != null and !self.chars.current.?.escaped and self.chars.current.?.char == '"') and
(self.chars.prev != null and !self.chars.prev.?.escaped and self.chars.prev.?.char == '"'));
(self.chars.prev != null and !self.chars.prev.?.escaped and self.chars.prev.?.char == '"')) or
(self.chars.state == .Single and
(self.chars.current != null and !self.chars.current.?.escaped and self.chars.current.?.char == '\'') and
(self.chars.prev != null and !self.chars.prev.?.escaped and self.chars.prev.?.char == '\'')));
}
fn break_word_impl(self: *@This(), add_delimiter: bool, in_normal_space: bool, in_operator: bool) !void {
@@ -4063,6 +4075,14 @@ pub const ShellSrcBuilder = struct {
) bun.OOM!bool {
const invalid = (bunstr.isUTF16() and !bun.simdutf.validate.utf16le(bunstr.utf16())) or (bunstr.isUTF8() and !bun.simdutf.validate.utf8(bunstr.byteSlice()));
if (invalid) return false;
// Empty interpolated values must still produce an argument (e.g. `${''}` should
// pass "" as an arg). Write literal `""` so the lexer sees an empty quoted string.
// Only do this for template values (allow_escape=true), not for template string
// parts (allow_escape=false), which are the static parts of the template literal.
if (allow_escape and bunstr.length() == 0) {
try this.outbuf.appendSlice("\"\"");
return true;
}
if (allow_escape) {
if (needsEscapeBunstr(bunstr)) {
try this.appendJSStrRef(bunstr);

View File

@@ -36,6 +36,9 @@ child_state: union(enum) {
out_exit_code: ExitCode = 0,
out: Result,
out_idx: u32,
/// Set when the word contains a quoted_empty atom, indicating that an empty
/// result should still be preserved as an argument (POSIX: `""` produces an empty arg).
has_quoted_empty: bool = false,
pub const ParentPtr = StatePtrUnion(.{
Cmd,
@@ -586,6 +589,11 @@ pub fn expandSimpleNoIO(this: *Expansion, atom: *const ast.SimpleAtom, str_list:
.Text => |txt| {
bun.handleOom(str_list.appendSlice(txt));
},
.quoted_empty => {
// A quoted empty string ("", '', or ${''}). We must ensure the word
// is not dropped by pushCurrentOut, so mark it with a flag.
this.has_quoted_empty = true;
},
.Var => |label| {
bun.handleOom(str_list.appendSlice(this.expandVar(label)));
},
@@ -630,8 +638,8 @@ pub fn appendSlice(this: *Expansion, buf: *std.array_list.Managed(u8), slice: []
}
pub fn pushCurrentOut(this: *Expansion) void {
if (this.current_out.items.len == 0) return;
if (this.current_out.items[this.current_out.items.len - 1] != 0) bun.handleOom(this.current_out.append(0));
if (this.current_out.items.len == 0 and !this.has_quoted_empty) return;
if (this.current_out.items.len == 0 or this.current_out.items[this.current_out.items.len - 1] != 0) bun.handleOom(this.current_out.append(0));
switch (this.out.pushResult(&this.current_out)) {
.copied => {
this.current_out.clearRetainingCapacity();
@@ -709,6 +717,7 @@ fn expansionSizeHint(this: *const Expansion, atom: *const ast.Atom, has_unknown:
fn expansionSizeHintSimple(this: *const Expansion, simple: *const ast.SimpleAtom, has_unknown: *bool) usize {
return switch (simple.*) {
.Text => |txt| txt.len,
.quoted_empty => 0,
.Var => |label| this.expandVar(label).len,
.VarArgv => |int| this.expandVarArgv(int).len,
.brace_begin, .brace_end, .comma, .asterisk => 1,

View File

@@ -105,8 +105,7 @@ describe("lex shell", () => {
{ "Delimit": {} },
{ "Text": "dev" },
{ "Delimit": {} },
{ "Text": "hello how is it going" },
{ "Delimit": {} },
{ "SingleQuotedText": "hello how is it going" },
{ "Eof": {} },
];
const result = JSON.parse(lex`next dev 'hello how is it going'`);

View File

@@ -0,0 +1,44 @@
import { $ } from "bun";
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/17294
// Empty string arguments should be passed through, not silently dropped.
test("empty string interpolation passes empty arg", async () => {
const result = await $`${bunExe()} -e "console.log(JSON.stringify(process.argv.slice(1)))" -- ${""}`
.env(bunEnv)
.text();
expect(JSON.parse(result.trim())).toEqual([""]);
});
test("double-quoted empty string passes empty arg", async () => {
const result = await $`${bunExe()} -e "console.log(JSON.stringify(process.argv.slice(1)))" -- ""`.env(bunEnv).text();
expect(JSON.parse(result.trim())).toEqual([""]);
});
test("single-quoted empty string passes empty arg", async () => {
const result = await $`${bunExe()} -e "console.log(JSON.stringify(process.argv.slice(1)))" -- ''`.env(bunEnv).text();
expect(JSON.parse(result.trim())).toEqual([""]);
});
test("non-empty string still works (control)", async () => {
const result = await $`${bunExe()} -e "console.log(JSON.stringify(process.argv.slice(1)))" -- ${"hello"}`
.env(bunEnv)
.text();
expect(JSON.parse(result.trim())).toEqual(["hello"]);
});
test("multiple empty strings", async () => {
const result = await $`${bunExe()} -e "console.log(JSON.stringify(process.argv.slice(1)))" -- ${""} ${""}`
.env(bunEnv)
.text();
expect(JSON.parse(result.trim())).toEqual(["", ""]);
});
test("empty string between non-empty strings", async () => {
const result = await $`${bunExe()} -e "console.log(JSON.stringify(process.argv.slice(1)))" -- ${"a"} ${""} ${"b"}`
.env(bunEnv)
.text();
expect(JSON.parse(result.trim())).toEqual(["a", "", "b"]);
});