Skip to content

Commit 8a8c458

Browse files
author
Chris Stockton
committed
feat: update envparse with minor bug fixes
This incorporates some of the upstream bug fixes and makes minor changes to Parsing for internal auth use. It includes all the tests from the godotenv which are ran side by side with the new Parse functions.
1 parent ff73039 commit 8a8c458

2 files changed

Lines changed: 568 additions & 65 deletions

File tree

internal/conf/envparse/envparse.go

Lines changed: 85 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
// Package envparse is a fork of the github.com/joho/godotenv parser.
22
//
3+
// This fork is based on master which has some minor fixes[1] since the v1.5.1
4+
// we previously used.
5+
//
6+
// [1] https://github.com/joho/godotenv/compare/v1.5.1...main
7+
//
38
// -------
49
//
510
// # Copyright (c) 2013 John Barton
@@ -30,12 +35,39 @@ import (
3035
"bytes"
3136
"errors"
3237
"fmt"
33-
"os"
34-
"regexp"
38+
"io"
3539
"strings"
3640
"unicode"
3741
)
3842

43+
// Parse reads an env file from io.Reader, returning a map of keys and values.
44+
func Parse(r io.Reader) (map[string]string, error) {
45+
data, err := io.ReadAll(r)
46+
if err != nil {
47+
return nil, err
48+
}
49+
return ParseData(data)
50+
}
51+
52+
// ParseData is like Parse but works on a string or []byte slice.
53+
func ParseData[T ~string | ~[]byte](data T) (map[string]string, error) {
54+
buf := []byte(data)
55+
if _, ok := any(data).([]byte); ok {
56+
// since we use data as scratch space
57+
buf = bytes.Clone(buf)
58+
}
59+
return parseData(buf)
60+
}
61+
62+
// parseData will mutate data during parsing, use ParseData to avoid this.
63+
func parseData(data []byte) (map[string]string, error) {
64+
out := make(map[string]string)
65+
if err := parseBytes([]byte(data), out); err != nil {
66+
return nil, err
67+
}
68+
return out, nil
69+
}
70+
3971
const (
4072
charComment = '#'
4173
prefixSingleQuote = '\''
@@ -59,7 +91,7 @@ func parseBytes(src []byte, out map[string]string) error {
5991
return err
6092
}
6193

62-
value, left, err := extractVarValue(left, out)
94+
value, left, err := extractVarValue(left)
6395
if err != nil {
6496
return err
6597
}
@@ -143,8 +175,8 @@ loop:
143175
return key, cutset, nil
144176
}
145177

146-
// extractVarValue extracts variable value and returns rest of slice
147-
func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) {
178+
// extractVarValue extracts variable value and returns rest of slice.
179+
func extractVarValue(src []byte) (value string, rest []byte, err error) {
148180
quote, hasPrefix := hasQuotePrefix(src)
149181
if !hasPrefix {
150182
// unquoted value - read until end of line
@@ -168,42 +200,36 @@ func extractVarValue(src []byte, vars map[string]string) (value string, rest []b
168200
return "", src[endOfLine:], nil
169201
}
170202

171-
// Work backwards to check if the line ends in whitespace then
172-
// a comment, ie: foo=bar # baz # other
173-
for i := 0; i < endOfVar; i++ {
174-
if line[i] == charComment && i < endOfVar {
175-
if isSpace(line[i-1]) {
176-
endOfVar = i
177-
break
178-
}
203+
// Strip trailing comments only when '#' is preceded by whitespace:
204+
// FOO=bar # comment => "bar"
205+
// FOO=bar#baz => "bar#baz"
206+
// FOO=#bar => "#bar"
207+
for i := 1; i < endOfVar; i++ {
208+
if line[i] == charComment && isSpace(line[i-1]) {
209+
endOfVar = i
210+
break
179211
}
180212
}
181213

182214
trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
183-
184-
return expandVariables(trimmed, vars), src[endOfLine:], nil
215+
return trimmed, src[endOfLine:], nil
185216
}
186217

187218
// lookup quoted string terminator
188219
for i := 1; i < len(src); i++ {
189-
if char := src[i]; char != quote {
220+
if src[i] != quote {
190221
continue
191222
}
192-
193-
// skip escaped quote symbol (\" or \', depends on quote)
194-
if prevChar := src[i-1]; prevChar == '\\' {
223+
if isEscaped(src, i) {
195224
continue
196225
}
197226

198-
// trim quotes
199-
trimFunc := isCharFunc(rune(quote))
200-
value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc))
227+
valueBytes := src[1:i]
201228
if quote == prefixDoubleQuote {
202-
// unescape newlines for double quote (this is compat feature)
203-
// and expand environment variables
204-
value = expandVariables(expandEscapes(value), vars)
229+
valueBytes = expandEscapes(valueBytes)
205230
}
206231

232+
value = string(valueBytes)
207233
return value, src[i+1:], nil
208234
}
209235

@@ -212,23 +238,45 @@ func extractVarValue(src []byte, vars map[string]string) (value string, rest []b
212238
if valEndIndex == -1 {
213239
valEndIndex = len(src)
214240
}
215-
216241
return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
217242
}
218243

219-
func expandEscapes(str string) string {
220-
out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string {
221-
c := strings.TrimPrefix(match, `\`)
222-
switch c {
223-
case "n":
224-
return "\n"
225-
case "r":
226-
return "\r"
244+
func isEscaped(src []byte, index int) bool {
245+
var n int
246+
for i := index - 1; i >= 0 && src[i] == '\\'; i-- {
247+
n++
248+
}
249+
return n%2 == 1
250+
}
251+
252+
func expandEscapes(src []byte) []byte {
253+
var n int
254+
for r := 0; r < len(src); r++ {
255+
if src[r] != '\\' || r+1 >= len(src) {
256+
src[n] = src[r]
257+
n++
258+
continue
259+
}
260+
261+
r++
262+
switch src[r] {
263+
case 'n':
264+
src[n] = '\n'
265+
case 'r':
266+
src[n] = '\r'
267+
case '$':
268+
// TODO(cstockton): We keep '$' here for stricter compat with todays
269+
// config. If we want to be more strict (e.g. \$ -> \$) we can emit
270+
// the additional \\ as well.
271+
src[n] = '$'
227272
default:
228-
return match
273+
// Preserve upstream godotenv behavior for non-dollar escapes:
274+
// \" => ", \\ => \, \x => x.
275+
src[n] = src[r]
229276
}
230-
})
231-
return unescapeCharsRegex.ReplaceAllString(out, "$1")
277+
n++
278+
}
279+
return src[:n]
232280
}
233281

234282
func indexOfNonSpaceChar(src []byte) int {
@@ -274,31 +322,3 @@ func isLineEnd(r rune) bool {
274322
}
275323
return false
276324
}
277-
278-
var (
279-
escapeRegex = regexp.MustCompile(`\\.`)
280-
expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
281-
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
282-
)
283-
284-
func expandVariables(v string, m map[string]string) string {
285-
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
286-
submatch := expandVarRegex.FindStringSubmatch(s)
287-
288-
if submatch == nil {
289-
return s
290-
}
291-
if submatch[1] == "\\" || submatch[2] == "(" {
292-
return submatch[0][1:]
293-
} else if submatch[4] != "" {
294-
if val, ok := m[submatch[4]]; ok {
295-
return val
296-
}
297-
if val, ok := os.LookupEnv(submatch[4]); ok {
298-
return val
299-
}
300-
return m[submatch[4]]
301-
}
302-
return s
303-
})
304-
}

0 commit comments

Comments
 (0)