Skip to content

Commit 3fc4292

Browse files
authored
Fix bug where internal unquoted whitespace truncates values (#205)
* Add tests to cover the regression reported in #204 * Add a comment on regex for clarity * Remove some old code that wasn't doing anything * Push _all_ parse code into the parser and get tests calling live code * Add some newline specific tests * Add some YAML tests for the newline/space split bug * Fix incorrect terminating of lines on whitespace * Fix most of the parser regressions * Bring back FOO.BAR names * remove some commented out code
1 parent b311b26 commit 3fc4292

File tree

4 files changed

+135
-143
lines changed

4 files changed

+135
-143
lines changed

Diff for: fixtures/plain.env

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ OPTION_C= 3
44
OPTION_D =4
55
OPTION_E = 5
66
OPTION_F =
7-
OPTION_G=
7+
OPTION_G=
8+
OPTION_H=1 2

Diff for: godotenv.go

-126
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@ package godotenv
1515

1616
import (
1717
"bytes"
18-
"errors"
1918
"fmt"
2019
"io"
2120
"os"
2221
"os/exec"
23-
"regexp"
2422
"sort"
2523
"strconv"
2624
"strings"
@@ -215,130 +213,6 @@ func readFile(filename string) (envMap map[string]string, err error) {
215213
return Parse(file)
216214
}
217215

218-
var exportRegex = regexp.MustCompile(`^\s*(?:export\s+)?(.*?)\s*$`)
219-
220-
func parseLine(line string, envMap map[string]string) (key string, value string, err error) {
221-
if len(line) == 0 {
222-
err = errors.New("zero length string")
223-
return
224-
}
225-
226-
// ditch the comments (but keep quoted hashes)
227-
if strings.Contains(line, "#") {
228-
segmentsBetweenHashes := strings.Split(line, "#")
229-
quotesAreOpen := false
230-
var segmentsToKeep []string
231-
for _, segment := range segmentsBetweenHashes {
232-
if strings.Count(segment, "\"") == 1 || strings.Count(segment, "'") == 1 {
233-
if quotesAreOpen {
234-
quotesAreOpen = false
235-
segmentsToKeep = append(segmentsToKeep, segment)
236-
} else {
237-
quotesAreOpen = true
238-
}
239-
}
240-
241-
if len(segmentsToKeep) == 0 || quotesAreOpen {
242-
segmentsToKeep = append(segmentsToKeep, segment)
243-
}
244-
}
245-
246-
line = strings.Join(segmentsToKeep, "#")
247-
}
248-
249-
firstEquals := strings.Index(line, "=")
250-
firstColon := strings.Index(line, ":")
251-
splitString := strings.SplitN(line, "=", 2)
252-
if firstColon != -1 && (firstColon < firstEquals || firstEquals == -1) {
253-
//this is a yaml-style line
254-
splitString = strings.SplitN(line, ":", 2)
255-
}
256-
257-
if len(splitString) != 2 {
258-
err = errors.New("can't separate key from value")
259-
return
260-
}
261-
262-
// Parse the key
263-
key = splitString[0]
264-
265-
key = strings.TrimPrefix(key, "export")
266-
267-
key = strings.TrimSpace(key)
268-
269-
key = exportRegex.ReplaceAllString(splitString[0], "$1")
270-
271-
// Parse the value
272-
value = parseValue(splitString[1], envMap)
273-
return
274-
}
275-
276-
var (
277-
singleQuotesRegex = regexp.MustCompile(`\A'(.*)'\z`)
278-
doubleQuotesRegex = regexp.MustCompile(`\A"(.*)"\z`)
279-
escapeRegex = regexp.MustCompile(`\\.`)
280-
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
281-
)
282-
283-
func parseValue(value string, envMap map[string]string) string {
284-
285-
// trim
286-
value = strings.Trim(value, " ")
287-
288-
// check if we've got quoted values or possible escapes
289-
if len(value) > 1 {
290-
singleQuotes := singleQuotesRegex.FindStringSubmatch(value)
291-
292-
doubleQuotes := doubleQuotesRegex.FindStringSubmatch(value)
293-
294-
if singleQuotes != nil || doubleQuotes != nil {
295-
// pull the quotes off the edges
296-
value = value[1 : len(value)-1]
297-
}
298-
299-
if doubleQuotes != nil {
300-
// expand newlines
301-
value = escapeRegex.ReplaceAllStringFunc(value, func(match string) string {
302-
c := strings.TrimPrefix(match, `\`)
303-
switch c {
304-
case "n":
305-
return "\n"
306-
case "r":
307-
return "\r"
308-
default:
309-
return match
310-
}
311-
})
312-
// unescape characters
313-
value = unescapeCharsRegex.ReplaceAllString(value, "$1")
314-
}
315-
316-
if singleQuotes == nil {
317-
value = expandVariables(value, envMap)
318-
}
319-
}
320-
321-
return value
322-
}
323-
324-
var expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
325-
326-
func expandVariables(v string, m map[string]string) string {
327-
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
328-
submatch := expandVarRegex.FindStringSubmatch(s)
329-
330-
if submatch == nil {
331-
return s
332-
}
333-
if submatch[1] == "\\" || submatch[2] == "(" {
334-
return submatch[0][1:]
335-
} else if submatch[4] != "" {
336-
return m[submatch[4]]
337-
}
338-
return s
339-
})
340-
}
341-
342216
func doubleQuoteEscape(line string) string {
343217
for _, c := range doubleQuoteSpecialChars {
344218
toReplace := "\\" + string(c)

Diff for: godotenv_test.go

+61-8
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ import (
1212
var noopPresets = make(map[string]string)
1313

1414
func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) {
15-
key, value, _ := parseLine(rawEnvLine, noopPresets)
16-
if key != expectedKey || value != expectedValue {
17-
t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, expectedKey, expectedValue, key, value)
15+
result, err := Unmarshal(rawEnvLine)
16+
17+
if err != nil {
18+
t.Errorf("Expected %q to parse as %q: %q, errored %q", rawEnvLine, expectedKey, expectedValue, err)
19+
return
20+
}
21+
if result[expectedKey] != expectedValue {
22+
t.Errorf("Expected '%v' to parse as '%v' => '%v', got %q instead", rawEnvLine, expectedKey, expectedValue, result)
1823
}
1924
}
2025

@@ -80,6 +85,7 @@ func TestReadPlainEnv(t *testing.T) {
8085
"OPTION_E": "5",
8186
"OPTION_F": "",
8287
"OPTION_G": "",
88+
"OPTION_H": "1 2",
8389
}
8490

8591
envMap, err := Read(envFileName)
@@ -153,6 +159,7 @@ func TestLoadPlainEnv(t *testing.T) {
153159
"OPTION_C": "3",
154160
"OPTION_D": "4",
155161
"OPTION_E": "5",
162+
"OPTION_H": "1 2",
156163
}
157164

158165
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
@@ -272,7 +279,6 @@ func TestExpanding(t *testing.T) {
272279
}
273280
})
274281
}
275-
276282
}
277283

278284
func TestVariableStringValueSeparator(t *testing.T) {
@@ -369,6 +375,9 @@ func TestParsing(t *testing.T) {
369375
// expect(env('foo=bar ')).to eql('foo' => 'bar') # not 'bar '
370376
parseAndCompare(t, "FOO=bar ", "FOO", "bar")
371377

378+
// unquoted internal whitespace is preserved
379+
parseAndCompare(t, `KEY=value value`, "KEY", "value value")
380+
372381
// it 'ignores inline comments' do
373382
// expect(env("foo=bar # this is foo")).to eql('foo' => 'bar')
374383
parseAndCompare(t, "FOO=bar # this is foo", "FOO", "bar")
@@ -391,18 +400,16 @@ func TestParsing(t *testing.T) {
391400
parseAndCompare(t, `FOO="bar\\r\ b\az"`, "FOO", "bar\\r baz")
392401

393402
parseAndCompare(t, `="value"`, "", "value")
394-
parseAndCompare(t, `KEY="`, "KEY", "\"")
395-
parseAndCompare(t, `KEY="value`, "KEY", "\"value")
396403

397-
// leading whitespace should be ignored
404+
// unquoted whitespace around keys should be ignored
398405
parseAndCompare(t, " KEY =value", "KEY", "value")
399406
parseAndCompare(t, " KEY=value", "KEY", "value")
400407
parseAndCompare(t, "\tKEY=value", "KEY", "value")
401408

402409
// it 'throws an error if line format is incorrect' do
403410
// expect{env('lol$wut')}.to raise_error(Dotenv::FormatError)
404411
badlyFormattedLine := "lol$wut"
405-
_, _, err := parseLine(badlyFormattedLine, noopPresets)
412+
_, err := Unmarshal(badlyFormattedLine)
406413
if err == nil {
407414
t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine)
408415
}
@@ -520,3 +527,49 @@ func TestRoundtrip(t *testing.T) {
520527

521528
}
522529
}
530+
531+
func TestTrailingNewlines(t *testing.T) {
532+
cases := map[string]struct {
533+
input string
534+
key string
535+
value string
536+
}{
537+
"Simple value without trailing newline": {
538+
input: "KEY=value",
539+
key: "KEY",
540+
value: "value",
541+
},
542+
"Value with internal whitespace without trailing newline": {
543+
input: "KEY=value value",
544+
key: "KEY",
545+
value: "value value",
546+
},
547+
"Value with internal whitespace with trailing newline": {
548+
input: "KEY=value value\n",
549+
key: "KEY",
550+
value: "value value",
551+
},
552+
"YAML style - value with internal whitespace without trailing newline": {
553+
input: "KEY: value value",
554+
key: "KEY",
555+
value: "value value",
556+
},
557+
"YAML style - value with internal whitespace with trailing newline": {
558+
input: "KEY: value value\n",
559+
key: "KEY",
560+
value: "value value",
561+
},
562+
}
563+
564+
for n, c := range cases {
565+
t.Run(n, func(t *testing.T) {
566+
result, err := Unmarshal(c.input)
567+
if err != nil {
568+
t.Errorf("Input: %q Unexpected error:\t%q", c.input, err)
569+
}
570+
if result[c.key] != c.value {
571+
t.Errorf("Input %q Expected:\t %q/%q\nGot:\t %q", c.input, c.key, c.value, result)
572+
}
573+
})
574+
}
575+
}

Diff for: parser.go

+72-8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"errors"
66
"fmt"
7+
"regexp"
78
"strings"
89
"unicode"
910
)
@@ -69,7 +70,13 @@ func getStatementStart(src []byte) []byte {
6970
// locateKeyName locates and parses key name and returns rest of slice
7071
func locateKeyName(src []byte) (key string, cutset []byte, err error) {
7172
// trim "export" and space at beginning
72-
src = bytes.TrimLeftFunc(bytes.TrimPrefix(src, []byte(exportPrefix)), isSpace)
73+
src = bytes.TrimLeftFunc(src, isSpace)
74+
if bytes.HasPrefix(src, []byte(exportPrefix)) {
75+
trimmed := bytes.TrimPrefix(src, []byte(exportPrefix))
76+
if bytes.IndexFunc(trimmed, isSpace) == 0 {
77+
src = bytes.TrimLeftFunc(trimmed, isSpace)
78+
}
79+
}
7380

7481
// locate key name end and validate it in single loop
7582
offset := 0
@@ -88,8 +95,8 @@ loop:
8895
break loop
8996
case '_':
9097
default:
91-
// variable name should match [A-Za-z0-9_]
92-
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) {
98+
// variable name should match [A-Za-z0-9_.]
99+
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' {
93100
continue
94101
}
95102

@@ -113,13 +120,41 @@ loop:
113120
func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) {
114121
quote, hasPrefix := hasQuotePrefix(src)
115122
if !hasPrefix {
116-
// unquoted value - read until whitespace
117-
end := bytes.IndexFunc(src, unicode.IsSpace)
118-
if end == -1 {
119-
return expandVariables(string(src), vars), nil, nil
123+
// unquoted value - read until end of line
124+
endOfLine := bytes.IndexFunc(src, isLineEnd)
125+
126+
// Hit EOF without a trailing newline
127+
if endOfLine == -1 {
128+
endOfLine = len(src)
129+
130+
if endOfLine == 0 {
131+
return "", nil, nil
132+
}
120133
}
121134

122-
return expandVariables(string(src[0:end]), vars), src[end:], nil
135+
// Convert line to rune away to do accurate countback of runes
136+
line := []rune(string(src[0:endOfLine]))
137+
138+
// Assume end of line is end of var
139+
endOfVar := len(line)
140+
if endOfVar == 0 {
141+
return "", src[endOfLine:], nil
142+
}
143+
144+
// Work backwards to check if the line ends in whitespace then
145+
// a comment (ie asdasd # some comment)
146+
for i := endOfVar - 1; i >= 0; i-- {
147+
if line[i] == charComment && i > 0 {
148+
if isSpace(line[i-1]) {
149+
endOfVar = i
150+
break
151+
}
152+
}
153+
}
154+
155+
trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
156+
157+
return expandVariables(trimmed, vars), src[endOfLine:], nil
123158
}
124159

125160
// lookup quoted string terminator
@@ -205,3 +240,32 @@ func isSpace(r rune) bool {
205240
}
206241
return false
207242
}
243+
244+
func isLineEnd(r rune) bool {
245+
if r == '\n' || r == '\r' {
246+
return true
247+
}
248+
return false
249+
}
250+
251+
var (
252+
escapeRegex = regexp.MustCompile(`\\.`)
253+
expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
254+
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
255+
)
256+
257+
func expandVariables(v string, m map[string]string) string {
258+
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
259+
submatch := expandVarRegex.FindStringSubmatch(s)
260+
261+
if submatch == nil {
262+
return s
263+
}
264+
if submatch[1] == "\\" || submatch[2] == "(" {
265+
return submatch[0][1:]
266+
} else if submatch[4] != "" {
267+
return m[submatch[4]]
268+
}
269+
return s
270+
})
271+
}

0 commit comments

Comments
 (0)