diff --git a/env.go b/env.go index d957aa67..61b96342 100644 --- a/env.go +++ b/env.go @@ -176,12 +176,24 @@ type Options struct { rawEnvVars map[string]string } -func (opts *Options) getRawEnv(s string) string { - val := opts.rawEnvVars[s] - if val == "" { - val = opts.Environment[s] - } - return os.Expand(val, opts.getRawEnv) +// expand replaces ${var} or $var in val, expanding the values it refers to +// as well. visiting holds the variables being expanded, so a reference back +// to one of them (e.g. A=${A}) expands to an empty string instead of +// recursing until the stack overflows. +func (opts *Options) expand(val string, visiting map[string]bool) string { + return os.Expand(val, func(s string) string { + if visiting[s] { + return "" + } + visiting[s] = true + defer delete(visiting, s) + + raw := opts.rawEnvVars[s] + if raw == "" { + raw = opts.Environment[s] + } + return opts.expand(raw, visiting) + }) } func defaultOptions() Options { @@ -601,7 +613,13 @@ func get(fieldParams FieldParams, opts Options) (val string, err error) { ) if fieldParams.Expand { - val = os.Expand(val, opts.getRawEnv) + visiting := map[string]bool{} + // A reference to this field's own key is a cycle, unless an earlier + // field with the same key already cached a value to read. + if opts.rawEnvVars[fieldParams.Key] == "" { + visiting[fieldParams.Key] = true + } + val = opts.expand(val, visiting) } opts.rawEnvVars[fieldParams.Key] = val diff --git a/env_test.go b/env_test.go index 2fe79c31..14588972 100644 --- a/env_test.go +++ b/env_test.go @@ -1051,6 +1051,43 @@ func TestParseExpandWithDefaultOption(t *testing.T) { isEqual(t, "3000:5000", cfg.NoDefault) } +func TestParseExpandCyclicReference(t *testing.T) { + type config struct { + Self string `env:"SELF,expand"` + Indirect string `env:"INDIRECT,expand"` + Twice string `env:"TWICE,expand"` + } + + t.Setenv("SELF", "a-${SELF}") + t.Setenv("INDIRECT", "b-${LOOP_A}") + t.Setenv("LOOP_A", "${LOOP_B}") + t.Setenv("LOOP_B", "${LOOP_A}") + t.Setenv("TWICE", "${ONE}${ONE}") + t.Setenv("ONE", "1") + + cfg := config{} + err := Parse(&cfg) + + isNoErr(t, err) + isEqual(t, "a-", cfg.Self) + isEqual(t, "b-", cfg.Indirect) + isEqual(t, "11", cfg.Twice) +} + +func TestParseExpandReadsEarlierFieldWithSameKey(t *testing.T) { + 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{}}) + + isNoErr(t, err) + isEqual(t, "3000", cfg.RawPort) + isEqual(t, 3000, cfg.Port) +} + func TestParseUnsetRequireOptions(t *testing.T) { type config struct { Password string `env:"PASSWORD,unset,required"`