diff --git a/README.md b/README.md index 608d2b3..039647b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,52 @@ > Environment variables substitution for Go. see docs [below](#docs) +## About this fork + +This is a fork of [a8m/envsubst](https://github.com/a8m/envsubst) tuned for use as +an [ArgoCD ConfigManagementPlugin](https://argo-cd.readthedocs.io/en/stable/operator-manual/config-management-plugins/) +rendering step. Two deliberate deviations from upstream: + +1. **`-prefix` flag.** Restrict substitution to variables matching a given prefix + (e.g. `-prefix ARGOCD_ENV_`). Variable references that don't match the prefix + are left as literal text. This keeps the plugin from accidentally substituting + anything that happens to look like a shell variable in rendered Kubernetes + manifests. + +2. **`$$` is preserved as literal text, not collapsed to `$`.** Upstream a8m + treats `$$` as a shell-style escape (so `$$VAR` produces literal `$VAR`). + That semantics silently mangles Kubernetes manifests whose contents quote + shell or kubelet idioms verbatim — most notably the KEDA Helm chart, which + embeds Kubernetes' own env-var-expansion docs in CRD schema descriptions + ("Double `$$` are reduced to a single `$`"). With upstream behavior, ArgoCD + shows perpetual `$$ → $` drift on synced resources. This fork emits `$$` as + literal text. + +The fork is intentionally narrow: same parser, same library API, same +restrictions (`-no-unset`, `-no-empty`, `-fail-fast`). **Shell-style parameter +expansion operators are preserved** — `${VAR:-default}`, `${VAR:=default}`, +`${VAR:+alt}`, `${VAR:?err}`, `${VAR-default}`, `${VAR=default}`, +`${VAR+alt}`, `${VAR?err}` all work as in upstream. Only the `$$` escape +behavior is dropped. If you want shell-style `$$` escapes, use upstream +[a8m/envsubst](https://github.com/a8m/envsubst) instead. + +#### Combining literal `$$` with a substitution + +After this change, `$$VAR` does **not** substitute `VAR` — both `$` characters +are consumed as literal text and `VAR` becomes plain text without a leading `$` +to mark it as a variable reference. The same applies to `$${VAR}`: the `$$` +consumes both dollars, leaving `{VAR}` as plain text rather than a substitution. + +To emit literal `$$` followed by a substituted variable, use one of: + +``` +input: $$$ARGOCD_ENV_FOO → output: $$ (triple-dollar; third $ starts the variable) +input: $$ ${ARGOCD_ENV_FOO} → output: $$ (space-separated) + +input: $$ARGOCD_ENV_FOO → output: $$ARGOCD_ENV_FOO (no substitution) +input: $${ARGOCD_ENV_FOO} → output: $${ARGOCD_ENV_FOO} (no substitution) +``` + #### Installation: ##### From binaries @@ -48,6 +94,7 @@ The flags and their restrictions are: | ------------| -------------- | ------------ | ------------ | |`-i` | input file | `string \| stdin` | `stdin` |`-o` | output file | `string \| stdout` | `stdout` +|`-prefix` | only substitute variables with this prefix; others are left as literal text (e.g. `-prefix ARGOCD_ENV_`) | `string` | `""` (substitute all) |`-no-digit` | do not replace variables starting with a digit, e.g. $1 and ${1} | `flag` | `false` |`-no-unset` | fail if a variable is not set | `flag` | `false` |`-no-empty` | fail if a variable is set but empty | `flag` | `false` @@ -86,7 +133,7 @@ func main() { |`${var:=$DEFAULT}` | If var not set or is empty, evaluate expression as $DEFAULT |`${var+$OTHER}` | If var set, evaluate expression as $OTHER, otherwise as empty string |`${var:+$OTHER}` | If var set, evaluate expression as $OTHER, otherwise as empty string -|`$$var` | Escape expressions. Result will be `$var`. +|`$$` | Preserved as literal `$$`. **Differs from upstream a8m**, which treats `$$` as a shell-style escape collapsing to `$`. See [About this fork](#about-this-fork). Most of the rows in this table were taken from [here](http://www.tldp.org/LDP/abs/html/refcards.html#AEN22728) diff --git a/cmd/envsubst/main.go b/cmd/envsubst/main.go index 8c0a003..eb880a9 100644 --- a/cmd/envsubst/main.go +++ b/cmd/envsubst/main.go @@ -14,6 +14,7 @@ import ( var ( input = flag.String("i", "", "") output = flag.String("o", "", "") + prefix = flag.String("prefix", "", "") noDigit = flag.Bool("no-digit", false, "") noUnset = flag.Bool("no-unset", false, "") noEmpty = flag.Bool("no-empty", false, "") @@ -25,6 +26,8 @@ Options: -i Specify file input, otherwise use last argument as input file. If no input file is specified, read from stdin. -o Specify file output. If none is specified, write to stdout. + -prefix Only substitute variables with this prefix (e.g., -prefix ARGOCD_ENV_). + Other variable references are left as literal text in the output. -no-digit Do not replace variables starting with a digit. e.g. $1 and ${1} -no-unset Fail if a variable is not set. -no-empty Fail if a variable is set but empty. @@ -33,7 +36,7 @@ Options: func main() { flag.Usage = func() { - fmt.Fprint(os.Stderr, fmt.Sprintf(usage)) + fmt.Fprint(os.Stderr, usage) } flag.Parse() var reader *bufio.Reader @@ -82,7 +85,22 @@ func main() { parserMode = parse.Quick } restrictions := &parse.Restrictions{*noUnset, *noEmpty, *noDigit} - result, err := (&parse.Parser{Name: "string", Env: os.Environ(), Restrict: restrictions, Mode: parserMode}).Parse(data) + + // Build the variable filter if prefix is specified + var varFilter *parse.VarFilter + if *prefix != "" { + varFilter = &parse.VarFilter{ + Prefixes: []string{*prefix}, + } + } + + result, err := (&parse.Parser{ + Name: "string", + Env: os.Environ(), + Restrict: restrictions, + Mode: parserMode, + VarFilter: varFilter, + }).Parse(data) if err != nil { errorAndExit(err) } @@ -97,7 +115,7 @@ func main() { func usageAndExit(msg string) { if msg != "" { - fmt.Fprintf(os.Stderr, msg) + fmt.Fprintf(os.Stderr, "%s", msg) fmt.Fprintf(os.Stderr, "\n\n") } flag.Usage() diff --git a/parse/lex.go b/parse/lex.go index 5cd4c46..6dbdbac 100644 --- a/parse/lex.go +++ b/parse/lex.go @@ -157,8 +157,13 @@ Loop: l.next() l.emit(itemText) case r == '$': - // ignore the previous '$'. - l.ignore() + // preserve "$$" as literal text. Upstream a8m/envsubst treats + // "$$" as a shell-style escape that collapses to "$", but that + // silently mangles inputs that contain literal "$$" (e.g. KEDA + // CRD descriptions quoting Kubernetes' env-var-expansion docs: + // "Double $$ are reduced to a single $"). Since this fork is + // scoped to ArgoCD CMP rendering with --prefix filtering, we + // preserve "$$" verbatim instead of escaping it. l.next() l.emit(itemText) case r == '{': diff --git a/parse/lex_test.go b/parse/lex_test.go index 34f4583..3d11c7f 100644 --- a/parse/lex_test.go +++ b/parse/lex_test.go @@ -110,15 +110,16 @@ var lexTests = []lexTest{ {itemVariable, 0, "world"}, {itemError, 0, "closing brace expected"}, }}, - {"escaping $$var", "hello $$HOME", []item{ + // "$$" is preserved as literal text in this fork (see lex.go). + {"literal $$var", "hello $$HOME", []item{ {itemText, 0, "hello "}, - {itemText, 7, "$"}, + {itemText, 6, "$$"}, {itemText, 8, "HOME"}, tEOF, }}, - {"escaping $${subst}", "hello $${HOME}", []item{ + {"literal $${subst}", "hello $${HOME}", []item{ {itemText, 0, "hello "}, - {itemText, 7, "$"}, + {itemText, 6, "$$"}, {itemText, 8, "{HOME}"}, tEOF, }}, diff --git a/parse/node.go b/parse/node.go index e566d70..d030d8b 100644 --- a/parse/node.go +++ b/parse/node.go @@ -2,8 +2,31 @@ package parse import ( "fmt" + "strings" ) +// VarFilter provides filtering for variable substitution by prefix. +// A nil filter allows all variables. A non-nil filter only allows +// variables whose names start with one of the specified prefixes. +type VarFilter struct { + Prefixes []string // Prefix patterns to match (e.g., "ARGOCD_ENV_") +} + +// IsAllowed checks if a variable name is allowed by this filter. +// Returns true if the variable matches any prefix pattern. +// A nil filter allows all variables. +func (f *VarFilter) IsAllowed(varName string) bool { + if f == nil { + return true + } + for _, prefix := range f.Prefixes { + if strings.HasPrefix(varName, prefix) { + return true + } + } + return false +} + type Node interface { Type() NodeType String() (string, error) @@ -39,16 +62,32 @@ func (t *TextNode) String() (string, error) { type VariableNode struct { NodeType - Ident string - Env Env - Restrict *Restrictions + Ident string + Env Env + Restrict *Restrictions + VarFilter *VarFilter // nil means all vars allowed + OriginalSrc string // Original source like "$VAR" for literal output when not allowed } -func NewVariable(ident string, env Env, restrict *Restrictions) *VariableNode { - return &VariableNode{NodeVariable, ident, env, restrict} +func NewVariable(ident string, env Env, restrict *Restrictions, varFilter *VarFilter) *VariableNode { + return &VariableNode{ + NodeType: NodeVariable, + Ident: ident, + Env: env, + Restrict: restrict, + VarFilter: varFilter, + } } func (t *VariableNode) String() (string, error) { + // If filtering is enabled and this var is not allowed, + // return original source as literal text + if t.VarFilter != nil && !t.VarFilter.IsAllowed(t.Ident) { + if t.OriginalSrc != "" { + return t.OriginalSrc, nil + } + return "$" + t.Ident, nil + } if err := t.validateNoUnset(); err != nil { return "", err } @@ -79,12 +118,22 @@ func (t *VariableNode) validateNoEmpty(value string) error { type SubstitutionNode struct { NodeType - ExpType itemType - Variable *VariableNode - Default Node // Default could be variable or text + ExpType itemType + Variable *VariableNode + Default Node // Default could be variable or text + OriginalSrc string // Original source like "${VAR:-default}" for literal output when not allowed } func (t *SubstitutionNode) String() (string, error) { + // If filtering is enabled and this var is not allowed, + // return original source as literal text + if t.Variable.VarFilter != nil && !t.Variable.VarFilter.IsAllowed(t.Variable.Ident) { + if t.OriginalSrc != "" { + return t.OriginalSrc, nil + } + // Fallback: reconstruct basic form + return "${" + t.Variable.Ident + "}", nil + } if t.ExpType >= itemPlus && t.Default != nil { switch t.ExpType { case itemColonDash, itemColonEquals: diff --git a/parse/parse.go b/parse/parse.go index 522f5ad..d9cfca0 100644 --- a/parse/parse.go +++ b/parse/parse.go @@ -32,10 +32,11 @@ var ( // Parser type initializer type Parser struct { - Name string // name of the processing template - Env Env - Restrict *Restrictions - Mode Mode + Name string // name of the processing template + Env Env + Restrict *Restrictions + Mode Mode + VarFilter *VarFilter // nil means all vars allowed; non-nil limits substitution // parsing state; lex *lexer token [3]item // three-token lookahead @@ -105,11 +106,12 @@ Loop: case itemError: return p.errorf(t.val) case itemVariable: - varNode := NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict) + varNode := NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict, p.VarFilter) + varNode.OriginalSrc = t.val // Store original like "$VAR" p.nodes = append(p.nodes, varNode) case itemLeftDelim: if p.peek().typ == itemVariable { - n, err := p.action() + n, err := p.action(t.pos) // Pass the position of ${ if err != nil { return err } @@ -126,19 +128,35 @@ Loop: } // Parse substitution. first item is a variable. -func (p *Parser) action() (Node, error) { +// delimPos is the position of the opening ${ +func (p *Parser) action(delimPos Pos) (Node, error) { var expType itemType var defaultNode Node - varNode := NewVariable(p.next().val, p.Env, p.Restrict) -Loop: + varNode := NewVariable(p.next().val, p.Env, p.Restrict, p.VarFilter) for { switch t := p.next(); t.typ { case itemRightDelim: - break Loop + // Capture original source from ${ through } + // t.pos is the position of }, and we need to include it + endPos := int(t.pos) + len(t.val) + if endPos > len(p.lex.input) { + endPos = len(p.lex.input) + } + originalSrc := p.lex.input[delimPos:endPos] + node := &SubstitutionNode{ + NodeType: NodeSubstitution, + ExpType: expType, + Variable: varNode, + Default: defaultNode, + OriginalSrc: originalSrc, + } + return node, nil case itemError: return nil, p.errorf(t.val) case itemVariable: - defaultNode = NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict) + defVar := NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict, p.VarFilter) + defVar.OriginalSrc = t.val + defaultNode = defVar case itemText: n := NewText(t.val) Text: @@ -156,7 +174,6 @@ Loop: expType = t.typ } } - return &SubstitutionNode{NodeSubstitution, expType, varNode, defaultNode}, nil } func (p *Parser) errorf(s string) error { diff --git a/parse/parse_test.go b/parse/parse_test.go index 923c89a..ee5e43a 100644 --- a/parse/parse_test.go +++ b/parse/parse_test.go @@ -111,11 +111,26 @@ var parseTests = []parseTest{ {"$var and $OTHER empty +", "${EMPTY+$ALSO_EMPTY}", "", errEmpty}, {"$var and $OTHER empty :+", "${EMPTY:+$ALSO_EMPTY}", "", errEmpty}, - // escaping. - {"escape $$var", "FOO $$BAR BAZ", "FOO $BAR BAZ", errNone}, - {"escape $${subst}", "FOO $${BAR} BAZ", "FOO ${BAR} BAZ", errNone}, - {"escape $$$var", "$$$BAR", "$bar", errNone}, - {"escape $$${subst}", "$$${BAZ:-baz}", "$baz", errNone}, + // "$$" is preserved as literal text in this fork (see lex.go). Upstream + // a8m/envsubst would collapse "$$" to "$" as a shell-style escape; we + // don't, because it silently mangles inputs that contain literal "$$" + // (e.g. KEDA CRD descriptions, Makefile-style snippets quoted in YAML). + {"literal $$var", "FOO $$BAR BAZ", "FOO $$BAR BAZ", errNone}, + {"literal $${subst}", "FOO $${BAR} BAZ", "FOO $${BAR} BAZ", errNone}, + {"literal $$$var", "$$$BAR", "$$bar", errNone}, + {"literal $$${subst}", "$$${BAZ:-baz}", "$$baz", errNone}, + // "$$" inside a substitution operand is unaffected by this fork's lexer + // change (lexSubstitution path is unchanged). Pinning the behavior so a + // future refactor doesn't silently regress it. + {"literal $$ in default operand", "${UNSET:-pre$$post}", "pre$$post", errNone}, + {"literal $$ in := default operand", "${UNSET:=pre$$post}", "pre$$post", errNone}, + + // Combining literal $$ with a substitution — pins the documented forms in + // README.md so docs and behavior can't drift apart silently. + {"$$ then var triple-dollar", "$$$BAR", "$$bar", errNone}, + {"$$ then var space-braced", "$$ ${BAR}", "$$ bar", errNone}, + {"$$ then var no-braces does NOT substitute", "$$BAR", "$$BAR", errNone}, + {"$$ then var no-space-braced does NOT substitute", "$${BAR}", "$${BAR}", errNone}, } var negativeParseTests = []parseTest{ @@ -169,3 +184,235 @@ func doNegativeAssertTest(t *testing.T, m mode) { } } } + +// Test VarFilter prefix filtering +func TestVarFilter(t *testing.T) { + tests := []struct { + name string + filter *VarFilter + varName string + expected bool + }{ + {"nil filter allows all", nil, "ANY_VAR", true}, + {"nil filter allows ARGOCD_ENV_", nil, "ARGOCD_ENV_FOO", true}, + {"prefix match", &VarFilter{Prefixes: []string{"ARGOCD_ENV_"}}, "ARGOCD_ENV_FOO", true}, + {"prefix match nested", &VarFilter{Prefixes: []string{"ARGOCD_ENV_"}}, "ARGOCD_ENV_CLUSTER_NAME", true}, + {"prefix no match", &VarFilter{Prefixes: []string{"ARGOCD_ENV_"}}, "OTHER_VAR", false}, + {"prefix no match partial", &VarFilter{Prefixes: []string{"ARGOCD_ENV_"}}, "ARGOCD_ENV", false}, + {"multiple prefixes match first", &VarFilter{Prefixes: []string{"FOO_", "BAR_"}}, "FOO_VAR", true}, + {"multiple prefixes match second", &VarFilter{Prefixes: []string{"FOO_", "BAR_"}}, "BAR_VAR", true}, + {"multiple prefixes no match", &VarFilter{Prefixes: []string{"FOO_", "BAR_"}}, "BAZ_VAR", false}, + {"empty prefixes blocks all", &VarFilter{Prefixes: []string{}}, "ANY_VAR", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := test.filter.IsAllowed(test.varName) + if result != test.expected { + t.Errorf("VarFilter.IsAllowed(%q) = %v, expected %v", test.varName, result, test.expected) + } + }) + } +} + +// Test parsing with prefix filter +func TestParseWithPrefixFilter(t *testing.T) { + env := []string{ + "ARGOCD_ENV_FOO=foo", + "ARGOCD_ENV_BAR=bar", + "OTHER_VAR=other", + } + + filter := &VarFilter{Prefixes: []string{"ARGOCD_ENV_"}} + + tests := []struct { + name string + input string + expected string + }{ + // Basic prefix filtering + {"allowed var substituted", "$ARGOCD_ENV_FOO", "foo"}, + {"disallowed var kept literal", "$OTHER_VAR", "$OTHER_VAR"}, + {"mixed vars", "$ARGOCD_ENV_FOO $OTHER_VAR", "foo $OTHER_VAR"}, + + // Braced syntax + {"braced allowed var", "${ARGOCD_ENV_FOO}", "foo"}, + {"braced disallowed var", "${OTHER_VAR}", "${OTHER_VAR}"}, + + // Default values - KEY TEST CASE + {"unset allowed var with default", "${ARGOCD_ENV_UNSET:-fallback}", "fallback"}, + {"unset disallowed var with default", "${OTHER_UNSET:-fallback}", "${OTHER_UNSET:-fallback}"}, + {"set allowed var with default", "${ARGOCD_ENV_FOO:-fallback}", "foo"}, + + // Complex defaults + {"allowed var empty default", "${ARGOCD_ENV_BAR:-}", "bar"}, + {"allowed var text default", "${ARGOCD_ENV_MISSING:-default_value}", "default_value"}, + + // Multiple vars + {"all allowed", "$ARGOCD_ENV_FOO ${ARGOCD_ENV_BAR}", "foo bar"}, + {"all disallowed", "$OTHER_VAR ${ANOTHER}", "$OTHER_VAR ${ANOTHER}"}, + {"mixed with defaults", "${ARGOCD_ENV_UNSET:-def1} ${OTHER_UNSET:-def2}", "def1 ${OTHER_UNSET:-def2}"}, + + // Text around vars + {"text with allowed", "hello $ARGOCD_ENV_FOO world", "hello foo world"}, + {"text with disallowed", "hello $OTHER_VAR world", "hello $OTHER_VAR world"}, + + // Real world ArgoCD pattern + {"argocd health check pattern", "path: ${ARGOCD_ENV_HEALTH_PATH:-/ping}", "path: /ping"}, + {"argocd service name pattern", "name: ${ARGOCD_ENV_SERVICE_NAME:-$ARGOCD_ENV_FOO}", "name: foo"}, + + // KEDA CRD scenario: upstream chart embeds Kubernetes' own env-var + // expansion docs in CRD schema descriptions. Those strings contain + // literal "$$" that must NOT be collapsed, or ArgoCD shows perpetual + // drift between desired ($) and live ($$). See parse/lex.go. + {"keda crd description literal $$", "description: Double $$ are reduced to a single $", "description: Double $$ are reduced to a single $"}, + {"keda kubelet escape literal", "value: $$(VAR_NAME) literal", "value: $$(VAR_NAME) literal"}, + {"literal $$ alongside prefix var", "$ARGOCD_ENV_FOO sees Double $$ are reduced", "foo sees Double $$ are reduced"}, + {"literal $$ alongside default", "${ARGOCD_ENV_HEALTH_PATH:-/ping} and $$VAR", "/ping and $$VAR"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + p := &Parser{ + Name: test.name, + Env: env, + Restrict: Relaxed, + VarFilter: filter, + } + result, err := p.Parse(test.input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != test.expected { + t.Errorf("Parse(%q) = %q, expected %q", test.input, result, test.expected) + } + }) + } +} + +// Test all substitution operators with prefix filter +func TestPrefixFilterAllOperators(t *testing.T) { + env := []string{ + "ARGOCD_ENV_SET=set_value", + "ARGOCD_ENV_EMPTY=", + "OTHER_SET=other_value", + "OTHER_EMPTY=", + } + + filter := &VarFilter{Prefixes: []string{"ARGOCD_ENV_"}} + + tests := []struct { + name string + input string + expected string + }{ + // === ALLOWED PREFIX - Simple $VAR === + {"allowed simple set", "$ARGOCD_ENV_SET", "set_value"}, + {"allowed simple unset", "$ARGOCD_ENV_UNSET", ""}, + {"allowed simple empty", "$ARGOCD_ENV_EMPTY", ""}, + + // === ALLOWED PREFIX - Braced ${VAR} === + {"allowed braced set", "${ARGOCD_ENV_SET}", "set_value"}, + {"allowed braced unset", "${ARGOCD_ENV_UNSET}", ""}, + {"allowed braced empty", "${ARGOCD_ENV_EMPTY}", ""}, + + // === ALLOWED PREFIX - Default if unset or empty :- === + {"allowed :- set", "${ARGOCD_ENV_SET:-default}", "set_value"}, + {"allowed :- unset", "${ARGOCD_ENV_UNSET:-default}", "default"}, + {"allowed :- empty", "${ARGOCD_ENV_EMPTY:-default}", "default"}, + + // === ALLOWED PREFIX - Default if unset only - === + {"allowed - set", "${ARGOCD_ENV_SET-default}", "set_value"}, + {"allowed - unset", "${ARGOCD_ENV_UNSET-default}", "default"}, + {"allowed - empty", "${ARGOCD_ENV_EMPTY-default}", ""}, // empty stays empty + + // === ALLOWED PREFIX - Assign default := === + {"allowed := set", "${ARGOCD_ENV_SET:=default}", "set_value"}, + {"allowed := unset", "${ARGOCD_ENV_UNSET:=default}", "default"}, + {"allowed := empty", "${ARGOCD_ENV_EMPTY:=default}", "default"}, + + // === ALLOWED PREFIX - Alternate if set :+ === + {"allowed :+ set", "${ARGOCD_ENV_SET:+alternate}", "alternate"}, + {"allowed :+ unset", "${ARGOCD_ENV_UNSET:+alternate}", ""}, + + // === ALLOWED PREFIX - Variable as default (level 1 nesting) === + {"allowed var default same prefix", "${ARGOCD_ENV_UNSET:-$ARGOCD_ENV_SET}", "set_value"}, + {"allowed var default diff prefix", "${ARGOCD_ENV_UNSET:-$OTHER_SET}", "$OTHER_SET"}, // inner not substituted + + // === DISALLOWED PREFIX - Should stay literal === + {"disallowed simple", "$OTHER_SET", "$OTHER_SET"}, + {"disallowed braced", "${OTHER_SET}", "${OTHER_SET}"}, + {"disallowed with default", "${OTHER_UNSET:-default}", "${OTHER_UNSET:-default}"}, + {"disallowed var default", "${OTHER_UNSET:-$OTHER_SET}", "${OTHER_UNSET:-$OTHER_SET}"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + p := &Parser{ + Name: test.name, + Env: env, + Restrict: Relaxed, + VarFilter: filter, + } + result, err := p.Parse(test.input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != test.expected { + t.Errorf("Parse(%q) = %q, expected %q", test.input, result, test.expected) + } + }) + } +} + +// TestNestedDefaultsLimitation documents that deeply nested ${VAR:-${VAR2:-default}} +// is a known limitation of the parser. Level 1 nesting works, deeper levels have issues. +func TestNestedDefaultsLimitation(t *testing.T) { + env := []string{"A=a", "B=b"} + + // Level 1 nesting works + t.Run("level 1 nesting works", func(t *testing.T) { + p := &Parser{Name: "test", Env: env, Restrict: Relaxed} + result, _ := p.Parse("${UNSET:-$A}") + if result != "a" { + t.Errorf("Level 1 nesting failed: got %q, expected %q", result, "a") + } + }) + + // Level 2+ has known issues with extra closing braces + // This is a pre-existing limitation in a8m/envsubst + t.Run("level 2 nesting limitation", func(t *testing.T) { + t.Skip("Known limitation: nested ${VAR:-${VAR2:-default}} produces extra }") + p := &Parser{Name: "test", Env: env, Restrict: Relaxed} + result, _ := p.Parse("${UNSET1:-${UNSET2:-$B}}") + if result != "b" { + t.Errorf("Level 2 nesting: got %q, expected %q", result, "b") + } + }) +} + +// Test that nil filter allows all vars (backward compatibility) +func TestParseWithNilFilter(t *testing.T) { + env := []string{ + "FOO=foo", + "BAR=bar", + } + + p := &Parser{ + Name: "nil-filter", + Env: env, + Restrict: Relaxed, + VarFilter: nil, // nil filter = allow all + } + + input := "$FOO ${BAR} ${UNSET:-default}" + expected := "foo bar default" + + result, err := p.Parse(input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != expected { + t.Errorf("Parse(%q) = %q, expected %q", input, result, expected) + } +}