Skip to content

fix: stop expand from recursing forever on a cyclic reference - #447

Open
abo3losh1 wants to merge 2 commits into
caarlos0:mainfrom
abo3losh1:fix/expand-cycle
Open

abo3losh1 wants to merge 2 commits into
caarlos0:mainfrom
abo3losh1:fix/expand-cycle

Conversation

@abo3losh1

Copy link
Copy Markdown

With expand, a variable that refers back to itself crashes the program:

var cfg struct {
	A string `env:"A,expand"`
}
env.ParseWithOptions(&cfg, env.Options{Environment: map[string]string{
	"A": "x-${B}",
	"B": "$B",
}})
runtime: goroutine stack exceeds 1000000000-byte limit
fatal error: stack overflow

SELF=a-${SELF} and A=${B} / B=${A} do the same. getRawEnv calls os.Expand with itself as the mapping function, so a cycle never ends. A stack overflow is a fatal error, not a panic, so callers can't recover from it, and a single bad value in the environment takes the whole process down.

The fix keeps the recursive expansion (TestParseExpandWithDefaultOption depends on it) but tracks which variables are being expanded. A reference back to one of them expands to an empty string, the same as an undefined variable. The field's own key starts in that set, which is what catches SELF=a-${SELF}. A variable used twice without a cycle still works: ${ONE}${ONE} gives 11.

Added TestParseExpandCyclicReference (self reference, a two-variable loop, and the repeated variable case). Before the fix it dies with the stack overflow. go test ./... and go vet ./... pass, and golangci-lint reports nothing new.

With the expand option, a variable that refers to itself (SELF=a-${SELF})
or two variables that refer to each other made getRawEnv call os.Expand
until the stack overflowed. That is a fatal error, so it cannot be
recovered and the whole program dies. Track the variables being expanded
and expand a reference back to one of them to an empty string, the same
as an undefined variable.
Copilot AI balanced review requested due to automatic review settings October 6, 2026 22:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (5057644) to head (d80eca7).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #447   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            3         3           
  Lines          427       432    +5     
=========================================
+ Hits           427       432    +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@caarlos0 caarlos0 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Automated review by GitHub Copilot. Changes requested for one confirmed compatibility regression. The reproduction passes on the base revision and fails on this head. Only focused expansion tests were run; the build was not independently verified.

Comment thread env.go

if fieldParams.Expand {
val = os.Expand(val, opts.getRawEnv)
val = opts.expand(val, map[string]bool{fieldParams.Key: true})

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

P2: Preserve valid references to earlier cached values. Marking the current key as visited before reading the cache rejects references that are not cyclic. For example:

type config struct {
    RawPort string `env:"PORT" envDefault:"3000"`
    Port    int    `env:"PORT,expand" envDefault:"${PORT}"`
}

var cfg config
err := ParseWithOptions(&cfg, Options{
    Environment: map[string]string{},
})

The first field caches "3000" under PORT. The second field should read that value, but the cycle guard returns an empty string before checking the cache. Parsing returns no error and leaves Port at 0. I confirmed that the same test passes on the base with Port == 3000 and fails on this head with Port == 0.

Do not mark the current key as cyclic when its reference resolves to a nonempty value cached by an earlier field. Keep the recursive cycle guard. Add this case as a regression test with an explicitly empty environment map, asserting no error and both field values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants