Skip to content

Commit cf9ee3c

Browse files
authored
Add an option for unexpanded sources (#97)
Some sources of configuration (e.g., secrets) shouldn't have environment variable expansion enabled. Internally, we require support for this feature in multiple places. This PR adds support for so-called "raw" sources, which disable variable expansion. This is (admittedly) a huge hack. I'm open to alternatives.
1 parent bd43fcd commit cf9ee3c

File tree

5 files changed

+128
-12
lines changed

5 files changed

+128
-12
lines changed

config.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,25 @@ func NewYAML(options ...YAMLOption) (*YAML, error) {
7070
return nil, fmt.Errorf("error applying options: %v", cfg.err)
7171
}
7272

73+
// Some sources shouldn't have environment variables expanded; protect those
74+
// sources by escaping the contents. (Expanding before merging re-exposes a
75+
// number of bugs, so we can't just selectively expand sources before
76+
// merging.)
77+
sourceBytes := make([][]byte, len(cfg.sources))
78+
for i := range cfg.sources {
79+
s := cfg.sources[i]
80+
if !s.raw {
81+
sourceBytes[i] = s.bytes
82+
continue
83+
}
84+
sourceBytes[i] = escapeVariables(s.bytes)
85+
}
86+
7387
// On construction, go through a full merge-serialize-deserialize cycle to
7488
// catch any duplicated keys as early as possible (in strict mode). It also
7589
// strips comments, which stops us from attempting environment variable
7690
// expansion. (We'll expand environment variables next.)
77-
merged, err := merge.YAML(cfg.sources, cfg.strict)
91+
merged, err := merge.YAML(sourceBytes, cfg.strict)
7892
if err != nil {
7993
return nil, fmt.Errorf("couldn't merge YAML sources: %v", err)
8094
}
@@ -89,7 +103,7 @@ func NewYAML(options ...YAMLOption) (*YAML, error) {
89103

90104
y := &YAML{
91105
name: cfg.name,
92-
raw: cfg.sources,
106+
raw: sourceBytes,
93107
strict: cfg.strict,
94108
}
95109

config_ext_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,3 +763,31 @@ func TestNullSources(t *testing.T) {
763763
})
764764
})
765765
}
766+
767+
func TestRawSources(t *testing.T) {
768+
env := map[string]string{
769+
"ZONE": "west1",
770+
}
771+
lookup := func(k string) (string, bool) {
772+
v, ok := env[k]
773+
return v, ok
774+
}
775+
776+
base := `zone: $ZONE`
777+
secrets := `secret: abc$ZONE`
778+
779+
cfg := struct {
780+
Zone string
781+
Secret string
782+
}{}
783+
784+
provider, err := NewYAML(
785+
Source(strings.NewReader(base)),
786+
RawSource(strings.NewReader(secrets)),
787+
Expand(lookup),
788+
)
789+
require.NoError(t, err, "failed to create provider")
790+
assert.NoError(t, provider.Get(Root).Populate(&cfg), "failed to populate config struct")
791+
assert.Equal(t, "west1", cfg.Zone, "unexpected zone")
792+
assert.Equal(t, "abc$ZONE", cfg.Secret, "unexpected secret")
793+
}

escape.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package config
2+
3+
import "bytes"
4+
5+
var (
6+
_dollar = []byte("$")
7+
_doubleDollar = []byte("$$")
8+
)
9+
10+
// To avoid a variety of other bugs (e.g,
11+
// https://github.com/uber-go/config/issues/80), we defer environment variable
12+
// expansion until we've already merged all sources. However, merging blends
13+
// data from sources that should have variables expanded and those that
14+
// shouldn't (e.g., secrets). To protect variable-like strings in sources that
15+
// shouldn't be expanded, we must escape them.
16+
func escapeVariables(bs []byte) []byte {
17+
return bytes.Replace(bs, _dollar, _doubleDollar, -1)
18+
}

