Skip to content

Commit b074fa0

Browse files
committed
fix: preserve parser compatibility for unmatched ')' handling
1 parent 735b5e8 commit b074fa0

2 files changed

Lines changed: 42 additions & 42 deletions

File tree

shellwords.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,12 @@ loop:
204204
case ')':
205205
if !singleQuoted && !doubleQuoted && !backQuote {
206206
if p.ParseBacktick {
207-
// Hardened fix:
207+
// Security fix:
208208
// A bare ')' must never open dollarQuote state.
209-
// Only close an already-open $(...) region.
209+
// Preserve prior behavior by rejecting unmatched ')'
210+
// when command substitution parsing is enabled.
210211
if !dollarQuote {
211-
buf += string(r)
212-
got = argSingle
213-
continue
212+
return nil, errors.New("invalid command line string")
214213
}
215214

216215
out, err := shellRun(backtick, p.Dir)
@@ -230,17 +229,19 @@ loop:
230229
continue
231230
}
232231

233-
// Default mode:
234-
// Do not toggle dollarQuote on a bare ')'.
235-
// Treat it as a literal character.
236-
if !dollarQuote {
232+
// Backtick parsing disabled:
233+
// preserve literal text for $(...) constructs instead of
234+
// silently dropping the closing ')'.
235+
if dollarQuote {
237236
buf += string(r)
237+
backtick = ""
238+
dollarQuote = false
238239
got = argSingle
239240
continue
240241
}
241242

242-
backtick = ""
243-
dollarQuote = false
243+
buf += string(r)
244+
got = argSingle
244245
continue
245246
}
246247

@@ -350,4 +351,4 @@ func Parse(line string) ([]string, error) {
350351

351352
func ParseWithEnvs(line string) (envs []string, args []string, err error) {
352353
return NewParser().ParseWithEnvs(line)
353-
}
354+
}

shellwords_security_test.go

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,36 @@ import (
55
"testing"
66
)
77

8-
func TestUnmatchedClosingParen_NoPanic_ParseBacktickEnabled(t *testing.T) {
8+
// ParseBacktick=true → an unmatched ‘)’ should be treated as an ERROR (not a literal)
9+
func TestUnmatchedClosingParen_ReturnsError_ParseBacktickEnabled(t *testing.T) {
910
p := NewParser()
1011
p.ParseBacktick = true
1112

12-
defer func() {
13-
if r := recover(); r != nil {
14-
t.Fatalf("unexpected panic: %v", r)
15-
}
16-
}()
17-
18-
out, err := p.Parse("))")
19-
if err != nil {
20-
t.Fatalf("unexpected error: %v", err)
21-
}
22-
23-
if len(out) != 1 || out[0] != "))" {
24-
t.Fatalf("unexpected output: %#v", out)
13+
_, err := p.Parse("))")
14+
if err == nil {
15+
t.Fatal("expected error for unmatched ')', got nil")
2516
}
2617
}
2718

28-
func TestBareClosingParen_DoesNotTriggerCommandExecution(t *testing.T) {
19+
// ParseBacktick=true → Do not execute; must fail
20+
func TestBareClosingParen_NoExecution_ReturnsError(t *testing.T) {
2921
p := NewParser()
3022
p.ParseBacktick = true
3123

32-
out, err := p.Parse("prefix)echo PWNED)")
33-
if err != nil {
34-
t.Fatalf("unexpected error: %v", err)
24+
_, err := p.Parse("prefix)echo PWNED)")
25+
if err == nil {
26+
t.Fatal("expected error for unmatched ')', got nil")
3527
}
28+
}
3629

37-
got := strings.Join(out, " ")
38-
if strings.Contains(got, "PWNED") && got != "prefix)echo PWNED)" {
39-
t.Fatalf("unexpected command execution or substitution occurred: %q", got)
30+
// Default behavior → ‘)’ is a literal; it does not break parsing
31+
func TestBareClosingParen_DefaultParsingLiteral(t *testing.T) {
32+
out, err := Parse(")a b)c d")
33+
if err != nil {
34+
t.Fatalf("unexpected error: %v", err)
4035
}
4136

42-
want := []string{"prefix)echo", "PWNED)"}
37+
want := []string{")a", "b)c", "d"}
4338
if len(out) != len(want) {
4439
t.Fatalf("unexpected token count: got %d, want %d, out=%#v", len(out), len(want), out)
4540
}
@@ -50,23 +45,26 @@ func TestBareClosingParen_DoesNotTriggerCommandExecution(t *testing.T) {
5045
}
5146
}
5247

53-
func TestBareClosingParen_DefaultParsingLiteral(t *testing.T) {
54-
out, err := Parse(")a b)c d")
48+
// ParseBacktick=false → $(...) remains a FULL literal
49+
func TestDollarParenLiteralWhenParseBacktickDisabled(t *testing.T) {
50+
p := NewParser()
51+
p.ParseBacktick = false
52+
53+
out, err := p.Parse(`$(echo "foo")`)
5554
if err != nil {
5655
t.Fatalf("unexpected error: %v", err)
5756
}
5857

59-
want := []string{")a", "b)c", "d"}
58+
want := []string{`$(echo "foo")`}
6059
if len(out) != len(want) {
6160
t.Fatalf("unexpected token count: got %d, want %d, out=%#v", len(out), len(want), out)
6261
}
63-
for i := range want {
64-
if out[i] != want[i] {
65-
t.Fatalf("unexpected token at %d: got %q, want %q, out=%#v", i, out[i], want[i], out)
66-
}
62+
if out[0] != want[0] {
63+
t.Fatalf("unexpected output: got %q, want %q", out[0], want[0])
6764
}
6865
}
6966

67+
// If true → $(...) continues to work with ParseBacktick=true
7068
func TestValidDollarCommandSubstitutionStillWorks(t *testing.T) {
7169
p := NewParser()
7270
p.ParseBacktick = true
@@ -82,6 +80,7 @@ func TestValidDollarCommandSubstitutionStillWorks(t *testing.T) {
8280
}
8381
}
8482

83+
// Error → Unclosed $(
8584
func TestUnclosedDollarCommandSubstitutionReturnsError(t *testing.T) {
8685
p := NewParser()
8786
p.ParseBacktick = true
@@ -90,4 +89,4 @@ func TestUnclosedDollarCommandSubstitutionReturnsError(t *testing.T) {
9089
if err == nil {
9190
t.Fatal("expected error for unclosed command substitution, got nil")
9291
}
93-
}
92+
}

0 commit comments

Comments
 (0)