Repository: elastic/go-ucfg
Pull request #210 (2025-03-04 13:33:36)

Add an option to escape paths
👋 team,

A user reported an issue in https://github.com/elastic/cloud-on-k8s/issues/8499 because it is not possible to escape dots in keys.

This small PR is an attempt to implement an escape mechanism similar to what is supported by Kibana: https://github.com/elastic/kibana/pull/178841
In order to preserve the existing behaviour the escape mechanism is enabled only if a related option is explicitly set to `true`. 

If possible we would like to get it in the next ECK release, which should happen in a few days. Please let me know if you have any feedback, opinion or thoughts about adding this feature or any workarounds I might have missed.

Thanks! 🙇 

CC @elastic/serverless-applications 

barkbay: @kuisathaverat thanks for the approval 🙏  It seems I do not have the permissions to merge it, would you mind merging it please?

Patch:
diff --git a/merge.go b/merge.go
index 1b9b10a..da04d10 100644
--- a/merge.go
+++ b/merge.go
@@ -514,7 +514,7 @@ func normalizeString(ctx context, opts *options, str string) (value, Error) {
 		return newString(ctx, opts.meta, str), nil
 	}
 
-	varexp, err := parseSplice(str, opts.pathSep, opts.maxIdx, opts.enableNumKeys)
+	varexp, err := parseSplice(str, opts.pathSep, opts.maxIdx, opts.enableNumKeys, opts.escapePath)
 	if err != nil {
 		return nil, raiseParseSplice(ctx, opts.meta, err)
 	}
diff --git a/opts.go b/opts.go
index b79481f..400d25d 100644
--- a/opts.go
+++ b/opts.go
@@ -37,6 +37,7 @@ type options struct {
 	tag          string
 	validatorTag string
 	pathSep      string
+	escapePath   bool
 	meta         *Meta
 	env          []*Config
 	resolvers    []func(name string) (string, parse.Config, error)
@@ -107,6 +108,13 @@ func PathSep(sep string) Option {
 	}
 }
 
