Skip to content

Commit 30b6d1f

Browse files
committed
cmd: Enhance .env (dotenv) file parsing
Basic support for quoted values, newlines in quoted values, and comments. Does not support variable or command expansion.
1 parent bc15b4b commit 30b6d1f

File tree

2 files changed

+212
-16
lines changed

2 files changed

+212
-16
lines changed

cmd/main.go

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -368,42 +368,68 @@ func loadEnvFromFile(envFile string) error {
368368
return nil
369369
}
370370

371+
// parseEnvFile parses an env file from KEY=VALUE format.
372+
// It's pretty naive. Limited value quotation is supported,
373+
// but variable and command expansions are not supported.
371374
func parseEnvFile(envInput io.Reader) (map[string]string, error) {
372375
envMap := make(map[string]string)
373376

374377
scanner := bufio.NewScanner(envInput)
375-
var line string
376-
lineNumber := 0
378+
var lineNumber int
377379

378380
for scanner.Scan() {
379-
line = strings.TrimSpace(scanner.Text())
381+
line := strings.TrimSpace(scanner.Text())
380382
lineNumber++
381383

382-
// skip lines starting with comment
383-
if strings.HasPrefix(line, "#") {
384-
continue
385-
}
386-
387-
// skip empty line
388-
if len(line) == 0 {
384+
// skip empty lines and lines starting with comment
385+
if line == "" || strings.HasPrefix(line, "#") {
389386
continue
390387
}
391388

389+
// split line into key and value
392390
fields := strings.SplitN(line, "=", 2)
393391
if len(fields) != 2 {
394392
return nil, fmt.Errorf("can't parse line %d; line should be in KEY=VALUE format", lineNumber)
395393
}
394+
key, val := fields[0], fields[1]
396395

397-
if strings.Contains(fields[0], " ") {
398-
return nil, fmt.Errorf("bad key on line %d: contains whitespace", lineNumber)
399-
}
400-
401-
key := fields[0]
402-
val := fields[1]
396+
// sometimes keys are prefixed by "export " so file can be sourced in bash; ignore it here
397+
key = strings.TrimPrefix(key, "export ")
403398

399+
// validate key and value
404400
if key == "" {
405401
return nil, fmt.Errorf("missing or empty key on line %d", lineNumber)
406402
}
403+
if strings.Contains(key, " ") {
404+
return nil, fmt.Errorf("invalid key on line %d: contains whitespace: %s", lineNumber, key)
405+
}
406+
if strings.HasPrefix(val, " ") || strings.HasPrefix(val, "\t") {
407+
return nil, fmt.Errorf("invalid value on line %d: whitespace before value: '%s'", lineNumber, val)
408+
}
409+
410+
// remove any trailing comment after value
411+
if commentStart := strings.Index(val, "#"); commentStart > 0 {
412+
before := val[commentStart-1]
413+
if before == '\t' || before == ' ' {
414+
val = strings.TrimRight(val[:commentStart], " \t")
415+
}
416+
}
417+
418+
// quoted value: support newlines
419+
if strings.HasPrefix(val, `"`) {
420+
for !(strings.HasSuffix(line, `"`) && !strings.HasSuffix(line, `\"`)) {
421+
val = strings.ReplaceAll(val, `\"`, `"`)
422+
if !scanner.Scan() {
423+
break
424+
}
425+
lineNumber++
426+
line = strings.ReplaceAll(scanner.Text(), `\"`, `"`)
427+
val += "\n" + line
428+
}
429+
val = strings.TrimPrefix(val, `"`)
430+
val = strings.TrimSuffix(val, `"`)
431+
}
432+
407433
envMap[key] = val
408434
}
409435

cmd/main_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package caddycmd
2+
3+
import (
4+
"reflect"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestParseEnvFile(t *testing.T) {
10+
for i, tc := range []struct {
11+
input string
12+
expect map[string]string
13+
shouldErr bool
14+
}{
15+
{
16+
input: `KEY=value`,
17+
expect: map[string]string{
18+
"KEY": "value",
19+
},
20+
},
21+
{
22+
input: `
23+
KEY=value
24+
OTHER_KEY=Some Value
25+
`,
26+
expect: map[string]string{
27+
"KEY": "value",
28+
"OTHER_KEY": "Some Value",
29+
},
30+
},
31+
{
32+
input: `
33+
KEY=value
34+
INVALID KEY=asdf
35+
OTHER_KEY=Some Value
36+
`,
37+
shouldErr: true,
38+
},
39+
{
40+
input: `
41+
KEY=value
42+
SIMPLE_QUOTED="quoted value"
43+
OTHER_KEY=Some Value
44+
`,
45+
expect: map[string]string{
46+
"KEY": "value",
47+
"SIMPLE_QUOTED": "quoted value",
48+
"OTHER_KEY": "Some Value",
49+
},
50+
},
51+
{
52+
input: `
53+
KEY=value
54+
NEWLINES="foo
55+
bar"
56+
OTHER_KEY=Some Value
57+
`,
58+
expect: map[string]string{
59+
"KEY": "value",
60+
"NEWLINES": "foo\n\tbar",
61+
"OTHER_KEY": "Some Value",
62+
},
63+
},
64+
{
65+
input: `
66+
KEY=value
67+
ESCAPED="\"escaped quotes\"
68+
here"
69+
OTHER_KEY=Some Value
70+
`,
71+
expect: map[string]string{
72+
"KEY": "value",
73+
"ESCAPED": "\"escaped quotes\"\nhere",
74+
"OTHER_KEY": "Some Value",
75+
},
76+
},
77+
{
78+
input: `
79+
export KEY=value
80+
OTHER_KEY=Some Value
81+
`,
82+
expect: map[string]string{
83+
"KEY": "value",
84+
"OTHER_KEY": "Some Value",
85+
},
86+
},
87+
{
88+
input: `
89+
=value
90+
OTHER_KEY=Some Value
91+
`,
92+
shouldErr: true,
93+
},
94+
{
95+
input: `
96+
EMPTY=
97+
OTHER_KEY=Some Value
98+
`,
99+
expect: map[string]string{
100+
"EMPTY": "",
101+
"OTHER_KEY": "Some Value",
102+
},
103+
},
104+
{
105+
input: `
106+
EMPTY=""
107+
OTHER_KEY=Some Value
108+
`,
109+
expect: map[string]string{
110+
"EMPTY": "",
111+
"OTHER_KEY": "Some Value",
112+
},
113+
},
114+
{
115+
input: `
116+
KEY=value
117+
#OTHER_KEY=Some Value
118+
`,
119+
expect: map[string]string{
120+
"KEY": "value",
121+
},
122+
},
123+
{
124+
input: `
125+
KEY=value
126+
COMMENT=foo bar # some comment here
127+
OTHER_KEY=Some Value
128+
`,
129+
expect: map[string]string{
130+
"KEY": "value",
131+
"COMMENT": "foo bar",
132+
"OTHER_KEY": "Some Value",
133+
},
134+
},
135+
{
136+
input: `
137+
KEY=value
138+
WHITESPACE= foo
139+
OTHER_KEY=Some Value
140+
`,
141+
shouldErr: true,
142+
},
143+
{
144+
input: `
145+
KEY=value
146+
WHITESPACE=" foo bar "
147+
OTHER_KEY=Some Value
148+
`,
149+
expect: map[string]string{
150+
"KEY": "value",
151+
"WHITESPACE": " foo bar ",
152+
"OTHER_KEY": "Some Value",
153+
},
154+
},
155+
} {
156+
actual, err := parseEnvFile(strings.NewReader(tc.input))
157+
if err != nil && !tc.shouldErr {
158+
t.Errorf("Test %d: Got error but shouldn't have: %v", i, err)
159+
}
160+
if err == nil && tc.shouldErr {
161+
t.Errorf("Test %d: Did not get error but should have", i)
162+
}
163+
if tc.shouldErr {
164+
continue
165+
}
166+
if !reflect.DeepEqual(tc.expect, actual) {
167+
t.Errorf("Test %d: Expected %v but got %v", i, tc.expect, actual)
168+
}
169+
}
170+
}

0 commit comments

Comments
 (0)