Skip to content

Commit cda62a9

Browse files
cstocktonChris Stockton
andauthored
feat: fork github.com/joho/godotenv into internal/conf/envparse (#2521)
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. --------- Co-authored-by: Chris Stockton <chris.stockton@supabase.io>
1 parent 77f5918 commit cda62a9

2 files changed

Lines changed: 829 additions & 0 deletions

File tree

internal/conf/envparse/envparse.go

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
// Package envparse is a fork of the github.com/joho/godotenv parser.
2+
//
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+
//
8+
// -------
9+
//
10+
// # Copyright (c) 2013 John Barton
11+
//
12+
// # MIT License
13+
//
14+
// Permission is hereby granted, free of charge, to any person obtaining
15+
// a copy of this software and associated documentation files (the
16+
// "Software"), to deal in the Software without restriction, including
17+
// without limitation the rights to use, copy, modify, merge, publish,
18+
// distribute, sublicense, and/or sell copies of the Software, and to
19+
// permit persons to whom the Software is furnished to do so, subject to
20+
// the following conditions:
21+
//
22+
// The above copyright notice and this permission notice shall be
23+
// included in all copies or substantial portions of the Software.
24+
//
25+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
26+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
27+
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
28+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
29+
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
30+
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
31+
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
32+
package envparse
33+
34+
import (
35+
"bytes"
36+
"errors"
37+
"io"
38+
"strings"
39+
"unicode"
40+
)
41+
42+
// Parse reads an env file from io.Reader, returning a map of keys and values.
43+
func Parse(r io.Reader) (map[string]string, error) {
44+
data, err := io.ReadAll(r)
45+
if err != nil {
46+
return nil, err
47+
}
48+
return ParseData(data)
49+
}
50+
51+
// ParseData is like Parse but works on a string or []byte slice.
52+
func ParseData[T ~string | ~[]byte](data T) (map[string]string, error) {
53+
buf := []byte(data)
54+
if _, ok := any(data).([]byte); ok {
55+
// since we use data as scratch space
56+
buf = bytes.Clone(buf)
57+
}
58+
return parseData(buf)
59+
}
60+
61+
// parseData will mutate data during parsing, use ParseData to avoid this.
62+
func parseData(data []byte) (map[string]string, error) {
63+
out := make(map[string]string)
64+
if err := parseBytes([]byte(data), out); err != nil {
65+
return nil, err
66+
}
67+
return out, nil
68+
}
69+
70+
const (
71+
charComment = '#'
72+
prefixSingleQuote = '\''
73+
prefixDoubleQuote = '"'
74+
75+
exportPrefix = "export"
76+
)
77+
78+
func parseBytes(src []byte, out map[string]string) error {
79+
src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1)
80+
cutset := src
81+
for {
82+
cutset = getStatementStart(cutset)
83+
if cutset == nil {
84+
// reached end of file
85+
break
86+
}
87+
88+
key, left, err := locateKeyName(cutset)
89+
if err != nil {
90+
return err
91+
}
92+
93+
value, left, err := extractVarValue(left)
94+
if err != nil {
95+
return err
96+
}
97+
98+
out[key] = value
99+
cutset = left
100+
}
101+
102+
return nil
103+
}
104+
105+
// getStatementPosition returns position of statement begin.
106+
//
107+
// It skips any comment line or non-whitespace character.
108+
func getStatementStart(src []byte) []byte {
109+
pos := indexOfNonSpaceChar(src)
110+
if pos == -1 {
111+
return nil
112+
}
113+
114+
src = src[pos:]
115+
if src[0] != charComment {
116+
return src
117+
}
118+
119+
// skip comment section
120+
pos = bytes.IndexFunc(src, isCharFunc('\n'))
121+
if pos == -1 {
122+
return nil
123+
}
124+
125+
return getStatementStart(src[pos:])
126+
}
127+
128+
// locateKeyName locates and parses key name and returns rest of slice
129+
func locateKeyName(src []byte) (key string, cutset []byte, err error) {
130+
// trim "export" and space at beginning
131+
src = bytes.TrimLeftFunc(src, isSpace)
132+
if bytes.HasPrefix(src, []byte(exportPrefix)) {
133+
trimmed := bytes.TrimPrefix(src, []byte(exportPrefix))
134+
if bytes.IndexFunc(trimmed, isSpace) == 0 {
135+
src = bytes.TrimLeftFunc(trimmed, isSpace)
136+
}
137+
}
138+
139+
// locate key name end and validate it in single loop
140+
offset := 0
141+
loop:
142+
for i, char := range src {
143+
rchar := rune(char)
144+
if isSpace(rchar) {
145+
continue
146+
}
147+
148+
switch char {
149+
case '=', ':':
150+
// library also supports yaml-style value declaration
151+
key = string(src[0:i])
152+
offset = i + 1
153+
break loop
154+
case '_':
155+
default:
156+
// variable name should match [A-Za-z0-9_.]
157+
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' {
158+
continue
159+
}
160+
return "", nil, errors.New("unexpected character in variable name")
161+
}
162+
}
163+
164+
if len(src) == 0 {
165+
return "", nil, errors.New("zero length string")
166+
}
167+
168+
// trim whitespace
169+
key = strings.TrimRightFunc(key, unicode.IsSpace)
170+
cutset = bytes.TrimLeftFunc(src[offset:], isSpace)
171+
return key, cutset, nil
172+
}
173+
174+
// expandDollarEscapes preserves godotenvs dollar escaping.
175+
func expandDollarEscapes(src []byte) []byte {
176+
var n int
177+
for r := 0; r < len(src); r++ {
178+
if src[r] != '$' {
179+
src[n] = src[r]
180+
n++
181+
continue
182+
}
183+
184+
if n > 0 && src[n-1] == '\\' {
185+
n--
186+
}
187+
188+
src[n] = '$'
189+
n++
190+
}
191+
return src[:n]
192+
}
193+
194+
// extractVarValue extracts variable value and returns rest of slice.
195+
func extractVarValue(src []byte) (value string, rest []byte, err error) {
196+
quote, hasPrefix := hasQuotePrefix(src)
197+
if !hasPrefix {
198+
// unquoted value - read until end of line
199+
endOfLine := bytes.IndexFunc(src, isLineEnd)
200+
201+
// Hit EOF without a trailing newline
202+
if endOfLine == -1 {
203+
endOfLine = len(src)
204+
205+
if endOfLine == 0 {
206+
return "", nil, nil
207+
}
208+
}
209+
210+
// Convert line to rune away to do accurate countback of runes
211+
line := []rune(string(src[0:endOfLine]))
212+
213+
// Assume end of line is end of var
214+
endOfVar := len(line)
215+
if endOfVar == 0 {
216+
return "", src[endOfLine:], nil
217+
}
218+
219+
// Strip trailing comments only when '#' is preceded by whitespace:
220+
// FOO=bar # comment => "bar"
221+
// FOO=bar#baz => "bar#baz"
222+
// FOO=#bar => "#bar"
223+
for i := 1; i < endOfVar; i++ {
224+
if line[i] == charComment && isSpace(line[i-1]) {
225+
endOfVar = i
226+
break
227+
}
228+
}
229+
230+
trimmed := []byte(strings.TrimFunc(string(line[0:endOfVar]), isSpace))
231+
return string(expandDollarEscapes(trimmed)), src[endOfLine:], nil
232+
}
233+
234+
// lookup quoted string terminator
235+
for i := 1; i < len(src); i++ {
236+
if src[i] != quote {
237+
continue
238+
}
239+
if isEscaped(src, i) {
240+
continue
241+
}
242+
243+
valueBytes := src[1:i]
244+
if quote == prefixDoubleQuote {
245+
valueBytes = expandEscapes(valueBytes)
246+
valueBytes = expandDollarEscapes(valueBytes)
247+
}
248+
249+
value = string(valueBytes)
250+
return value, src[i+1:], nil
251+
}
252+
return "", nil, errors.New("unterminated quoted value")
253+
}
254+
255+
func isEscaped(src []byte, index int) bool {
256+
var n int
257+
for i := index - 1; i >= 0 && src[i] == '\\'; i-- {
258+
n++
259+
}
260+
return n%2 == 1
261+
}
262+
263+
func expandEscapes(src []byte) []byte {
264+
var n int
265+
for r := 0; r < len(src); r++ {
266+
if src[r] != '\\' || r+1 >= len(src) {
267+
src[n] = src[r]
268+
n++
269+
continue
270+
}
271+
272+
r++
273+
switch src[r] {
274+
case 'n':
275+
src[n] = '\n'
276+
case 'r':
277+
src[n] = '\r'
278+
case '$':
279+
// TODO(cstockton): We keep '$' here for stricter compat with todays
280+
// config. If we want to be more strict (e.g. \$ -> \$) we can emit
281+
// the additional \\ as well.
282+
src[n] = '$'
283+
default:
284+
// Preserve upstream godotenv behavior for non-dollar escapes:
285+
// \" => ", \\ => \, \x => x.
286+
src[n] = src[r]
287+
}
288+
n++
289+
}
290+
return src[:n]
291+
}
292+
293+
func indexOfNonSpaceChar(src []byte) int {
294+
return bytes.IndexFunc(src, func(r rune) bool {
295+
return !unicode.IsSpace(r)
296+
})
297+
}
298+
299+
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
300+
func hasQuotePrefix(src []byte) (prefix byte, isQuoted bool) {
301+
if len(src) == 0 {
302+
return 0, false
303+
}
304+
305+
switch prefix := src[0]; prefix {
306+
case prefixDoubleQuote, prefixSingleQuote:
307+
return prefix, true
308+
default:
309+
return 0, false
310+
}
311+
}
312+
313+
func isCharFunc(char rune) func(rune) bool {
314+
return func(v rune) bool {
315+
return v == char
316+
}
317+
}
318+
319+
// isSpace reports whether the rune is a space character but not line break character
320+
//
321+
// this differs from unicode.IsSpace, which also applies line break as space
322+
func isSpace(r rune) bool {
323+
switch r {
324+
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
325+
return true
326+
}
327+
return false
328+
}
329+
330+
func isLineEnd(r rune) bool {
331+
if r == '\n' || r == '\r' {
332+
return true
333+
}
334+
return false
335+
}

0 commit comments

Comments
 (0)