fix: support escaped commas in --state-values-set (#2813)

* feat: support comma escape for values overwrites

Signed-off-by: zhengbayi <zhengbaiyi@sensetime.com>

* fix: harden state-values-set parsing and support \, in state-values-set-string

- Return a descriptive error instead of panicking when a --state-values-set
  assignment is missing '=', has an empty key, or ends with a trailing comma
- Support escaped commas in --state-values-set-string and preserve other
  backslashes (including trailing ones), aligning it with --state-values-set
  and Helm's --set/--set-string; quoted values keep working
- Allow empty values in --state-values-set-string for parity with
  --state-values-set
- Restructure parsing into guard-clause helpers with single-level
  conditionals; parse assignments via strings.Cut
- Extend table-driven tests: error cases, --state-values-set-string
  coverage, combined flags, and unit tests for the split/unescape helpers

Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: zhengbayi <zhengbaiyi@sensetime.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: zhengbayi <zhengbaiyi@sensetime.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
This commit is contained in:
zebreay
2026-10-03 15:31:04 +08:00
committed by GitHub
co-authored by zhengbayi yxxhero
parent 226c16b88e
commit 000a6fa660
4 changed files with 394 additions and 31 deletions
+126 -29
View File
@@ -1,44 +1,141 @@
package config
import (
"fmt"
"regexp"
"strings"
"github.com/helmfile/helmfile/pkg/maputil"
)
// stateValuesSetStringRE matches a single key=value assignment within one
// --state-values-set-string flag value. The value is either a quoted string,
// which may contain commas, or a run of plain or backslash-escaped characters
// ending at the next unescaped comma.
var stateValuesSetStringRE = regexp.MustCompile(`(?s)(?:,|^)([^\s=]+)=(['"][^'"]*['"]|(?:\\.|[^,\\])*\\?)`)
// NewCLIConfigImpl parses the raw --state-values-set-string and
// --state-values-set flag values into state value overrides on g.
func NewCLIConfigImpl(g *GlobalImpl) error {
re := regexp.MustCompile(`(?:,|^)([^\s=]+)=(['"][^'"]*['"]|[^,]+)`)
if err := parseStateValuesSetString(g); err != nil {
return err
}
return parseStateValuesSet(g)
}
// parseStateValuesSetString applies the --state-values-set-string flag values
// to g. Unlike --state-values-set, values are kept as strings without type
// conversion.
func parseStateValuesSetString(g *GlobalImpl) error {
optsSet := g.RawStateValuesSetString()
if len(optsSet) > 0 {
set := map[string]any{}
for i := range optsSet {
ops := re.FindAllStringSubmatch(optsSet[i], -1)
for j := range ops {
op := ops[j]
k := maputil.ParseKey(op[1])
v := op[2]
maputil.Set(set, k, v, true)
}
}
g.SetSet(set)
}
optsSet = g.RawStateValuesSet()
if len(optsSet) > 0 {
set := map[string]any{}
for i := range optsSet {
ops := strings.Split(optsSet[i], ",")
for j := range ops {
op := strings.SplitN(ops[j], "=", 2)
k := maputil.ParseKey(op[0])
v := op[1]
maputil.Set(set, k, v, false)
}
}
g.SetSet(set)
if len(optsSet) == 0 {
return nil
}
set := map[string]any{}
for _, raw := range optsSet {
for _, match := range stateValuesSetStringRE.FindAllStringSubmatch(raw, -1) {
key, value := match[1], match[2]
maputil.Set(set, maputil.ParseKey(key), unescapeCommas(value), true)
}
}
g.SetSet(set)
return nil
}
// parseStateValuesSet applies the --state-values-set flag values to g. Values
// are type-converted like Helm's --set.
func parseStateValuesSet(g *GlobalImpl) error {
optsSet := g.RawStateValuesSet()
if len(optsSet) == 0 {
return nil
}
set := map[string]any{}
for _, raw := range optsSet {
for _, assignment := range splitOnUnescapedCommas(raw) {
if err := setAssignment(set, assignment); err != nil {
return err
}
}
}
g.SetSet(set)
return nil
}
// setAssignment parses a single key=value assignment into set, returning an
// error instead of panicking when the assignment is malformed.
func setAssignment(set map[string]any, assignment string) error {
key, value, found := strings.Cut(unescapeCommas(assignment), "=")
if !found || key == "" {
return fmt.Errorf("--state-values-set: invalid assignment %q: expected <key>=<value>", assignment)
}
maputil.Set(set, maputil.ParseKey(key), value, false)
return nil
}
// splitOnUnescapedCommas splits the input on commas that are not escaped by a
// preceding backslash, keeping every backslash intact so that escapes remain
// available to key and value parsing.
func splitOnUnescapedCommas(input string) []string {
segments := make([]string, 0, 1+strings.Count(input, ","))
var current strings.Builder
escaped := false
for i := range len(input) {
char := input[i]
switch {
case escaped:
current.WriteByte('\\')
current.WriteByte(char)
escaped = false
case char == '\\':
escaped = true
case char == ',':
segments = append(segments, current.String())
current.Reset()
default:
current.WriteByte(char)
}
}
if escaped {
current.WriteByte('\\')
}
return append(segments, current.String())
}
// unescapeCommas turns every backslash-escaped comma into a literal comma,
// preserving all other backslashes, including a trailing one.
func unescapeCommas(input string) string {
if !strings.Contains(input, `\,`) {
return input
}
var out strings.Builder
out.Grow(len(input))
escaped := false
for i := range len(input) {
char := input[i]
switch {
case escaped && char == ',':
out.WriteByte(',')
escaped = false
case escaped:
out.WriteByte('\\')
out.WriteByte(char)
escaped = false
case char == '\\':
escaped = true
default:
out.WriteByte(char)
}
}
if escaped {
out.WriteByte('\\')
}
return out.String()
}
+242
View File
@@ -0,0 +1,242 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCLIConfigImplStateValuesSet(t *testing.T) {
tests := []struct {
name string
set []string
want map[string]any
wantErr string
}{
{
name: "comma separated assignments retain their types",
set: []string{"count=2,enabled=true,disabled=false,empty=null,zero=0,code=001"},
want: map[string]any{
"count": int64(2), "enabled": true, "disabled": false,
"empty": nil, "zero": int64(0), "code": "001",
},
},
{
name: "escaped commas within multiple values",
set: []string{`a=1,b=2\,3,c=9\,10\,11`},
want: map[string]any{"a": int64(1), "b": "2,3", "c": "9,10,11"},
},
{
name: "escaped commas at the start and end of a value",
set: []string{`message=\,hello\,`},
want: map[string]any{"message": ",hello,"},
},
{
name: "repeated flags overwrite earlier values",
set: []string{`message=first\,second`, `message=third\,fourth,count=2`},
want: map[string]any{"message": "third,fourth", "count": int64(2)},
},
{
name: "equals signs remain within values",
set: []string{`message=first\,second=value,count=2`},
want: map[string]any{"message": "first,second=value", "count": int64(2)},
},
{
name: "unicode values",
set: []string{`message=你好\,世界`},
want: map[string]any{"message": "你好,世界"},
},
{
name: "empty value",
set: []string{"message=,count=2"},
want: map[string]any{"message": "", "count": int64(2)},
},
{
name: "backslashes before other characters are preserved",
set: []string{`path=C:\tmp\config`},
want: map[string]any{"path": `C:\tmp\config`},
},
{
name: "trailing backslash is preserved",
set: []string{`path=C:\tmp\`},
want: map[string]any{"path": `C:\tmp\`},
},
{
name: "escaped dots in keys are preserved for key parsing",
set: []string{`annotations.example\.com/key=first\,second`},
want: map[string]any{
"annotations": map[string]any{"example.com/key": "first,second"},
},
},
{
name: "nested array keys",
set: []string{`servers[0].host=east\,west,enabled=true`},
want: map[string]any{
"servers": []any{map[string]any{"host": "east,west"}},
"enabled": true,
},
},
{
name: "even backslashes leave comma as an assignment separator",
set: []string{`path=C:\\,count=2`},
want: map[string]any{"path": `C:\\`, "count": int64(2)},
},
{
name: "odd backslashes escape the comma",
set: []string{`message=first\\\,second`},
want: map[string]any{"message": `first\\,second`},
},
{
name: "assignment without equals sign is rejected",
set: []string{"message"},
wantErr: `invalid assignment "message": expected <key>=<value>`,
},
{
name: "trailing comma is rejected",
set: []string{"message=hello,"},
wantErr: `invalid assignment "": expected <key>=<value>`,
},
{
name: "empty key is rejected",
set: []string{"=hello"},
wantErr: `invalid assignment "=hello": expected <key>=<value>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewGlobalImpl(&GlobalOptions{StateValuesSet: tt.set})
err := NewCLIConfigImpl(g)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, g.StateValuesSet())
})
}
}
func TestNewCLIConfigImplStateValuesSetString(t *testing.T) {
tests := []struct {
name string
set []string
want map[string]any
wantErr string
}{
{
name: "values are kept as strings without type conversion",
set: []string{"count=2,enabled=true"},
want: map[string]any{"count": "2", "enabled": "true"},
},
{
name: "quoted values may contain commas",
set: []string{`zone="zone1,zone2",imageTag=1.23.3`},
want: map[string]any{"zone": `"zone1,zone2"`, "imageTag": "1.23.3"},
},
{
name: "escaped commas within values",
set: []string{`zone=zone1\,zone2,imageTag=1.23.3`},
want: map[string]any{"zone": "zone1,zone2", "imageTag": "1.23.3"},
},
{
name: "backslashes and trailing backslash are preserved",
set: []string{`path=C:\tmp\config,trailing=C:\tmp\`},
want: map[string]any{"path": `C:\tmp\config`, "trailing": `C:\tmp\`},
},
{
name: "even backslashes leave comma as an assignment separator",
set: []string{`path=C:\\,count=2`},
want: map[string]any{"path": `C:\\`, "count": "2"},
},
{
name: "empty value",
set: []string{"message=,count=2"},
want: map[string]any{"message": "", "count": "2"},
},
{
name: "repeated flags overwrite earlier values",
set: []string{`zone=a\,b`, `zone=c\,d`},
want: map[string]any{"zone": "c,d"},
},
{
name: "escaped dots in keys are preserved for key parsing",
set: []string{`annotations.example\.com/key=first\,second`},
want: map[string]any{
"annotations": map[string]any{"example.com/key": "first,second"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewGlobalImpl(&GlobalOptions{StateValuesSetString: tt.set})
err := NewCLIConfigImpl(g)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, g.StateValuesSet())
})
}
}
func TestNewCLIConfigImplBothSetFlags(t *testing.T) {
g := NewGlobalImpl(&GlobalOptions{
StateValuesSet: []string{"count=2"},
StateValuesSetString: []string{"imageTag=1.23.3"},
})
require.NoError(t, NewCLIConfigImpl(g))
want := map[string]any{
"count": int64(2),
"imageTag": "1.23.3",
}
assert.Equal(t, want, g.StateValuesSet())
}
func TestSplitOnUnescapedCommas(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "no commas", input: "a=1", want: []string{"a=1"}},
{name: "plain separators", input: "a=1,b=2", want: []string{"a=1", "b=2"}},
{name: "escaped separator", input: `a=1\,2,b=3`, want: []string{`a=1\,2`, "b=3"}},
{name: "escaped separator only", input: `a=1\,2`, want: []string{`a=1\,2`}},
{name: "even backslashes unescape the separator", input: `a=1\\,b=2`, want: []string{`a=1\\`, "b=2"}},
{name: "trailing separator", input: "a=1,", want: []string{"a=1", ""}},
{name: "trailing backslash", input: `a=1\`, want: []string{`a=1\`}},
{name: "unicode", input: `a=你好\,世界,b=2`, want: []string{`a=你好\,世界`, "b=2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, splitOnUnescapedCommas(tt.input))
})
}
}
func TestUnescapeCommas(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "no escapes", input: "a=1,b=2", want: "a=1,b=2"},
{name: "escaped comma", input: `a=1\,2`, want: "a=1,2"},
{name: "escaped dot preserved", input: `a\.b=1`, want: `a\.b=1`},
{name: "even backslashes preserved", input: `a=1\\`, want: `a=1\\`},
{name: "trailing backslash preserved", input: `a=1\`, want: `a=1\`},
{name: "unicode preserved", input: `a=你好\,世界`, want: "a=你好,世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, unescapeCommas(tt.input))
})
}
}