Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.idea
*~
bin/
dist/
cover.out
vals
.vscode
57 changes: 41 additions & 16 deletions pkg/expansion/expand_match.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@ package expansion
import (
"fmt"
"regexp"
"slices"
"strings"
)

type ExpandRegexMatch struct {
Target *regexp.Regexp
Lookup func(string) (string, error)
Lookup func(string) (interface{}, error)
Only []string
}

var DefaultRefRegexp = regexp.MustCompile(`((secret)?ref)\+([^\+:]*:\/\/[^\+\n ]+[^\+\n ",])\+?`)

func (e *ExpandRegexMatch) shouldExpand(kind string) bool {
return len(e.Only) == 0 || slices.Contains(e.Only, kind)
}

func (e *ExpandRegexMatch) InString(s string) (string, error) {
var sb strings.Builder
for {
Expand All @@ -22,40 +27,60 @@ func (e *ExpandRegexMatch) InString(s string) (string, error) {
sb.WriteString(s)
return sb.String(), nil
}

kind := s[ixs[2]:ixs[3]]
if len(e.Only) > 0 {
var shouldExpand bool
for _, k := range e.Only {
if k == kind {
shouldExpand = true
break
}
}
if !shouldExpand {
sb.WriteString(s)
return sb.String(), nil
}
if !e.shouldExpand(kind) {
sb.WriteString(s)
// FIXME: this skips the rest of the string, is this intended?
return sb.String(), nil
Comment on lines +33 to +35
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the code should just skip to the end of the current match.
Something like this:

Suggested change
sb.WriteString(s)
// FIXME: this skips the rest of the string, is this intended?
return sb.String(), nil
sb.WriteString(s[:ixs[1]]) // Write as is from the beginning of the string to the end of the current match
s = s[ixs[1]:]
continue

Comment on lines +33 to +35
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This FIXME comment highlights a potential bug in the existing logic. When a reference kind is not in the expansion list, the function returns immediately, skipping any subsequent references in the same string. This means if you have multiple references in a string and the first one should be skipped, the remaining ones won't be processed either. Consider whether this behavior is intended or if the function should continue processing the rest of the string after skipping non-matching references.

Suggested change
sb.WriteString(s)
// FIXME: this skips the rest of the string, is this intended?
return sb.String(), nil
// Keep this reference as-is and continue processing the rest of the string.
sb.WriteString(s[:ixs[1]])
s = s[ixs[1]:]
continue

Copilot uses AI. Check for mistakes.
}

ref := s[ixs[6]:ixs[7]]
val, err := e.Lookup(ref)
if err != nil {
return "", fmt.Errorf("expand %s: %v", ref, err)
}
sb.WriteString(s[:ixs[0]])
sb.WriteString(val)
fmt.Fprintf(&sb, "%v", val)
s = s[ixs[1]:]
}
}

// InValue expands matches in the given string value.
// If the entire string matches the regex, it expands and preserves the type.
// If only part of the string matches, it expands as a string.
func (e *ExpandRegexMatch) InValue(s string) (interface{}, error) {
ixs := e.Target.FindStringSubmatchIndex(s)
switch {
// No match, return as is
case ixs == nil:
return s, nil
// Full match, expand preserving type
case ixs[0] == 0 && ixs[1] == len(s):
kind := s[ixs[2]:ixs[3]]
ref := s[ixs[6]:ixs[7]]
if !e.shouldExpand(kind) {
return s, nil
}
val, err := e.Lookup(ref)
if err != nil {
return nil, fmt.Errorf("expand %s: %v", ref, err)
}
return val, nil
// Partial match, expand as string
default:
return e.InString(s)
}
}
Comment on lines +49 to +74
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new InValue function that enables type preservation for boolean and integer values lacks test coverage. Consider adding tests that verify type preservation for full matches and string conversion for partial matches, including edge cases like booleans, integers, and mixed scenarios.

Copilot uses AI. Check for mistakes.

func (e *ExpandRegexMatch) InMap(target map[string]interface{}) (map[string]interface{}, error) {
ret, err := ModifyStringValues(target, func(p string) (interface{}, error) {
ret, err := e.InString(p)
ret, err := e.InValue(p)
if err != nil {
return nil, err
}
return ret, nil
})

if err != nil {
return nil, err
}
Expand Down
6 changes: 2 additions & 4 deletions pkg/expansion/expand_match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func TestExpandRegexpMatchInString(t *testing.T) {
tc := testcases[i]

t.Run(tc.name, func(t *testing.T) {
lookup := func(m string) (string, error) {
lookup := func(m string) (interface{}, error) {
parsed, err := url.Parse(m)
if err != nil {
return "", err
Expand All @@ -160,7 +160,6 @@ func TestExpandRegexpMatchInString(t *testing.T) {
}

actual, err := expand.InString(tc.input)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -227,7 +226,7 @@ func TestExpandRegexpMatchInMap(t *testing.T) {
tc := testcases[i]

t.Run(tc.name, func(t *testing.T) {
lookup := func(m string) (string, error) {
lookup := func(m string) (interface{}, error) {
parsed, err := url.Parse(m)
if err != nil {
return "", err
Expand All @@ -242,7 +241,6 @@ func TestExpandRegexpMatchInMap(t *testing.T) {
}

actual, err := expand.InMap(tc.input)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
62 changes: 36 additions & 26 deletions vals.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,7 @@ const (
ProviderServercore = "servercore"
)

var (
EnvFallbackPrefix = "VALS_"
)
var EnvFallbackPrefix = "VALS_"

type Evaluator interface {
Eval(map[string]interface{}) (map[string]interface{}, error)
Expand Down Expand Up @@ -344,25 +342,23 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) {
expand := expansion.ExpandRegexMatch{
Only: only,
Target: expansion.DefaultRefRegexp,
Lookup: func(key string) (string, error) {
Lookup: func(key string) (interface{}, error) {
if val, ok := r.docCache.Get(key); ok {
valStr, ok := val.(string)
if ok {
return valStr, nil
if isTerminalValue(val) {
return val, nil
}
}

uri, err := url.Parse(key)
if err != nil {
return "", err
return nil, err
}

hash := uriToProviderHash(uri)

p, err := updateProviders(uri, hash)

if err != nil {
return "", err
return nil, err
}

var frag string
Expand Down Expand Up @@ -401,12 +397,12 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) {
if cachedStr, ok := r.strCache.Get(cacheKey); ok {
str, ok = cachedStr.(string)
if !ok {
return "", fmt.Errorf("error reading str from cache: unsupported value type %T", cachedStr)
return nil, fmt.Errorf("error reading str from cache: unsupported value type %T", cachedStr)
}
} else {
str, err = p.GetString(path)
if err != nil {
return "", err
return nil, err
}
r.strCache.Add(cacheKey, str)
}
Expand All @@ -418,7 +414,7 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) {
if cachedMap, ok := r.docCache.Get(mapRequestURI); ok {
obj, ok = cachedMap.(map[string]interface{})
if !ok {
return "", fmt.Errorf("error reading map from cache: unsupported value type %T", cachedMap)
return nil, fmt.Errorf("error reading map from cache: unsupported value type %T", cachedMap)
}
} else if uri.Scheme == "httpjson" {
// Due to the unpredictability in the structure of the JSON object,
Expand All @@ -430,49 +426,61 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) {
// object, accommodating different configurations and variations.
value, err := p.GetString(key)
if err != nil {
return "", err
return nil, err
}
return value, nil
} else {
obj, err = p.GetStringMap(path)
if err != nil {
return "", err
return nil, err
}
r.docCache.Add(mapRequestURI, obj)
}

keys := strings.Split(frag, "/")
for i, k := range keys {
newobj := map[string]interface{}{}
switch t := obj[k].(type) {
case string:
t := obj[k]
if isTerminalValue(t) {
if i != len(keys)-1 {
return "", fmt.Errorf("unexpected type of value for key at %d=%s in %v: expected map[string]interface{}, got %v(%T)", i, k, keys, t, t)
return nil, fmt.Errorf("unexpected type of value for key at %d=%s in %v: expected map[string]interface{}, got %v(%T)", i, k, keys, t, t)
}
r.docCache.Add(key, t)
return t, nil
}
switch t := t.(type) {
case map[string]interface{}:
newobj = t
obj = t
case map[interface{}]interface{}:
obj := map[string]interface{}{}
for k, v := range t {
newobj[fmt.Sprintf("%v", k)] = v
obj[fmt.Sprintf("%v", k)] = v
}
default:
return nil, fmt.Errorf("unsupported type for key at %d=%s in %v: %v(%T)", i, k, keys, t, t)
}
obj = newobj
}

if r.Options.FailOnMissingKeyInMap {
return "", fmt.Errorf("no value found for key %s", frag)
return nil, fmt.Errorf("no value found for key %s", frag)
}

return "", nil
return nil, nil
}
},
}

return &expand, nil
}

func isTerminalValue(v any) bool {
switch v.(type) {
case bool, int, string:
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type check only handles int, but JSON unmarshaling (used by providers like Scaleway, and potentially returned by other providers) produces float64 for all numeric values. This means integer values from JSON-based secret sources will not be recognized as terminal values. Consider adding support for float64, int64, int32, and other numeric types that might be returned by different providers and unmarshaling libraries.

Suggested change
case bool, int, string:
case bool,
int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64,
string:

Copilot uses AI. Check for mistakes.
return true
default:
return false
}
}

// Eval replaces 'ref+<provider>://xxxxx' entries by their actual values
func (r *Runtime) Eval(template map[string]interface{}) (map[string]interface{}, error) {
expand, err := r.prepare()
Expand Down Expand Up @@ -597,8 +605,10 @@ func applyEnvWithQuote(quote bool) func(map[string]interface{}, ...Options) ([]s
}
}

var Env = applyEnvWithQuote(false)
var QuotedEnv = applyEnvWithQuote(true)
var (
Env = applyEnvWithQuote(false)
QuotedEnv = applyEnvWithQuote(true)
)

type ExecConfig struct {
Stdout io.Writer
Expand Down
Loading