Skip to content

Commit 6904a43

Browse files
Merge pull request #571 from oasisprotocol/433-rofl-secret-set-add-env-support
feat: add rofl secret import cmd
2 parents f530cc2 + 4a97637 commit 6904a43

File tree

5 files changed

+524
-0
lines changed

5 files changed

+524
-0
lines changed

build/dotenv/dotenv.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package dotenv
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"strings"
8+
)
9+
10+
// errUnterminatedQuote is returned when a quoted value is not properly terminated.
11+
var errUnterminatedQuote = errors.New("unterminated quoted value")
12+
13+
// Parse parses a .env formatted string into key/value pairs.
14+
//
15+
// Supports lines like:
16+
//
17+
// KEY=value
18+
// KEY="quoted # not comment"
19+
// KEY='single quoted'
20+
// export KEY=value
21+
//
22+
// Comments start with '#' when unquoted (at start or preceded by whitespace).
23+
func Parse(data string) (map[string]string, error) {
24+
res := make(map[string]string)
25+
26+
// Normalize line endings.
27+
data = strings.ReplaceAll(data, "\r\n", "\n")
28+
data = strings.ReplaceAll(data, "\r", "\n")
29+
30+
lines := strings.Split(data, "\n")
31+
for i := 0; i < len(lines); i++ {
32+
raw := lines[i]
33+
line := strings.TrimSpace(raw)
34+
if line == "" {
35+
continue
36+
}
37+
// Strip BOM on first line if present.
38+
if i == 0 && strings.HasPrefix(line, "\uFEFF") {
39+
line = strings.TrimSpace(strings.TrimPrefix(line, "\uFEFF"))
40+
}
41+
// Full-line comment.
42+
if strings.HasPrefix(line, "#") {
43+
continue
44+
}
45+
// Optional export prefix (allow any whitespace after 'export').
46+
if strings.HasPrefix(line, "export") {
47+
after := line[len("export"):]
48+
if len(after) > 0 && isSpace(after[0]) {
49+
line = strings.TrimSpace(after)
50+
}
51+
}
52+
53+
eq := strings.IndexByte(line, '=')
54+
if eq == -1 {
55+
fmt.Fprintf(os.Stderr, "warning: skipping malformed .env line %d (no '=')\n", i+1)
56+
continue
57+
}
58+
key := strings.TrimSpace(line[:eq])
59+
if key == "" {
60+
fmt.Fprintf(os.Stderr, "warning: skipping malformed .env line %d (empty key)\n", i+1)
61+
continue
62+
}
63+
valuePart := strings.TrimSpace(line[eq+1:])
64+
65+
var (
66+
val string
67+
err error
68+
)
69+
70+
// If the value starts quoted and doesn't close on the same line,
71+
// keep appending subsequent lines until we find the closing quote.
72+
if startsWithQuote(valuePart) {
73+
var consumed int
74+
val, consumed, err = collectQuotedValue(valuePart, lines, i)
75+
if err != nil {
76+
return nil, err
77+
}
78+
// Skip the extra lines we consumed for this value.
79+
i += consumed
80+
} else {
81+
val, err = parseEnvValue(valuePart)
82+
if err != nil {
83+
return nil, fmt.Errorf("line %d: %w", i+1, err)
84+
}
85+
}
86+
87+
res[key] = val
88+
}
89+
return res, nil
90+
}
91+
92+
// parseEnvValue parses a single .env value (possibly quoted) and strips trailing comments when unquoted.
93+
func parseEnvValue(s string) (string, error) {
94+
if s == "" {
95+
return "", nil
96+
}
97+
if strings.HasPrefix(s, "\"") {
98+
v, err := parseQuotedValue(s, '"', true)
99+
return v, err
100+
}
101+
if strings.HasPrefix(s, "'") {
102+
v, err := parseQuotedValue(s, '\'', false)
103+
return v, err
104+
}
105+
// Unquoted value: stop before an unquoted comment start '#'
106+
// when it's the first character or preceded by whitespace.
107+
for i := 0; i < len(s); i++ {
108+
if s[i] == '#' && (i == 0 || isSpace(s[i-1])) {
109+
return strings.TrimSpace(s[:i]), nil
110+
}
111+
}
112+
return strings.TrimSpace(s), nil
113+
}
114+
115+
// startsWithQuote reports whether the value begins with a single or double quote.
116+
func startsWithQuote(s string) bool {
117+
return len(s) > 0 && (s[0] == '"' || s[0] == '\'')
118+
}
119+
120+
// collectQuotedValue accumulates a possibly multi-line quoted value until the closing quote.
121+
// Returns the parsed value, the number of extra lines consumed (beyond the current one), or an error.
122+
func collectQuotedValue(valuePart string, lines []string, startIdx int) (string, int, error) {
123+
delim := valuePart[0]
124+
unescape := delim == '"'
125+
combined := valuePart
126+
consumed := 0
127+
128+
for {
129+
v, err := parseQuotedValue(combined, delim, unescape)
130+
switch {
131+
case err == nil:
132+
return v, consumed, nil
133+
case errors.Is(err, errUnterminatedQuote):
134+
// Need another line. If none left, report a wrapped unterminated-quote error with line context.
135+
if startIdx+consumed+1 >= len(lines) {
136+
return "", 0, fmt.Errorf("line %d: %w", startIdx+1, errUnterminatedQuote)
137+
}
138+
consumed++
139+
combined += "\n" + lines[startIdx+consumed]
140+
default:
141+
return "", 0, fmt.Errorf("line %d: %w", startIdx+1, err)
142+
}
143+
}
144+
}
145+
146+
// parseQuotedValue parses a value starting with the given delimiter.
147+
// If unescape is true, common backslash escapes (\n,\r,\t,\",\\) are processed.
148+
func parseQuotedValue(s string, delim byte, unescape bool) (val string, err error) {
149+
var b strings.Builder
150+
escaped := false
151+
for i := 1; i < len(s); i++ {
152+
ch := s[i]
153+
if escaped {
154+
if unescape {
155+
switch ch {
156+
case 'n':
157+
b.WriteByte('\n')
158+
case 'r':
159+
b.WriteByte('\r')
160+
case 't':
161+
b.WriteByte('\t')
162+
case '\\':
163+
b.WriteByte('\\')
164+
case '"':
165+
b.WriteByte('"')
166+
default:
167+
// Preserve the backslash for unknown escapes.
168+
b.WriteByte('\\')
169+
b.WriteByte(ch)
170+
}
171+
} else {
172+
// In single-quoted values, keep escapes literally.
173+
b.WriteByte('\\')
174+
b.WriteByte(ch)
175+
}
176+
escaped = false
177+
continue
178+
}
179+
if ch == '\\' && unescape {
180+
escaped = true
181+
continue
182+
}
183+
if ch == delim {
184+
// Ignore anything after the closing quote (comments, etc).
185+
return b.String(), nil
186+
}
187+
b.WriteByte(ch)
188+
}
189+
return "", errUnterminatedQuote
190+
}
191+
192+
func isSpace(b byte) bool {
193+
return b == ' ' || b == '\t'
194+
}

