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= diff --git a/interp/runner_expand.go b/interp/runner_expand.go index ef46d64f..b39c16b9 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -231,11 +231,231 @@ 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 brace +// metacharacters before delegating to mvdan.cc/sh's field expansion. +// +// 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 { + 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 + } + rightBraceQuotes := rightBraceQuotesAfterEscapedLeftBraces(word.Parts) + 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 := 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]...) + } + if parts != nil { + parts = append(parts, litParts...) + } + } + if parts == nil { + return word + } + protected := *word + protected.Parts = parts + return &protected +} + +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 splitEscapedBraceMetasLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{}) ([]syntax.WordPart, bool) { + s := lit.Value + if strings.Index(s, "\\") < 0 && len(rightBraceQuotes) == 0 { + 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) + } + 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 + } + + for i := 0; i < len(s); i++ { + if _, ok := rightBraceQuotes[i]; ok && s[i] == '}' { + appendProtected(i, i+1, &syntax.SglQuoted{Value: "}"}) + continue + } + if !isBraceMetaByte(s[i]) { + continue + } + slashStart := i + for slashStart > segmentStart && s[slashStart-1] == '\\' { + slashStart-- + } + slashCount := i - slashStart + if slashCount%2 == 0 { + continue + } + appendProtected(slashStart, i+1, &syntax.SglQuoted{ + Value: escapedBraceMetaValue(slashCount, s[i]), + }) + } + + if parts == nil { + return nil, false + } + appendLit(s[segmentStart:]) + return parts, true +} + +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 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 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 + } + depth-- + } + } + } + return 0, 0, false +} + +func countBackslashesBefore(s string, i int) int { + count := 0 + for i > 0 && s[i-1] == '\\' { + count++ + i-- + } + return count +} + +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 string(meta) + } + var b strings.Builder + for i := 0; i < quotedSlashCount; i++ { + b.WriteByte('\\') + } + b.WriteByte(meta) + 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..b4de77a6 --- /dev/null +++ b/interp/runner_expand_test.go @@ -0,0 +1,182 @@ +// 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 + env []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: "multiple_escaped_left_braces_in_one_literal", + script: `cmd \{\{`, + want: []string{"cmd", "{{"}, + }, + { + name: "escaped_left_brace_before_empty_alternative", + 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_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}`, + 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"`, + 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}`, + want: []string{"cmd", `\{a,b}`}, + }, + { + name: "odd_backslashes_before_empty_alternative", + script: `cmd \\\{{},}`, + want: []string{"cmd", `\{}`, `\{`}, + }, + } + + 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) + + 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.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 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..ed0ea3d8 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/escaped_left_brace_composite.yaml @@ -0,0 +1,59 @@ +description: Escaped left braces work next to other word parts and sequence syntax. +input: + script: |+ + X=ok + printf '<%s>\n' \{\{ + printf '<%s>\n' \{{},} + 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' \{{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 + printf '<%s>\n' \{1..3} + printf '<%s>\n' \\{1..3} +expect: + stdout: |+ + <{{> + <{}> + <{> + <{}x> + <{y> + <{x}> + <{> + <{x}> + <{z> + <{x}> + <{> + <{ok}> + <{z> + <{a,b}> + <{> + <{{a}}> + <{a}> + <{> + <{a}> + <{z> + <{1,}> + <{2,}> + <{3,}> + <{1..3}> + <{> + <\{}> + <\{> + <{x> + <{ok> + <{1..3}> + <\1> + <\2> + <\3> + stderr: |+ + exit_code: 0