Skip to content

Commit 6e6c9b3

Browse files
authored
Merge branch 'master' into fix/test-otp-equals-delimiter
2 parents 81d8819 + cda62a9 commit 6e6c9b3

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)