From 1c5380e59c911cb9a209f232bcd5825c9649ee3a Mon Sep 17 00:00:00 2001 From: "dd-octo-sts-26fcfa[bot]" <266798054+dd-octo-sts-26fcfa[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:23:20 +0000 Subject: [PATCH 01/10] Executing automated changes Co-authored-by: dd-octo-sts-03ec73[bot] <256648721+dd-octo-sts-03ec73[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5aa4ee27..5ceea828 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( golang.org/x/sys v0.43.0 golang.org/x/tools v0.44.0 gopkg.in/yaml.v3 v3.0.1 - mvdan.cc/sh/v3 v3.13.0 + mvdan.cc/sh/v3 v3.13.1 ) require ( diff --git a/go.sum b/go.sum index ba431edb..9c50004b 100644 --- a/go.sum +++ b/go.sum @@ -78,5 +78,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= -mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= From cfb88e2bfdd8c561a310c763f2f330aad735fddf Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 28 Apr 2026 09:23:13 +0200 Subject: [PATCH 02/10] fix escaped brace expansion Co-Authored-By: Claude Opus 4.6 --- analysis/symbols_interp.go | 2 + interp/runner_expand.go | 105 ++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index 3da6c1a2..ae50d716 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -56,9 +56,11 @@ var interpAllowedSymbols = []string{ "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. + "strings.Contains", // 🟢 checks for an escaped left brace marker; pure function, no I/O. "strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O. "strings.NewReader", // 🟢 wraps a string as an io.Reader; pure function, no I/O; used by ParseScript. "strings.Index", // 🟢 finds substring index; pure function, no I/O. + "strings.Repeat", // 🟢 builds quoted backslash prefixes during word expansion; pure function, no I/O. "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. "strings.Join", // 🟢 joins string slices; pure function, no I/O. diff --git a/interp/runner_expand.go b/interp/runner_expand.go index ef46d64f..27a6d766 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -231,11 +231,114 @@ func (r *Runner) expandErr(err error) { } func (r *Runner) fields(words ...*syntax.Word) []string { - strs, err := expand.Fields(r.ecfg, words...) + strs, err := expand.Fields(r.ecfg, protectEscapedLeftBraces(words)...) r.expandErr(err) return strs } +// protectEscapedLeftBraces preserves bash's handling of backslash-quoted left +// braces before delegating to mvdan.cc/sh's field expansion. +// +// Brace expansion happens before quote removal, but backslashes still quote the +// next byte for the purpose of deciding whether a "{" starts a brace +// expansion. syntax.SplitBraces does not track that quote state for literal +// parts, so an input like `\{` can be treated as an unmatched brace and keep the +// backslash. Rewriting odd-backslash-escaped left braces as quoted word parts +// prevents brace expansion from seeing them while producing the same final text. +func protectEscapedLeftBraces(words []*syntax.Word) []*syntax.Word { + var out []*syntax.Word + for i, word := range words { + protected := protectEscapedLeftBracesWord(word) + if protected != word && out == nil { + out = make([]*syntax.Word, len(words)) + copy(out, words[:i]) + } + if out != nil { + out[i] = protected + } + } + if out == nil { + return words + } + return out +} + +func protectEscapedLeftBracesWord(word *syntax.Word) *syntax.Word { + if word == nil { + return nil + } + var parts []syntax.WordPart + for i, part := range word.Parts { + lit, ok := part.(*syntax.Lit) + if !ok { + if parts != nil { + parts = append(parts, part) + } + continue + } + litParts, changed := splitEscapedLeftBracesLit(lit) + if changed && parts == nil { + parts = make([]syntax.WordPart, 0, len(word.Parts)+len(litParts)-1) + parts = append(parts, word.Parts[:i]...) + } + if parts != nil { + parts = append(parts, litParts...) + } + } + if parts == nil { + return word + } + protected := *word + protected.Parts = parts + return &protected +} + +func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { + s := lit.Value + if !strings.Contains(s, "\\{") { + return nil, false + } + + var parts []syntax.WordPart + segmentStart := 0 + appendLit := func(value string) { + if value == "" { + return + } + part := *lit + part.Value = value + parts = append(parts, &part) + } + + for i := 0; i < len(s); i++ { + if s[i] != '{' { + continue + } + slashStart := i + for slashStart > segmentStart && s[slashStart-1] == '\\' { + slashStart-- + } + slashCount := i - slashStart + if slashCount%2 == 0 { + continue + } + if parts == nil { + parts = make([]syntax.WordPart, 0, 3) + } + appendLit(s[segmentStart:slashStart]) + parts = append(parts, &syntax.SglQuoted{ + Value: strings.Repeat("\\", slashCount/2) + "{", + }) + segmentStart = i + 1 + } + + if parts == nil { + return nil, false + } + appendLit(s[segmentStart:]) + return parts, true +} + func (r *Runner) literal(word *syntax.Word) string { str, err := expand.Literal(r.ecfg, word) r.expandErr(err) From f17690cf669395255d404dca71ada8b445808307 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 28 Apr 2026 09:39:06 +0200 Subject: [PATCH 03/10] test escaped brace expansion Co-Authored-By: Claude Opus 4.6 --- analysis/symbols_interp.go | 2 - interp/runner_expand.go | 17 ++++- interp/runner_expand_test.go | 65 +++++++++++++++++++ .../quoting/escaped_left_brace.yaml | 16 +++++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 interp/runner_expand_test.go create mode 100644 tests/scenarios/shell/var_expand/quoting/escaped_left_brace.yaml diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index ae50d716..3da6c1a2 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -56,11 +56,9 @@ var interpAllowedSymbols = []string{ "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. - "strings.Contains", // 🟢 checks for an escaped left brace marker; pure function, no I/O. "strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O. "strings.NewReader", // 🟢 wraps a string as an io.Reader; pure function, no I/O; used by ParseScript. "strings.Index", // 🟢 finds substring index; pure function, no I/O. - "strings.Repeat", // 🟢 builds quoted backslash prefixes during word expansion; pure function, no I/O. "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. "strings.Join", // 🟢 joins string slices; pure function, no I/O. diff --git a/interp/runner_expand.go b/interp/runner_expand.go index 27a6d766..3a513cb1 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -295,7 +295,7 @@ func protectEscapedLeftBracesWord(word *syntax.Word) *syntax.Word { func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { s := lit.Value - if !strings.Contains(s, "\\{") { + if strings.Index(s, "\\{") < 0 { return nil, false } @@ -327,7 +327,7 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { } appendLit(s[segmentStart:slashStart]) parts = append(parts, &syntax.SglQuoted{ - Value: strings.Repeat("\\", slashCount/2) + "{", + Value: escapedLeftBraceValue(slashCount), }) segmentStart = i + 1 } @@ -339,6 +339,19 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { return parts, true } +func escapedLeftBraceValue(slashCount int) string { + quotedSlashCount := slashCount / 2 + if quotedSlashCount == 0 { + return "{" + } + var b strings.Builder + for i := 0; i < quotedSlashCount; i++ { + b.WriteByte('\\') + } + b.WriteByte('{') + return b.String() +} + func (r *Runner) literal(word *syntax.Word) string { str, err := expand.Literal(r.ecfg, word) r.expandErr(err) diff --git a/interp/runner_expand_test.go b/interp/runner_expand_test.go new file mode 100644 index 00000000..9a0a8861 --- /dev/null +++ b/interp/runner_expand_test.go @@ -0,0 +1,65 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" +) + +func TestProtectEscapedLeftBraces(t *testing.T) { + tests := []struct { + name string + script string + want []string + }{ + { + name: "escaped_unmatched_left_brace", + script: `cmd \{`, + want: []string{"cmd", "{"}, + }, + { + name: "escaped_left_brace_disables_brace_expansion", + script: `cmd \{a,b}`, + want: []string{"cmd", "{a,b}"}, + }, + { + name: "escaped_left_brace_in_middle_of_word", + script: `cmd pre\{post`, + want: []string{"cmd", "pre{post"}, + }, + { + name: "even_backslashes_still_allow_brace_expansion", + script: `cmd \\{a,b}`, + want: []string{"cmd", `\a`, `\b`}, + }, + { + name: "odd_backslashes_quote_left_brace", + script: `cmd \\\{a,b}`, + want: []string{"cmd", `\{a,b}`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prog, err := syntax.NewParser().Parse(strings.NewReader(tt.script), "") + require.NoError(t, err) + require.Len(t, prog.Stmts, 1) + + call, ok := prog.Stmts[0].Cmd.(*syntax.CallExpr) + require.True(t, ok) + + fields, err := expand.Fields(nil, protectEscapedLeftBraces(call.Args)...) + require.NoError(t, err) + assert.Equal(t, tt.want, fields) + }) + } +} diff --git a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace.yaml b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace.yaml new file mode 100644 index 00000000..79b7cfcb --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace.yaml @@ -0,0 +1,16 @@ +description: Escaped left braces follow bash quote-removal and brace-expansion rules. +input: + script: |+ + printf '<%s>\n' \{ + printf '<%s>\n' \{a,b} + printf '<%s>\n' \\{a,b} + printf '<%s>\n' \\\{a,b} +expect: + stdout: |+ + <{> + <{a,b}> + <\a> + <\b> + <\{a,b}> + stderr: |+ + exit_code: 0 From 9f9488f63d30d7af86ee96752085595bf2ba4b5f Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 28 Apr 2026 09:50:46 +0200 Subject: [PATCH 04/10] expand escaped brace test coverage Co-Authored-By: Claude Opus 4.6 --- interp/runner_expand_test.go | 38 ++++++++++++++++++- .../quoting/escaped_left_brace_composite.yaml | 20 ++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml diff --git a/interp/runner_expand_test.go b/interp/runner_expand_test.go index 9a0a8861..ecd64afe 100644 --- a/interp/runner_expand_test.go +++ b/interp/runner_expand_test.go @@ -19,6 +19,7 @@ func TestProtectEscapedLeftBraces(t *testing.T) { tests := []struct { name string script string + env []string want []string }{ { @@ -36,11 +37,42 @@ func TestProtectEscapedLeftBraces(t *testing.T) { script: `cmd pre\{post`, want: []string{"cmd", "pre{post"}, }, + { + name: "multiple_escaped_left_braces_in_one_literal", + script: `cmd \{\{`, + want: []string{"cmd", "{{"}, + }, + { + name: "escaped_left_brace_adjacent_to_quoted_part", + script: `cmd \{"x"`, + want: []string{"cmd", "{x"}, + }, + { + name: "escaped_left_brace_adjacent_to_parameter_expansion", + script: `cmd \{$X`, + env: []string{"X=ok"}, + want: []string{"cmd", "{ok"}, + }, + { + name: "escaped_left_brace_disables_sequence_expansion", + script: `cmd \{1..3}`, + want: []string{"cmd", "{1..3}"}, + }, + { + name: "unescaped_left_brace_still_expands", + script: `cmd {a,b}`, + want: []string{"cmd", "a", "b"}, + }, { name: "even_backslashes_still_allow_brace_expansion", script: `cmd \\{a,b}`, want: []string{"cmd", `\a`, `\b`}, }, + { + name: "even_backslashes_still_allow_sequence_expansion", + script: `cmd \\{1..3}`, + want: []string{"cmd", `\1`, `\2`, `\3`}, + }, { name: "odd_backslashes_quote_left_brace", script: `cmd \\\{a,b}`, @@ -57,7 +89,11 @@ func TestProtectEscapedLeftBraces(t *testing.T) { call, ok := prog.Stmts[0].Cmd.(*syntax.CallExpr) require.True(t, ok) - fields, err := expand.Fields(nil, protectEscapedLeftBraces(call.Args)...) + cfg := &expand.Config{} + if len(tt.env) > 0 { + cfg.Env = expand.ListEnviron(tt.env...) + } + fields, err := expand.Fields(cfg, protectEscapedLeftBraces(call.Args)...) require.NoError(t, err) assert.Equal(t, tt.want, fields) }) diff --git a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml new file mode 100644 index 00000000..255a26f0 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml @@ -0,0 +1,20 @@ +description: Escaped left braces work next to other word parts and sequence syntax. +input: + script: |+ + X=ok + printf '<%s>\n' \{\{ + printf '<%s>\n' \{"x" + printf '<%s>\n' \{$X + printf '<%s>\n' \{1..3} + printf '<%s>\n' \\{1..3} +expect: + stdout: |+ + <{{> + <{x> + <{ok> + <{1..3}> + <\1> + <\2> + <\3> + stderr: |+ + exit_code: 0 From c0c333488047978a0e29396c7e1b9302cc01a4f9 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 28 Apr 2026 10:56:09 +0200 Subject: [PATCH 05/10] fix escaped brace expansion with empty alternatives --- interp/runner_expand.go | 11 +++++++++++ interp/runner_expand_test.go | 15 +++++++++++++++ .../quoting/escaped_left_brace_composite.yaml | 9 +++++++++ 3 files changed, 35 insertions(+) diff --git a/interp/runner_expand.go b/interp/runner_expand.go index 3a513cb1..2c62023a 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -330,6 +330,17 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { Value: escapedLeftBraceValue(slashCount), }) segmentStart = i + 1 + // If the escaped left brace is immediately followed by "{}", the + // following "{" can still start a bash brace expansion whose first + // alternative begins with a literal "}" (for example, `\{{},}`). + // Quote that "}" too so syntax.SplitBraces does not prematurely + // close a malformed single-element expansion before seeing the comma. + if i+2 < len(s) && s[i+1] == '{' && s[i+2] == '}' { + appendLit(s[i+1 : i+2]) + parts = append(parts, &syntax.SglQuoted{Value: "}"}) + segmentStart = i + 3 + i += 2 + } } if parts == nil { diff --git a/interp/runner_expand_test.go b/interp/runner_expand_test.go index ecd64afe..9b015e94 100644 --- a/interp/runner_expand_test.go +++ b/interp/runner_expand_test.go @@ -42,6 +42,16 @@ func TestProtectEscapedLeftBraces(t *testing.T) { script: `cmd \{\{`, want: []string{"cmd", "{{"}, }, + { + name: "escaped_left_brace_before_empty_alternative", + script: `cmd \{{},}`, + want: []string{"cmd", "{}", "{"}, + }, + { + name: "escaped_left_brace_before_right_brace_alternative_with_suffix", + script: `cmd \{{}x,y}`, + want: []string{"cmd", "{}x", "{y"}, + }, { name: "escaped_left_brace_adjacent_to_quoted_part", script: `cmd \{"x"`, @@ -78,6 +88,11 @@ func TestProtectEscapedLeftBraces(t *testing.T) { script: `cmd \\\{a,b}`, want: []string{"cmd", `\{a,b}`}, }, + { + name: "odd_backslashes_before_empty_alternative", + script: `cmd \\\{{},}`, + want: []string{"cmd", `\{}`, `\{`}, + }, } for _, tt := range tests { diff --git a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml index 255a26f0..418adf67 100644 --- a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml +++ b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml @@ -3,6 +3,9 @@ input: script: |+ X=ok printf '<%s>\n' \{\{ + printf '<%s>\n' \{{},} + printf '<%s>\n' \{{}x,y} + printf '<%s>\n' \\\{{},} printf '<%s>\n' \{"x" printf '<%s>\n' \{$X printf '<%s>\n' \{1..3} @@ -10,6 +13,12 @@ input: expect: stdout: |+ <{{> + <{}> + <{> + <{}x> + <{y> + <\{}> + <\{> <{x> <{ok> <{1..3}> From 21df8d6aa486f0f6447cf92333275d51243267b7 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 28 Apr 2026 23:38:35 +0200 Subject: [PATCH 06/10] Fix escaped brace expansion alternatives Co-Authored-By: Claude Sonnet 4.6 (1M context) --- interp/runner_expand.go | 113 +++++++++++++----- interp/runner_expand_test.go | 10 ++ .../quoting/escaped_left_brace_composite.yaml | 6 + 3 files changed, 101 insertions(+), 28 deletions(-) diff --git a/interp/runner_expand.go b/interp/runner_expand.go index 2c62023a..85a65828 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -13,6 +13,7 @@ import ( "io" "io/fs" "os" + "sort" "strings" "sync" @@ -299,57 +300,113 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { return nil, false } - var parts []syntax.WordPart - segmentStart := 0 - appendLit := func(value string) { - if value == "" { - return - } - part := *lit - part.Value = value - parts = append(parts, &part) + type protectedPart struct { + start int + end int + part syntax.WordPart } + var protected []protectedPart + protectedRightBraces := make(map[int]struct{}) for i := 0; i < len(s); i++ { if s[i] != '{' { continue } slashStart := i - for slashStart > segmentStart && s[slashStart-1] == '\\' { + for slashStart > 0 && s[slashStart-1] == '\\' { slashStart-- } slashCount := i - slashStart if slashCount%2 == 0 { continue } - if parts == nil { - parts = make([]syntax.WordPart, 0, 3) - } - appendLit(s[segmentStart:slashStart]) - parts = append(parts, &syntax.SglQuoted{ - Value: escapedLeftBraceValue(slashCount), + protected = append(protected, protectedPart{ + start: slashStart, + end: i + 1, + part: &syntax.SglQuoted{ + Value: escapedLeftBraceValue(slashCount), + }, }) - segmentStart = i + 1 - // If the escaped left brace is immediately followed by "{}", the - // following "{" can still start a bash brace expansion whose first - // alternative begins with a literal "}" (for example, `\{{},}`). - // Quote that "}" too so syntax.SplitBraces does not prematurely - // close a malformed single-element expansion before seeing the comma. - if i+2 < len(s) && s[i+1] == '{' && s[i+2] == '}' { - appendLit(s[i+1 : i+2]) - parts = append(parts, &syntax.SglQuoted{Value: "}"}) - segmentStart = i + 3 - i += 2 + + // If the escaped left brace is immediately followed by a brace + // expression whose first top-level right brace appears before the first + // top-level comma, that right brace is literal text in bash (for + // example, `\{{},}` and `\{{x},}`). Quote it too so + // syntax.SplitBraces does not prematurely close a malformed + // single-element expansion before seeing the comma. + if rightBrace := rightBraceToQuoteAfterEscapedLeftBrace(s, i+1); rightBrace >= 0 { + if _, ok := protectedRightBraces[rightBrace]; !ok { + protectedRightBraces[rightBrace] = struct{}{} + protected = append(protected, protectedPart{ + start: rightBrace, + end: rightBrace + 1, + part: &syntax.SglQuoted{Value: "}"}, + }) + } } } - if parts == nil { + if len(protected) == 0 { return nil, false } + sort.Slice(protected, func(i, j int) bool { + return protected[i].start < protected[j].start + }) + + parts := make([]syntax.WordPart, 0, len(protected)*2+1) + segmentStart := 0 + appendLit := func(value string) { + if value == "" { + return + } + part := *lit + part.Value = value + parts = append(parts, &part) + } + for _, part := range protected { + appendLit(s[segmentStart:part.start]) + parts = append(parts, part.part) + segmentStart = part.end + } appendLit(s[segmentStart:]) return parts, true } +func rightBraceToQuoteAfterEscapedLeftBrace(s string, openBrace int) int { + if openBrace >= len(s) || s[openBrace] != '{' { + return -1 + } + + depth := 0 + for i := openBrace + 1; i < len(s); i++ { + switch s[i] { + case '{': + if countBackslashesBefore(s, i)%2 == 0 { + depth++ + } + case ',': + if depth == 0 { + return -1 + } + case '}': + if depth == 0 { + return i + } + depth-- + } + } + return -1 +} + +func countBackslashesBefore(s string, i int) int { + count := 0 + for i > 0 && s[i-1] == '\\' { + count++ + i-- + } + return count +} + func escapedLeftBraceValue(slashCount int) string { quotedSlashCount := slashCount / 2 if quotedSlashCount == 0 { diff --git a/interp/runner_expand_test.go b/interp/runner_expand_test.go index 9b015e94..ecaac5f3 100644 --- a/interp/runner_expand_test.go +++ b/interp/runner_expand_test.go @@ -47,6 +47,16 @@ func TestProtectEscapedLeftBraces(t *testing.T) { script: `cmd \{{},}`, want: []string{"cmd", "{}", "{"}, }, + { + name: "escaped_left_brace_before_braced_non_empty_alternative", + script: `cmd \{{x},}`, + want: []string{"cmd", "{x}", "{"}, + }, + { + name: "escaped_left_brace_before_braced_non_empty_alternative_with_suffix", + script: `cmd \{{x},z}`, + want: []string{"cmd", "{x}", "{z"}, + }, { name: "escaped_left_brace_before_right_brace_alternative_with_suffix", script: `cmd \{{}x,y}`, diff --git a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml index 418adf67..70e63d6e 100644 --- a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml +++ b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml @@ -5,6 +5,8 @@ input: printf '<%s>\n' \{\{ printf '<%s>\n' \{{},} printf '<%s>\n' \{{}x,y} + printf '<%s>\n' \{{x},} + printf '<%s>\n' \{{x},z} printf '<%s>\n' \\\{{},} printf '<%s>\n' \{"x" printf '<%s>\n' \{$X @@ -17,6 +19,10 @@ expect: <{> <{}x> <{y> + <{x}> + <{> + <{x}> + <{z> <\{}> <\{> <{x> From bac4e353d254f1794408646c08c040b82eb4f5b3 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 28 Apr 2026 23:55:14 +0200 Subject: [PATCH 07/10] Handle escaped brace expansions across word parts Co-Authored-By: Claude Sonnet 4.6 (1M context) --- interp/runner_expand.go | 114 ++++++++++++------ interp/runner_expand_test.go | 11 ++ .../quoting/escaped_left_brace_composite.yaml | 6 + 3 files changed, 93 insertions(+), 38 deletions(-) diff --git a/interp/runner_expand.go b/interp/runner_expand.go index 85a65828..322ccbad 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -268,6 +268,7 @@ func protectEscapedLeftBracesWord(word *syntax.Word) *syntax.Word { if word == nil { return nil } + rightBraceQuotes := rightBraceQuotesAfterEscapedLeftBraces(word.Parts) var parts []syntax.WordPart for i, part := range word.Parts { lit, ok := part.(*syntax.Lit) @@ -277,7 +278,7 @@ func protectEscapedLeftBracesWord(word *syntax.Word) *syntax.Word { } continue } - litParts, changed := splitEscapedLeftBracesLit(lit) + litParts, changed := splitEscapedLeftBracesLit(lit, rightBraceQuotes[i]) if changed && parts == nil { parts = make([]syntax.WordPart, 0, len(word.Parts)+len(litParts)-1) parts = append(parts, word.Parts[:i]...) @@ -294,9 +295,43 @@ func protectEscapedLeftBracesWord(word *syntax.Word) *syntax.Word { return &protected } -func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { +func rightBraceQuotesAfterEscapedLeftBraces(parts []syntax.WordPart) map[int]map[int]struct{} { + var quotes map[int]map[int]struct{} + for partIndex, part := range parts { + lit, ok := part.(*syntax.Lit) + if !ok || strings.Index(lit.Value, "\\{") < 0 { + continue + } + for i := 0; i < len(lit.Value); i++ { + if lit.Value[i] != '{' { + continue + } + slashStart := i + for slashStart > 0 && lit.Value[slashStart-1] == '\\' { + slashStart-- + } + if (i-slashStart)%2 == 0 { + continue + } + quotePart, quoteOffset, ok := rightBraceToQuoteAfterEscapedLeftBrace(parts, partIndex, i+1) + if !ok { + continue + } + if quotes == nil { + quotes = make(map[int]map[int]struct{}) + } + if quotes[quotePart] == nil { + quotes[quotePart] = make(map[int]struct{}) + } + quotes[quotePart][quoteOffset] = struct{}{} + } + } + return quotes +} + +func splitEscapedLeftBracesLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{}) ([]syntax.WordPart, bool) { s := lit.Value - if strings.Index(s, "\\{") < 0 { + if strings.Index(s, "\\{") < 0 && len(rightBraceQuotes) == 0 { return nil, false } @@ -307,7 +342,6 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { } var protected []protectedPart - protectedRightBraces := make(map[int]struct{}) for i := 0; i < len(s); i++ { if s[i] != '{' { continue @@ -327,23 +361,16 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { Value: escapedLeftBraceValue(slashCount), }, }) - - // If the escaped left brace is immediately followed by a brace - // expression whose first top-level right brace appears before the first - // top-level comma, that right brace is literal text in bash (for - // example, `\{{},}` and `\{{x},}`). Quote it too so - // syntax.SplitBraces does not prematurely close a malformed - // single-element expansion before seeing the comma. - if rightBrace := rightBraceToQuoteAfterEscapedLeftBrace(s, i+1); rightBrace >= 0 { - if _, ok := protectedRightBraces[rightBrace]; !ok { - protectedRightBraces[rightBrace] = struct{}{} - protected = append(protected, protectedPart{ - start: rightBrace, - end: rightBrace + 1, - part: &syntax.SglQuoted{Value: "}"}, - }) - } + } + for rightBrace := range rightBraceQuotes { + if rightBrace < 0 || rightBrace >= len(s) || s[rightBrace] != '}' { + continue } + protected = append(protected, protectedPart{ + start: rightBrace, + end: rightBrace + 1, + part: &syntax.SglQuoted{Value: "}"}, + }) } if len(protected) == 0 { @@ -372,30 +399,41 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit) ([]syntax.WordPart, bool) { return parts, true } -func rightBraceToQuoteAfterEscapedLeftBrace(s string, openBrace int) int { - if openBrace >= len(s) || s[openBrace] != '{' { - return -1 +func rightBraceToQuoteAfterEscapedLeftBrace(parts []syntax.WordPart, openPart int, openOffset int) (int, int, bool) { + openLit, ok := parts[openPart].(*syntax.Lit) + if !ok || openOffset >= len(openLit.Value) || openLit.Value[openOffset] != '{' { + return 0, 0, false } depth := 0 - for i := openBrace + 1; i < len(s); i++ { - switch s[i] { - case '{': - if countBackslashesBefore(s, i)%2 == 0 { - depth++ - } - case ',': - if depth == 0 { - return -1 - } - case '}': - if depth == 0 { - return i + for partIndex := openPart; partIndex < len(parts); partIndex++ { + lit, ok := parts[partIndex].(*syntax.Lit) + if !ok { + continue + } + start := 0 + if partIndex == openPart { + start = openOffset + 1 + } + for i := start; i < len(lit.Value); i++ { + switch lit.Value[i] { + case '{': + if countBackslashesBefore(lit.Value, i)%2 == 0 { + depth++ + } + case ',': + if depth == 0 { + return 0, 0, false + } + case '}': + if depth == 0 { + return partIndex, i, true + } + depth-- } - depth-- } } - return -1 + return 0, 0, false } func countBackslashesBefore(s string, i int) int { diff --git a/interp/runner_expand_test.go b/interp/runner_expand_test.go index ecaac5f3..4c747636 100644 --- a/interp/runner_expand_test.go +++ b/interp/runner_expand_test.go @@ -57,6 +57,17 @@ func TestProtectEscapedLeftBraces(t *testing.T) { script: `cmd \{{x},z}`, want: []string{"cmd", "{x}", "{z"}, }, + { + name: "escaped_left_brace_before_braced_quoted_alternative", + script: `cmd \{{"x"},}`, + want: []string{"cmd", "{x}", "{"}, + }, + { + name: "escaped_left_brace_before_braced_parameter_alternative_with_suffix", + script: `cmd \{{$X},z}`, + env: []string{"X=x"}, + want: []string{"cmd", "{x}", "{z"}, + }, { name: "escaped_left_brace_before_right_brace_alternative_with_suffix", script: `cmd \{{}x,y}`, diff --git a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml index 70e63d6e..a25a3799 100644 --- a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml +++ b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml @@ -7,6 +7,8 @@ input: printf '<%s>\n' \{{}x,y} printf '<%s>\n' \{{x},} printf '<%s>\n' \{{x},z} + printf '<%s>\n' \{{"x"},} + printf '<%s>\n' \{{$X},z} printf '<%s>\n' \\\{{},} printf '<%s>\n' \{"x" printf '<%s>\n' \{$X @@ -23,6 +25,10 @@ expect: <{> <{x}> <{z> + <{x}> + <{> + <{ok}> + <{z> <\{}> <\{> <{x> From 69e7be761b3bd815829ae9eaa733505ce6f06fce Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 28 Apr 2026 23:58:20 +0200 Subject: [PATCH 08/10] Avoid sort dependency in brace expansion Co-Authored-By: Claude Sonnet 4.6 (1M context) --- interp/runner_expand.go | 68 +++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/interp/runner_expand.go b/interp/runner_expand.go index 322ccbad..ed3cedf7 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -13,7 +13,6 @@ import ( "io" "io/fs" "os" - "sort" "strings" "sync" @@ -335,66 +334,49 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{ return nil, false } - type protectedPart struct { - start int - end int - part syntax.WordPart + var parts []syntax.WordPart + segmentStart := 0 + appendLit := func(value string) { + if value == "" { + return + } + part := *lit + part.Value = value + parts = append(parts, &part) + } + appendProtected := func(start int, end int, part syntax.WordPart) { + if parts == nil { + parts = make([]syntax.WordPart, 0, 3) + } + appendLit(s[segmentStart:start]) + parts = append(parts, part) + segmentStart = end } - var protected []protectedPart for i := 0; i < len(s); i++ { + if _, ok := rightBraceQuotes[i]; ok && s[i] == '}' { + appendProtected(i, i+1, &syntax.SglQuoted{Value: "}"}) + continue + } if s[i] != '{' { continue } slashStart := i - for slashStart > 0 && s[slashStart-1] == '\\' { + for slashStart > segmentStart && s[slashStart-1] == '\\' { slashStart-- } slashCount := i - slashStart if slashCount%2 == 0 { continue } - protected = append(protected, protectedPart{ - start: slashStart, - end: i + 1, - part: &syntax.SglQuoted{ - Value: escapedLeftBraceValue(slashCount), - }, - }) - } - for rightBrace := range rightBraceQuotes { - if rightBrace < 0 || rightBrace >= len(s) || s[rightBrace] != '}' { - continue - } - protected = append(protected, protectedPart{ - start: rightBrace, - end: rightBrace + 1, - part: &syntax.SglQuoted{Value: "}"}, + appendProtected(slashStart, i+1, &syntax.SglQuoted{ + Value: escapedLeftBraceValue(slashCount), }) } - if len(protected) == 0 { + if parts == nil { return nil, false } - sort.Slice(protected, func(i, j int) bool { - return protected[i].start < protected[j].start - }) - - parts := make([]syntax.WordPart, 0, len(protected)*2+1) - segmentStart := 0 - appendLit := func(value string) { - if value == "" { - return - } - part := *lit - part.Value = value - parts = append(parts, &part) - } - for _, part := range protected { - appendLit(s[segmentStart:part.start]) - parts = append(parts, part.part) - segmentStart = part.end - } appendLit(s[segmentStart:]) return parts, true } From 124a5576b1f40ab630509635f67c35e9c7d17d6c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 29 Apr 2026 00:10:11 +0200 Subject: [PATCH 09/10] [iter 1] Fix escaped brace meta expansion Preserve bash quote state for escaped brace metacharacters before mvdan SplitBraces runs, including commas, right braces, and sequence dots. Add regression coverage for escaped delimiters and sequence cases. --- interp/runner_expand.go | 50 ++++++++++++------- interp/runner_expand_test.go | 45 +++++++++++++++++ .../quoting/escaped_left_brace_composite.yaml | 18 +++++++ 3 files changed, 96 insertions(+), 17 deletions(-) diff --git a/interp/runner_expand.go b/interp/runner_expand.go index ed3cedf7..b09e6224 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -236,15 +236,15 @@ func (r *Runner) fields(words ...*syntax.Word) []string { return strs } -// protectEscapedLeftBraces preserves bash's handling of backslash-quoted left -// braces before delegating to mvdan.cc/sh's field expansion. +// protectEscapedLeftBraces preserves bash's handling of backslash-quoted brace +// metacharacters before delegating to mvdan.cc/sh's field expansion. // -// Brace expansion happens before quote removal, but backslashes still quote the -// next byte for the purpose of deciding whether a "{" starts a brace -// expansion. syntax.SplitBraces does not track that quote state for literal -// parts, so an input like `\{` can be treated as an unmatched brace and keep the -// backslash. Rewriting odd-backslash-escaped left braces as quoted word parts -// prevents brace expansion from seeing them while producing the same final text. +// expand.Fields calls syntax.SplitBraces before quote removal. SplitBraces scans +// literal word parts for "{", ",", "..", and "}" without tracking whether a +// backslash quoted the byte, while bash uses that quote state when deciding +// whether a byte is brace syntax. Rewriting odd-backslash-escaped brace +// metacharacters as quoted word parts prevents brace expansion from seeing them +// while producing the same final text after quote removal. func protectEscapedLeftBraces(words []*syntax.Word) []*syntax.Word { var out []*syntax.Word for i, word := range words { @@ -277,7 +277,7 @@ func protectEscapedLeftBracesWord(word *syntax.Word) *syntax.Word { } continue } - litParts, changed := splitEscapedLeftBracesLit(lit, rightBraceQuotes[i]) + litParts, changed := splitEscapedBraceMetasLit(lit, rightBraceQuotes[i]) if changed && parts == nil { parts = make([]syntax.WordPart, 0, len(word.Parts)+len(litParts)-1) parts = append(parts, word.Parts[:i]...) @@ -328,9 +328,9 @@ func rightBraceQuotesAfterEscapedLeftBraces(parts []syntax.WordPart) map[int]map return quotes } -func splitEscapedLeftBracesLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{}) ([]syntax.WordPart, bool) { +func splitEscapedBraceMetasLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{}) ([]syntax.WordPart, bool) { s := lit.Value - if strings.Index(s, "\\{") < 0 && len(rightBraceQuotes) == 0 { + if !strings.Contains(s, "\\") && len(rightBraceQuotes) == 0 { return nil, false } @@ -358,7 +358,7 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{ appendProtected(i, i+1, &syntax.SglQuoted{Value: "}"}) continue } - if s[i] != '{' { + if !isBraceMetaByte(s[i]) { continue } slashStart := i @@ -370,7 +370,7 @@ func splitEscapedLeftBracesLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{ continue } appendProtected(slashStart, i+1, &syntax.SglQuoted{ - Value: escapedLeftBraceValue(slashCount), + Value: escapedBraceMetaValue(slashCount, s[i]), }) } @@ -404,10 +404,17 @@ func rightBraceToQuoteAfterEscapedLeftBrace(parts []syntax.WordPart, openPart in depth++ } case ',': - if depth == 0 { + if countBackslashesBefore(lit.Value, i)%2 == 0 && depth == 0 { + return 0, 0, false + } + case '.': + if countBackslashesBefore(lit.Value, i)%2 == 0 && depth == 0 && i+1 < len(lit.Value) && lit.Value[i+1] == '.' { return 0, 0, false } case '}': + if countBackslashesBefore(lit.Value, i)%2 != 0 { + continue + } if depth == 0 { return partIndex, i, true } @@ -427,16 +434,25 @@ func countBackslashesBefore(s string, i int) int { return count } -func escapedLeftBraceValue(slashCount int) string { +func isBraceMetaByte(b byte) bool { + switch b { + case '{', ',', '.', '}': + return true + default: + return false + } +} + +func escapedBraceMetaValue(slashCount int, meta byte) string { quotedSlashCount := slashCount / 2 if quotedSlashCount == 0 { - return "{" + return string(meta) } var b strings.Builder for i := 0; i < quotedSlashCount; i++ { b.WriteByte('\\') } - b.WriteByte('{') + b.WriteByte(meta) return b.String() } diff --git a/interp/runner_expand_test.go b/interp/runner_expand_test.go index 4c747636..b4de77a6 100644 --- a/interp/runner_expand_test.go +++ b/interp/runner_expand_test.go @@ -73,6 +73,51 @@ func TestProtectEscapedLeftBraces(t *testing.T) { script: `cmd \{{}x,y}`, want: []string{"cmd", "{}x", "{y"}, }, + { + name: "escaped_comma_in_braced_alternative", + script: `cmd \{{a\,b},}`, + want: []string{"cmd", "{a,b}", "{"}, + }, + { + name: "escaped_right_brace_in_single_braced_word", + script: `cmd \{{a\}}`, + want: []string{"cmd", "{{a}}"}, + }, + { + name: "escaped_right_brace_in_braced_alternative", + script: `cmd \{{a\},}`, + want: []string{"cmd", "{a}", "{"}, + }, + { + name: "escaped_right_brace_in_braced_alternative_with_suffix", + script: `cmd \{{a\},z}`, + want: []string{"cmd", "{a}", "{z"}, + }, + { + name: "sequence_after_escaped_left_brace_still_expands", + script: `cmd \{{1..3},}`, + want: []string{"cmd", "{1,}", "{2,}", "{3,}"}, + }, + { + name: "escaped_dot_disables_sequence_after_escaped_left_brace", + script: `cmd \{{1\..3},}`, + want: []string{"cmd", "{1..3}", "{"}, + }, + { + name: "escaped_comma_in_regular_brace_expansion", + script: `cmd {a\,b,c}`, + want: []string{"cmd", "a,b", "c"}, + }, + { + name: "escaped_right_brace_in_regular_brace_expansion", + script: `cmd {a\}b,c}`, + want: []string{"cmd", "a}b", "c"}, + }, + { + name: "escaped_dot_in_regular_brace_expansion", + script: `cmd {1\..3,foo}`, + want: []string{"cmd", "1..3", "foo"}, + }, { name: "escaped_left_brace_adjacent_to_quoted_part", script: `cmd \{"x"`, diff --git a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml index a25a3799..ed0ea3d8 100644 --- a/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml +++ b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml @@ -9,6 +9,12 @@ input: printf '<%s>\n' \{{x},z} printf '<%s>\n' \{{"x"},} printf '<%s>\n' \{{$X},z} + printf '<%s>\n' \{{a\,b},} + printf '<%s>\n' \{{a\}} + printf '<%s>\n' \{{a\},} + printf '<%s>\n' \{{a\},z} + printf '<%s>\n' \{{1..3},} + printf '<%s>\n' \{{1\..3},} printf '<%s>\n' \\\{{},} printf '<%s>\n' \{"x" printf '<%s>\n' \{$X @@ -29,6 +35,18 @@ expect: <{> <{ok}> <{z> + <{a,b}> + <{> + <{{a}}> + <{a}> + <{> + <{a}> + <{z> + <{1,}> + <{2,}> + <{3,}> + <{1..3}> + <{> <\{}> <\{> <{x> From ae68608f5526795286993299e5fb9bb6e3b23132 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 29 Apr 2026 00:14:27 +0200 Subject: [PATCH 10/10] [iter 2] Fix interp symbol allowlist failure Use strings.Index, which is already allowed for interp, instead of introducing strings.Contains in the escaped brace preprocessor. --- interp/runner_expand.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interp/runner_expand.go b/interp/runner_expand.go index b09e6224..b39c16b9 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -330,7 +330,7 @@ func rightBraceQuotesAfterEscapedLeftBraces(parts []syntax.WordPart) map[int]map func splitEscapedBraceMetasLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{}) ([]syntax.WordPart, bool) { s := lit.Value - if !strings.Contains(s, "\\") && len(rightBraceQuotes) == 0 { + if strings.Index(s, "\\") < 0 && len(rightBraceQuotes) == 0 { return nil, false }