+// EscapePath when set allows the user to escape the path using brackets.
+func EscapePath() Option {
+	return func(o *options) {
+		o.escapePath = true
+	}
+}
+
 // MetaData option passes additional metadata (currently only source of the
 // configuration) to be stored internally (e.g. for error reporting).
 func MetaData(meta Meta) Option {
diff --git a/path.go b/path.go
index a89bb47..b876d16 100644
--- a/path.go
+++ b/path.go
@@ -19,6 +19,7 @@ package ucfg
 
 import (
 	"fmt"
+	"regexp"
 	"strconv"
 	"strings"
 )
@@ -59,8 +60,14 @@ func parsePathIdx(in string, idx int, opts *options) cfgPath {
 	return p
 }
 
-func parsePath(in, sep string, maxIdx int64, enableNumKeys bool) cfgPath {
-	if sep == "" {
+const (
+	escapePathRegExp = `^\[.*\]$`
+)
+
+var escapePathReg = regexp.MustCompile(escapePathRegExp)
+
+func parsePath(in, sep string, maxIdx int64, enableNumKeys, allowEscapePath bool) cfgPath {
+	if sep == "" || (allowEscapePath && escapePathReg.MatchString(in)) {
 		return cfgPath{
 			sep:    sep,
 			fields: []field{parseField(in, maxIdx, enableNumKeys)},
@@ -81,7 +88,7 @@ func parsePath(in, sep string, maxIdx int64, enableNumKeys bool) cfgPath {
 }
 
 func parsePathWithOpts(in string, opts *options) cfgPath {
-	return parsePath(in, opts.pathSep, opts.maxIdx, opts.enableNumKeys)
+	return parsePath(in, opts.pathSep, opts.maxIdx, opts.enableNumKeys, opts.escapePath)
 }
 
 func parseField(in string, maxIdx int64, enableNumKeys bool) field {
diff --git a/variables.go b/variables.go
index b559d6e..af3fc28 100644
--- a/variables.go
+++ b/variables.go
@@ -241,7 +241,7 @@ func (e *expansionDefault) eval(cfg *Config, opts *options) (string, error) {
 	if err != nil || path == "" {
 		return e.right.eval(cfg, opts)
 	}
-	ref := newReference(parsePath(path, e.pathSep, opts.maxIdx, opts.enableNumKeys))
+	ref := newReference(parsePath(path, e.pathSep, opts.maxIdx, opts.enableNumKeys, opts.escapePath))
 	v, err := ref.eval(cfg, opts)
 	if err != nil || v == "" {
 		return e.right.eval(cfg, opts)
@@ -255,7 +255,7 @@ func (e *expansionAlt) eval(cfg *Config, opts *options) (string, error) {
 		return "", nil
 	}
 
-	ref := newReference(parsePath(path, e.pathSep, opts.maxIdx, opts.enableNumKeys))
+	ref := newReference(parsePath(path, e.pathSep, opts.maxIdx, opts.enableNumKeys, opts.escapePath))
 	tmp, err := ref.resolve(cfg, opts)
 	if err != nil || tmp == nil {
 		return "", nil
@@ -267,7 +267,7 @@ func (e *expansionAlt) eval(cfg *Config, opts *options) (string, error) {
 func (e *expansionErr) eval(cfg *Config, opts *options) (string, error) {
 	path, err := e.left.eval(cfg, opts)
 	if err == nil && path != "" {
-		ref := newReference(parsePath(path, e.pathSep, opts.maxIdx, opts.enableNumKeys))
+		ref := newReference(parsePath(path, e.pathSep, opts.maxIdx, opts.enableNumKeys, opts.escapePath))
 		str, err := ref.eval(cfg, opts)
 		if err == nil && str != "" {
 			return str, nil
@@ -281,7 +281,7 @@ func (e *expansionErr) eval(cfg *Config, opts *options) (string, error) {
 	return "", errors.New(errStr)
 }
 
-func (st parseState) finalize(pathSep string, maxIdx int64, enableNumKeys bool) (varEvaler, error) {
+func (st parseState) finalize(pathSep string, maxIdx int64, enableNumKeys, allowEscapePath bool) (varEvaler, error) {
 	if !st.isvar {
 		return nil, errors.New("fatal: processing non-variable state")
 	}
@@ -298,7 +298,7 @@ func (st parseState) finalize(pathSep string, maxIdx int64, enableNumKeys bool)
 
 		if len(pieces) == 1 {
 			if str, ok := pieces[0].(constExp); ok {
-				return newReference(parsePath(string(str), pathSep, maxIdx, enableNumKeys)), nil
+				return newReference(parsePath(string(str), pathSep, maxIdx, enableNumKeys, allowEscapePath)), nil
 			}
 		}
 
@@ -334,7 +334,7 @@ func makeOpExpansion(l, r varEvaler, op, pathSep string) varEvaler {
 	panic(fmt.Sprintf("Unknown operator: %v", op))
 }
 
-func parseSplice(in, pathSep string, maxIdx int64, enableNumKeys bool) (varEvaler, error) {
+func parseSplice(in, pathSep string, maxIdx int64, enableNumKeys, allowEscapePath bool) (varEvaler, error) {
 	lex, errs := lexer(in)
 	drainLex := func() {
 		for range lex {
@@ -344,7 +344,7 @@ func parseSplice(in, pathSep string, maxIdx int64, enableNumKeys bool) (varEvale
 	// drain lexer on return so go-routine won't leak
 	defer drainLex()
 
-	pieces, perr := parseVarExp(lex, pathSep, maxIdx, enableNumKeys)
+	pieces, perr := parseVarExp(lex, pathSep, maxIdx, enableNumKeys, allowEscapePath)
 	if perr != nil {
 		return nil, perr
 	}
@@ -448,7 +448,7 @@ func lexer(in string) (<-chan token, <-chan error) {
 	return lex, errors
 }
 
-func parseVarExp(lex <-chan token, pathSep string, maxIdx int64, enableNumKeys bool) (varEvaler, error) {
+func parseVarExp(lex <-chan token, pathSep string, maxIdx int64, enableNumKeys, allowEscapePath bool) (varEvaler, error) {
 	stack := []parseState{{st: stLeft}}
 
 	// parser loop
@@ -458,7 +458,7 @@ func parseVarExp(lex <-chan token, pathSep string, maxIdx int64, enableNumKeys b
 			stack = append(stack, parseState{st: stLeft, isvar: true})
 		case tokClose:
 			// finalize and pop state
-			piece, err := stack[len(stack)-1].finalize(pathSep, maxIdx, enableNumKeys)
+			piece, err := stack[len(stack)-1].finalize(pathSep, maxIdx, enableNumKeys, allowEscapePath)
 			stack = stack[:len(stack)-1]
 			if err != nil {
 				return nil, err


Test patch:
diff --git a/path_test.go b/path_test.go
new file mode 100644
index 0000000..e111664
--- /dev/null
+++ b/path_test.go
@@ -0,0 +1,69 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ucfg
+
+import (
+	"reflect"
+	"testing"
+)
+
+func Test_parsePathWithOpts(t *testing.T) {
+	type args struct {
+		in   string
+		opts *options
+	}
+	tests := []struct {
+		name string
+		args args
+		want cfgPath
+	}{
+		{
+			name: "happy path",
+			args: args{
+				in:   "a.b",
+				opts: &options{pathSep: "."},
+			},
+			want: cfgPath{
+				fields: []field{
+					namedField{"a"},
+					namedField{"b"},
+				},
+				sep: ".",
+			},
+		},
+		{
+			name: "escape path",
+			args: args{
+				in:   "[a.b]",
+				opts: &options{pathSep: ".", escapePath: true},
+			},
+			want: cfgPath{
+				fields: []field{
+					namedField{"[a.b]"}},
+				sep: ".",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := parsePathWithOpts(tt.args.in, tt.args.opts); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("parsePathWithOpts() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/variables_test.go b/variables_test.go
index 6f54004..a9985bf 100644
--- a/variables_test.go
+++ b/variables_test.go
@@ -26,7 +26,7 @@ import (
 
 func TestVarExpParserSuccess(t *testing.T) {
 	str := func(s string) varEvaler { return constExp(s) }
-	ref := func(s string) *reference { return newReference(parsePath(s, ".", defaultMaxIdx, false)) }
+	ref := func(s string) *reference { return newReference(parsePath(s, ".", defaultMaxIdx, false, false)) }
 	cat := func(e ...varEvaler) *splice { return &splice{e} }
 	nested := func(n ...varEvaler) varEvaler {
 		return &expansionSingle{&splice{n}, "."}
@@ -73,7 +73,7 @@ func TestVarExpParserSuccess(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(fmt.Sprintf("%s %s", test.title, test.exp), func(t *testing.T) {
-			actual, err := parseSplice(test.exp, ".", defaultMaxIdx, false)
+			actual, err := parseSplice(test.exp, ".", defaultMaxIdx, false, false)
 			if assert.NoError(t, err) {
 				assert.Equal(t, test.expected, actual)
 			}
@@ -89,7 +89,7 @@ func TestVarExpParseErrors(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(fmt.Sprintf("test %v: %v", test.title, test.exp), func(t *testing.T) {
-			res, err := parseSplice(test.exp, ".", defaultMaxIdx, false)
+			res, err := parseSplice(test.exp, ".", defaultMaxIdx, false, false)
 			assert.True(t, err != nil)
 			assert.Error(t, err, fmt.Sprintf("result: %v, error: %v", res, err))
 		})