build/dotenv/dotenv_test.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package dotenv
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestParseDotEnv(t *testing.T) {
9+
cases := []struct {
10+
name string
11+
input string
12+
want map[string]string
13+
wantErr bool
14+
}{
15+
{
16+
name: "basic",
17+
input: "FOO=bar\nBAZ=qux\n",
18+
want: map[string]string{
19+
"FOO": "bar",
20+
"BAZ": "qux",
21+
},
22+
},
23+
{
24+
name: "whitespace_and_comments",
25+
input: `
26+
# comment
27+
FOO=bar # trailing comment
28+
BAZ = qux # another
29+
QUUX=abc#not a comment
30+
`,
31+
want: map[string]string{
32+
"FOO": "bar",
33+
"BAZ": "qux",
34+
"QUUX": "abc#not a comment",
35+
},
36+
},
37+
{
38+
name: "export_prefix",
39+
input: "export KEY=value\nX=1 # trailing comment\n",
40+
want: map[string]string{
41+
"KEY": "value",
42+
"X": "1",
43+
},
44+
},
45+
{
46+
name: "export_whitespace",
47+
input: "export\tKEY=value\nexport X=1\nexportY=should_not_match\n",
48+
want: map[string]string{
49+
"KEY": "value",
50+
"X": "1",
51+
"exportY": "should_not_match",
52+
},
53+
},
54+
{
55+
name: "double_and_single_quotes",
56+
input: `
57+
A="quoted # not comment"
58+
B='single quoted # not comment'
59+
C="line1\nline2\tTabbed\\Backslash\"Quote"
60+
D='escapes \n are literal \\ and # not comment'
61+
`,
62+
want: map[string]string{
63+
"A": "quoted # not comment",
64+
"B": "single quoted # not comment",
65+
"C": "line1\nline2\tTabbed\\Backslash\"Quote",
66+
"D": "escapes \\n are literal \\\\ and # not comment",
67+
},
68+
},
69+
{
70+
name: "bom",
71+
input: "\uFEFFFOO=bar\n",
72+
want: map[string]string{
73+
"FOO": "bar",
74+
},
75+
},
76+
{
77+
name: "unterminated_double_quote",
78+
input: `FOO="bar`,
79+
wantErr: true,
80+
},
81+
{
82+
name: "unterminated_single_quote",
83+
input: "FOO='bar",
84+
wantErr: true,
85+
},
86+
{
87+
name: "unknown_escape_is_preserved",
88+
input: `FOO="bar\qbaz"`,
89+
want: map[string]string{
90+
"FOO": `bar\qbaz`,
91+
},
92+
},
93+
{
94+
name: "malformed_empty_key_is_skipped",
95+
input: `
96+
=value
97+
OK=1
98+
`,
99+
want: map[string]string{
100+
"OK": "1",
101+
},
102+
},
103+
{
104+
name: "ignore_after_closing_quote",
105+
input: `
106+
A="val" # comment
107+
B='val' # comment
108+
`,
109+
want: map[string]string{
110+
"A": "val",
111+
"B": "val",
112+
},
113+
},
114+
{
115+
name: "multiline_double_quoted_physical",
116+
input: `
117+
MULTI="line1
118+
line2
119+
line3"
120+
`,
121+
want: map[string]string{
122+
"MULTI": "line1\nline2\nline3",
123+
},
124+
},
125+
{
126+
name: "multiline_single_quoted_physical",
127+
input: `
128+
S='line1
129+
line2\q'
130+
`,
131+
want: map[string]string{
132+
"S": "line1\nline2\\q",
133+
},
134+
},
135+
{
136+
name: "multiline_with_trailing_comment",
137+
input: `
138+
A="v1
139+
v2" # trailing comment
140+
`,
141+
want: map[string]string{
142+
"A": "v1\nv2",
143+
},
144+
},
145+
{
146+
name: "pem_like_certificate",
147+
input: `
148+
TLS_CERT="-----BEGIN CERTIFICATE-----
149+
ABC
150+
DEF
151+
-----END CERTIFICATE-----"
152+
`,
153+
want: map[string]string{
154+
"TLS_CERT": "-----BEGIN CERTIFICATE-----\nABC\nDEF\n-----END CERTIFICATE-----",
155+
},
156+
},
157+
{
158+
name: "unterminated_multiline_at_eof",
159+
input: `
160+
X="line1
161+
line2
162+
`,
163+
wantErr: true,
164+
},
165+
}
166+
167+
for _, tc := range cases {
168+
tc := tc
169+
t.Run(tc.name, func(t *testing.T) {
170+
got, err := Parse(tc.input)
171+
if tc.wantErr {
172+
if err == nil {
173+
t.Fatalf("expected error, got none (result: %#v)", got)
174+
}
175+
return
176+
}
177+
if err != nil {
178+
t.Fatalf("unexpected error: %v", err)
179+
}
180+
if !reflect.DeepEqual(got, tc.want) {
181+
t.Fatalf("mismatch:\n got: %#v\n want: %#v", got, tc.want)
182+
}
183+
})
184+
}
185+
}

0 commit comments

Comments
 (0)