escape_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package config
2+
3+
import (
4+
"bytes"
5+
"io/ioutil"
6+
"testing"
7+
"testing/quick"
8+
9+
"github.com/stretchr/testify/require"
10+
"golang.org/x/text/transform"
11+
)
12+
13+
func TestEscapeVariablesQuick(t *testing.T) {
14+
transformer := newExpandTransformer(func(k string) (string, bool) {
15+
return "expanded", true
16+
})
17+
// Round-tripping any string through escaping, then expansion should return
18+
// the original unchanged.
19+
round := func(s string) bool {
20+
escaped := escapeVariables([]byte(s))
21+
r := transform.NewReader(bytes.NewReader(escaped), transformer)
22+
unescaped, err := ioutil.ReadAll(r)
23+
require.NoError(t, err, "failed to read expanded config")
24+
return s == string(unescaped)
25+
}
26+
if err := quick.Check(round, nil); err != nil {
27+
t.Error(err)
28+
}
29+
}

option.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ type optionFunc func(*config)
3939

4040
func (f optionFunc) apply(c *config) { f(c) }
4141

42-
// Expand enables variable expansion in all provided sources. The supplied
43-
// function MUST behave like os.LookupEnv: it looks up a key and returns a
44-
// value and whether the key was found.
42+
// Expand enables variable expansion in all non-raw provided sources. The
43+
// supplied function MUST behave like os.LookupEnv: it looks up a key and
44+
// returns a value and whether the key was found. Any expansion is deferred
45+
// until after all sources are merged, so it's not possible to reference
46+
// different variables in different sources and have the values automatically
47+
// merged.
4548
//
4649
// Expand allows variable references to take two forms: $VAR or
4750
// ${VAR:default}. In the first form, variable names MUST adhere to shell
@@ -90,19 +93,38 @@ func Name(name string) YAMLOption {
9093

9194
// Source adds a source of YAML configuration. Later sources override earlier
9295
// ones using the merge logic described in the package-level documentation.
96+
//
97+
// Sources are subject to variable expansion (via the Expand option). To
98+
// provide a source that remains unexpanded, use the RawSource option.
9399
func Source(r io.Reader) YAMLOption {
94100
all, err := ioutil.ReadAll(r)
95101
if err != nil {
96102
return failed(err)
97103
}
98104
return optionFunc(func(c *config) {
99-
c.sources = append(c.sources, all)
105+
c.sources = append(c.sources, source{bytes: all})
106+
})
107+
}
108+
109+
// RawSource adds a source of YAML configuration. Later sources override
110+
// earlier ones using the merge logic described in the package-level
111+
// documentation.
112+
//
113+
// Raw sources are not subject to variable expansion. To provide a source with
114+
// variable expansion enabled, use the Source option.
115+
func RawSource(r io.Reader) YAMLOption {
116+
all, err := ioutil.ReadAll(r)
117+
if err != nil {
118+
return failed(err)
119+
}
120+
return optionFunc(func(c *config) {
121+
c.sources = append(c.sources, source{bytes: all, raw: true})
100122
})
101123
}
102124

103125
// File opens a file, uses it as a source of YAML configuration, and closes it
104-
// once provider construction is complete. Priority and merge logic are
105-
// identical to Source.
126+
// once provider construction is complete. Priority, merge, and expansion
127+
// logic are identical to Source.
106128
func File(name string) YAMLOption {
107129
f, err := os.Open(name)
108130
if err != nil {
@@ -117,20 +139,20 @@ func File(name string) YAMLOption {
117139
return failed(err)
118140
}
119141
return optionFunc(func(c *config) {
120-
c.sources = append(c.sources, all)
142+
c.sources = append(c.sources, source{bytes: all})
121143
})
122144
}
123145

124146
// Static serializes a Go data structure to YAML and uses the result as a
125147
// source. If serialization fails, provider construction will return an error.
126-
// Priority and merge logic are identical to Source.
148+
// Priority, merge, and expansion logic are identical to Source.
127149
func Static(val interface{}) YAMLOption {
128150
bs, err := yaml.Marshal(val)
129151
if err != nil {
130152
return failed(err)
131153
}
132154
return optionFunc(func(c *config) {
133-
c.sources = append(c.sources, bs)
155+
c.sources = append(c.sources, source{bytes: bs})
134156
})
135157
}
136158

@@ -140,10 +162,15 @@ func failed(err error) YAMLOption {
140162
})
141163
}
142164

165+
type source struct {
166+
bytes []byte
167+
raw bool
168+
}
169+
143170
type config struct {
144171
name string
145172
strict bool
146-
sources [][]byte
173+
sources []source
147174
lookup LookupFunc
148175
err error
149176
}

0 commit comments

Comments
 (0)