From 30b6d1f47a35d00fca7cff0daa2ff59a98c5a85e Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 13 Apr 2022 11:35:28 -0600 Subject: [PATCH] cmd: Enhance .env (dotenv) file parsing Basic support for quoted values, newlines in quoted values, and comments. Does not support variable or command expansion. --- cmd/main.go | 58 +++++++++++----- cmd/main_test.go | 170 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 16 deletions(-) create mode 100644 cmd/main_test.go diff --git a/cmd/main.go b/cmd/main.go index f111ba4d6c0..498a8ae6a88 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -368,42 +368,68 @@ func loadEnvFromFile(envFile string) error { return nil } +// parseEnvFile parses an env file from KEY=VALUE format. +// It's pretty naive. Limited value quotation is supported, +// but variable and command expansions are not supported. func parseEnvFile(envInput io.Reader) (map[string]string, error) { envMap := make(map[string]string) scanner := bufio.NewScanner(envInput) - var line string - lineNumber := 0 + var lineNumber int for scanner.Scan() { - line = strings.TrimSpace(scanner.Text()) + line := strings.TrimSpace(scanner.Text()) lineNumber++ - // skip lines starting with comment - if strings.HasPrefix(line, "#") { - continue - } - - // skip empty line - if len(line) == 0 { + // skip empty lines and lines starting with comment + if line == "" || strings.HasPrefix(line, "#") { continue } + // split line into key and value fields := strings.SplitN(line, "=", 2) if len(fields) != 2 { return nil, fmt.Errorf("can't parse line %d; line should be in KEY=VALUE format", lineNumber) } + key, val := fields[0], fields[1] - if strings.Contains(fields[0], " ") { - return nil, fmt.Errorf("bad key on line %d: contains whitespace", lineNumber) - } - - key := fields[0] - val := fields[1] + // sometimes keys are prefixed by "export " so file can be sourced in bash; ignore it here + key = strings.TrimPrefix(key, "export ") + // validate key and value if key == "" { return nil, fmt.Errorf("missing or empty key on line %d", lineNumber) } + if strings.Contains(key, " ") { + return nil, fmt.Errorf("invalid key on line %d: contains whitespace: %s", lineNumber, key) + } + if strings.HasPrefix(val, " ") || strings.HasPrefix(val, "\t") { + return nil, fmt.Errorf("invalid value on line %d: whitespace before value: '%s'", lineNumber, val) + } + + // remove any trailing comment after value + if commentStart := strings.Index(val, "#"); commentStart > 0 { + before := val[commentStart-1] + if before == '\t' || before == ' ' { + val = strings.TrimRight(val[:commentStart], " \t") + } + } + + // quoted value: support newlines + if strings.HasPrefix(val, `"`) { + for !(strings.HasSuffix(line, `"`) && !strings.HasSuffix(line, `\"`)) { + val = strings.ReplaceAll(val, `\"`, `"`) + if !scanner.Scan() { + break + } + lineNumber++ + line = strings.ReplaceAll(scanner.Text(), `\"`, `"`) + val += "\n" + line + } + val = strings.TrimPrefix(val, `"`) + val = strings.TrimSuffix(val, `"`) + } + envMap[key] = val } diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 00000000000..90e8194f6b9 --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,170 @@ +package caddycmd + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseEnvFile(t *testing.T) { + for i, tc := range []struct { + input string + expect map[string]string + shouldErr bool + }{ + { + input: `KEY=value`, + expect: map[string]string{ + "KEY": "value", + }, + }, + { + input: ` + KEY=value + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + KEY=value + INVALID KEY=asdf + OTHER_KEY=Some Value + `, + shouldErr: true, + }, + { + input: ` + KEY=value + SIMPLE_QUOTED="quoted value" + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + "SIMPLE_QUOTED": "quoted value", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + KEY=value + NEWLINES="foo + bar" + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + "NEWLINES": "foo\n\tbar", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + KEY=value + ESCAPED="\"escaped quotes\" +here" + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + "ESCAPED": "\"escaped quotes\"\nhere", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + export KEY=value + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + =value + OTHER_KEY=Some Value + `, + shouldErr: true, + }, + { + input: ` + EMPTY= + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "EMPTY": "", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + EMPTY="" + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "EMPTY": "", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + KEY=value + #OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + }, + }, + { + input: ` + KEY=value + COMMENT=foo bar # some comment here + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + "COMMENT": "foo bar", + "OTHER_KEY": "Some Value", + }, + }, + { + input: ` + KEY=value + WHITESPACE= foo + OTHER_KEY=Some Value + `, + shouldErr: true, + }, + { + input: ` + KEY=value + WHITESPACE=" foo bar " + OTHER_KEY=Some Value + `, + expect: map[string]string{ + "KEY": "value", + "WHITESPACE": " foo bar ", + "OTHER_KEY": "Some Value", + }, + }, + } { + actual, err := parseEnvFile(strings.NewReader(tc.input)) + if err != nil && !tc.shouldErr { + t.Errorf("Test %d: Got error but shouldn't have: %v", i, err) + } + if err == nil && tc.shouldErr { + t.Errorf("Test %d: Did not get error but should have", i) + } + if tc.shouldErr { + continue + } + if !reflect.DeepEqual(tc.expect, actual) { + t.Errorf("Test %d: Expected %v but got %v", i, tc.expect, actual) + } + } +}