diff --git a/default.nix b/default.nix index 19ef0a735..191dcb146 100644 --- a/default.nix +++ b/default.nix @@ -12,7 +12,7 @@ buildGoModule rec { version = lib.fileContents ./version.txt; subPackages = [ "." ]; - vendorSha256 = "sha256-gFGGnnR1UNT4MYC411X8NwIqVJZqhnmUlVR+XAnrKY8="; + vendorSha256 = "sha256-u/LukIOYRudFYOrrlZTMtDAlM3+WjoSBiueR7aySSVU="; src = builtins.fetchGit ./.; diff --git a/go.mod b/go.mod index 6f23aa36f..511654c11 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.16 require ( github.com/BurntSushi/toml v1.1.0 - github.com/direnv/go-dotenv v0.0.0-20220613081022-872ea3db4cb5 github.com/mattn/go-isatty v0.0.14 golang.org/x/mod v0.5.1 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect diff --git a/go.sum b/go.sum index dd8dd7d46..b3ae8d45d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/direnv/go-dotenv v0.0.0-20220613081022-872ea3db4cb5 h1:lMrU+5WHwaPfmvp+CIxUi2ujVJSu/mYh1Rcu23n7HaY= -github.com/direnv/go-dotenv v0.0.0-20220613081022-872ea3db4cb5/go.mod h1:K5R9ofHu1mQRwNWMZgDnr0s1Ca1nkvz9r09NN/U2UyM= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/cmd/cmd_dotenv.go b/internal/cmd/cmd_dotenv.go index c495223b4..0248c2928 100644 --- a/internal/cmd/cmd_dotenv.go +++ b/internal/cmd/cmd_dotenv.go @@ -4,7 +4,7 @@ import ( "fmt" "io/ioutil" - "github.com/direnv/go-dotenv" + "github.com/direnv/direnv/v2/pkg/dotenv" ) // CmdDotEnv is `direnv dotenv [SHELL [PATH_TO_DOTENV]]` diff --git a/pkg/dotenv/README.md b/pkg/dotenv/README.md new file mode 100644 index 000000000..2f6ef68f9 --- /dev/null +++ b/pkg/dotenv/README.md @@ -0,0 +1,29 @@ +# go-dotenv + +Go parsing library for the dotenv format. + +There is no formal definition of the dotenv format but it has been introduced +by https://github.com/bkeepers/dotenv which is thus canonical. This library is a port of that. + +This library was developed specifically for [direnv](https://direnv.net). + +## Features + +* `k=v` format +* bash `export k=v` format +* yaml `k: v` format +* variable expansion, including default values as in `${FOO:-default}` +* comments + +## Missing + +* probably needs API breakage + +## Alternatives + +Some other good alternatives with various variations. + +* https://github.com/joho/godotenv +* https://github.com/lazureykis/dotenv +* https://github.com/subosito/gotenv + diff --git a/pkg/dotenv/parse.go b/pkg/dotenv/parse.go new file mode 100644 index 000000000..b8683d2cc --- /dev/null +++ b/pkg/dotenv/parse.go @@ -0,0 +1,158 @@ +// Package dotenv implements the parsing of the .env format. +// +// There is no formal definition of the format but it has been introduced by +// https://github.com/bkeepers/dotenv which is thus canonical. +package dotenv + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +// LINE is the regexp matching a single line +const LINE = ` +\A +\s* +(?:|#.*| # comment line +(?:export\s+)? # optional export +([\w\.]+) # key +(?:\s*=\s*|:\s+?) # separator +( # optional value begin + '(?:\'|[^'])*' # single quoted value + | # or + "(?:\"|[^"])*" # double quoted value + | # or + [^\s#\n]+ # unquoted value +)? # value end +\s* +(?:\#.*)? # optional comment +) +\z +` + +var linesRe = regexp.MustCompile("[\\r\\n]+") +var lineRe = regexp.MustCompile( + regexp.MustCompile("\\s+").ReplaceAllLiteralString( + regexp.MustCompile("\\s+# .*").ReplaceAllLiteralString(LINE, ""), "")) + +// Parse reads a string in the .env format and returns a map of the extracted key=values. +// +// Ported from https://github.com/bkeepers/dotenv/blob/84f33f48107c492c3a99bd41c1059e7b4c1bb67a/lib/dotenv/parser.rb +func Parse(data string) (map[string]string, error) { + var dotenv = make(map[string]string) + + for _, line := range linesRe.Split(data, -1) { + if !lineRe.MatchString(line) { + return nil, fmt.Errorf("invalid line: %s", line) + } + + match := lineRe.FindStringSubmatch(line) + // commented or empty line + if len(match) == 0 { + continue + } + if len(match[1]) == 0 { + continue + } + + key := match[1] + value := match[2] + + err := parseValue(key, value, dotenv) + + if err != nil { + return nil, fmt.Errorf("unable to parse %s, %s: %s", key, value, err) + } + } + + return dotenv, nil +} + +// MustParse works the same as Parse but panics on error +func MustParse(data string) map[string]string { + env, err := Parse(data) + if err != nil { + panic(err) + } + return env +} + +func parseValue(key string, value string, dotenv map[string]string) error { + if len(value) <= 1 { + dotenv[key] = value + return nil + } + + singleQuoted := false + + if value[0:1] == "'" && value[len(value)-1:] == "'" { + // single-quoted string, do not expand + singleQuoted = true + value = value[1 : len(value)-1] + } else if value[0:1] == `"` && value[len(value)-1:] == `"` { + value = value[1 : len(value)-1] + value = expandNewLines(value) + value = unescapeCharacters(value) + } + + if !singleQuoted { + value = expandEnv(value, dotenv) + } + + dotenv[key] = value + return nil +} + +var escRe = regexp.MustCompile("\\\\([^$])") + +func unescapeCharacters(value string) string { + return escRe.ReplaceAllString(value, "$1") +} + +func expandNewLines(value string) string { + value = strings.Replace(value, "\\n", "\n", -1) + value = strings.Replace(value, "\\r", "\r", -1) + return value +} + +func expandEnv(value string, dotenv map[string]string) string { + expander := func(value string) string { + envKey, defaultValue, hasDefault := splitKeyAndDefault(value, ":-") + expanded, found := lookupDotenv(envKey, dotenv) + + if found { + return expanded + } else { + return getFromEnvOrDefault(envKey, defaultValue, hasDefault) + } + } + + return os.Expand(value, expander) +} + +func splitKeyAndDefault(value string, sep string) (string, string, bool) { + var i = strings.Index(value, sep) + + if i == -1 { + return value, "", false + } else { + return value[0:i], value[i+len(sep):], true + } +} + +func lookupDotenv(value string, dotenv map[string]string) (string, bool) { + retval, ok := dotenv[value] + return retval, ok +} + +func getFromEnvOrDefault(envKey string, defaultValue string, hasDefault bool) string { + var envValue = os.Getenv(envKey) + + if len(envValue) == 0 && hasDefault { + return defaultValue + } else { + return envValue + } +} diff --git a/pkg/dotenv/parse_test.go b/pkg/dotenv/parse_test.go new file mode 100644 index 000000000..9eb6b069c --- /dev/null +++ b/pkg/dotenv/parse_test.go @@ -0,0 +1,327 @@ +package dotenv_test + +import ( + "os" + "testing" + + dotenv "github.com/direnv/direnv/v2/pkg/dotenv" +) + +func shouldNotHaveEmptyKey(t *testing.T, env map[string]string) { + if _, ok := env[""]; ok { + t.Error("should not have empty key") + } +} + +func envShouldContain(t *testing.T, env map[string]string, key string, value string) { + if env[key] != value { + t.Errorf("%s: %s, expected %s", key, env[key], value) + } +} + +// See the reference implementation: +// https://github.com/bkeepers/dotenv/blob/master/lib/dotenv/environment.rb +// TODO: support shell variable expansions + +const TEST_EXPORTED = `export OPTION_A=2 +export OPTION_B='\n' # foo +#export OPTION_C=3 +export OPTION_D= +export OPTION_E="foo" +` + +func TestDotEnvExported(t *testing.T) { + env := dotenv.MustParse(TEST_EXPORTED) + shouldNotHaveEmptyKey(t, env) + + if env["OPTION_A"] != "2" { + t.Error("OPTION_A") + } + if env["OPTION_B"] != "\\n" { + t.Error("OPTION_B") + } + if env["OPTION_C"] != "" { + t.Error("OPTION_C", env["OPTION_C"]) + } + if v, ok := env["OPTION_D"]; !(v == "" && ok) { + t.Error("OPTION_D") + } + if env["OPTION_E"] != "foo" { + t.Error("OPTION_E") + } +} + +const TEST_PLAIN = `OPTION_A=1 +OPTION_B=2 +OPTION_C= 3 +OPTION_D =4 +OPTION_E = 5 +OPTION_F= +OPTION_G = +SMTP_ADDRESS=smtp # This is a comment +` + +func TestDotEnvPlain(t *testing.T) { + env := dotenv.MustParse(TEST_PLAIN) + shouldNotHaveEmptyKey(t, env) + + if env["OPTION_A"] != "1" { + t.Error("OPTION_A") + } + if env["OPTION_B"] != "2" { + t.Error("OPTION_B") + } + if env["OPTION_C"] != "3" { + t.Error("OPTION_C") + } + if env["OPTION_D"] != "4" { + t.Error("OPTION_D") + } + if env["OPTION_E"] != "5" { + t.Error("OPTION_E") + } + if v, ok := env["OPTION_F"]; !(v == "" && ok) { + t.Error("OPTION_F") + } + if v, ok := env["OPTION_G"]; !(v == "" && ok) { + t.Error("OPTION_G") + } + if env["SMTP_ADDRESS"] != "smtp" { + t.Error("SMTP_ADDRESS") + } +} + +const TEST_SOLO_EMPTY = "SOME_VAR=" + +func TestSoloEmpty(t *testing.T) { + env := dotenv.MustParse(TEST_SOLO_EMPTY) + shouldNotHaveEmptyKey(t, env) + + v, ok := env["SOME_VAR"] + if !ok { + t.Error("SOME_VAR missing") + } + if v != "" { + t.Error("SOME_VAR should be empty") + } +} + +const TEST_QUOTED = `OPTION_A='1' +OPTION_B='2' +OPTION_C='' +OPTION_D='\n' +OPTION_E="1" +OPTION_F="2" +OPTION_G="" +OPTION_H="\n" +#OPTION_I="3" +` + +func TestDotEnvQuoted(t *testing.T) { + env := dotenv.MustParse(TEST_QUOTED) + shouldNotHaveEmptyKey(t, env) + + if env["OPTION_A"] != "1" { + t.Error("OPTION_A") + } + if env["OPTION_B"] != "2" { + t.Error("OPTION_B") + } + if env["OPTION_C"] != "" { + t.Error("OPTION_C") + } + if env["OPTION_D"] != "\\n" { + t.Error("OPTION_D") + } + if env["OPTION_E"] != "1" { + t.Error("OPTION_E") + } + if env["OPTION_F"] != "2" { + t.Error("OPTION_F") + } + if env["OPTION_G"] != "" { + t.Error("OPTION_G") + } + if env["OPTION_H"] != "\n" { + t.Error("OPTION_H") + } + if env["OPTION_I"] != "" { + t.Error("OPTION_I") + } +} + +const TEST_YAML = `OPTION_A: 1 +OPTION_B: '2' +OPTION_C: '' +OPTION_D: '\n' +#OPTION_E: '333' +OPTION_F: +` + +func TestDotEnvYAML(t *testing.T) { + env := dotenv.MustParse(TEST_YAML) + shouldNotHaveEmptyKey(t, env) + + if env["OPTION_A"] != "1" { + t.Error("OPTION_A") + } + if env["OPTION_B"] != "2" { + t.Error("OPTION_B") + } + if env["OPTION_C"] != "" { + t.Error("OPTION_C") + } + if env["OPTION_D"] != "\\n" { + t.Error("OPTION_D") + } + if env["OPTION_E"] != "" { + t.Error("OPTION_E") + } + if v, ok := env["OPTION_F"]; !(v == "" && ok) { + t.Error("OPTION_F") + } +} + +func TestFailingMustParse(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Error("should panic") + } + }() + dotenv.MustParse("...") +} + +const TEST_COMMENT_OVERRIDE = ` +VARIABLE=value +#VARIABLE=disabled_value +` + +func TestCommentOverride(t *testing.T) { + env := dotenv.MustParse(TEST_COMMENT_OVERRIDE) + shouldNotHaveEmptyKey(t, env) + + if env["VARIABLE"] != "value" { + t.Error("VARIABLE should == value, not", env["VARIABLE"]) + } +} + +const TEST_VARIABLE_EXPANSION = ` +OPTION_A=$FOO +OPTION_B="$FOO" +OPTION_C=${FOO} +OPTION_D="${FOO}" +OPTION_E='$FOO' +OPTION_F=$FOO/bar +OPTION_G="$FOO/bar" +OPTION_H=${FOO}/bar +OPTION_I="${FOO}/bar" +OPTION_J='$FOO/bar' +OPTION_K=$BAR +OPTION_L="$BAR" +OPTION_M=${BAR} +OPTION_N="${BAR}" +OPTION_O='$BAR' +OPTION_P=$BAR/baz +OPTION_Q="$BAR/baz" +OPTION_R=${BAR}/baz +OPTION_S="${BAR}/baz" +OPTION_T='$BAR/baz' +OPTION_U="$OPTION_A/bar" +OPTION_V=$OPTION_A/bar +OPTION_W="$OPTION_A/bar" +OPTION_X=${OPTION_A}/bar +OPTION_Y="${OPTION_A}/bar" +OPTION_Z='$OPTION_A/bar' +OPTION_A1="$OPTION_A/bar/${OPTION_H}/$FOO" +` + +func TestVariableExpansion(t *testing.T) { + err := os.Setenv("FOO", "foo") + if err != nil { + t.Fatalf("unable to set environment variable for testing: %s", err) + } + + env := dotenv.MustParse(TEST_VARIABLE_EXPANSION) + shouldNotHaveEmptyKey(t, env) + + envShouldContain(t, env, "OPTION_A", "foo") + envShouldContain(t, env, "OPTION_B", "foo") + envShouldContain(t, env, "OPTION_C", "foo") + envShouldContain(t, env, "OPTION_D", "foo") + envShouldContain(t, env, "OPTION_E", "$FOO") + envShouldContain(t, env, "OPTION_F", "foo/bar") + envShouldContain(t, env, "OPTION_G", "foo/bar") + envShouldContain(t, env, "OPTION_H", "foo/bar") + envShouldContain(t, env, "OPTION_I", "foo/bar") + envShouldContain(t, env, "OPTION_J", "$FOO/bar") + envShouldContain(t, env, "OPTION_K", "") + envShouldContain(t, env, "OPTION_L", "") + envShouldContain(t, env, "OPTION_M", "") + envShouldContain(t, env, "OPTION_N", "") + envShouldContain(t, env, "OPTION_O", "$BAR") + envShouldContain(t, env, "OPTION_P", "/baz") + envShouldContain(t, env, "OPTION_Q", "/baz") + envShouldContain(t, env, "OPTION_R", "/baz") + envShouldContain(t, env, "OPTION_S", "/baz") + envShouldContain(t, env, "OPTION_T", "$BAR/baz") + envShouldContain(t, env, "OPTION_U", "foo/bar") + envShouldContain(t, env, "OPTION_V", "foo/bar") + envShouldContain(t, env, "OPTION_W", "foo/bar") + envShouldContain(t, env, "OPTION_X", "foo/bar") + envShouldContain(t, env, "OPTION_Y", "foo/bar") + envShouldContain(t, env, "OPTION_Z", "$OPTION_A/bar") + envShouldContain(t, env, "OPTION_A1", "foo/bar/foo/bar/foo") +} + +const TEST_VARIABLE_EXPANSION_WITH_DEFAULTS = ` +OPTION_A="${FOO:-}" +OPTION_B="${FOO:-default}" +OPTION_C='${FOO:-default}' +OPTION_D="${FOO:-default}/bar" +OPTION_E='${FOO:-default}/bar' +OPTION_F="$FOO:-default" +OPTION_G="$BAR:-default" +OPTION_H="${BAR:-}" +OPTION_I="${BAR:-default}" +OPTION_J='${BAR:-default}' +OPTION_K="${BAR:-default}/bar" +OPTION_L='${BAR:-default}/bar' +OPTION_M="${OPTION_A:-}" +OPTION_N="${OPTION_A:-default}" +OPTION_O='${OPTION_A:-default}' +OPTION_P="${OPTION_A:-default}/bar" +OPTION_Q='${OPTION_A:-default}/bar' +OPTION_R="${:-}" +OPTION_S="${BAR:-:-}" +` + +func TestVariableExpansionWithDefaults(t *testing.T) { + err := os.Setenv("FOO", "foo") + if err != nil { + t.Fatalf("unable to set environment variable for testing: %s", err) + } + + env := dotenv.MustParse(TEST_VARIABLE_EXPANSION_WITH_DEFAULTS) + shouldNotHaveEmptyKey(t, env) + + envShouldContain(t, env, "OPTION_A", "foo") + envShouldContain(t, env, "OPTION_B", "foo") + envShouldContain(t, env, "OPTION_C", "${FOO:-default}") + envShouldContain(t, env, "OPTION_D", "foo/bar") + envShouldContain(t, env, "OPTION_E", "${FOO:-default}/bar") + envShouldContain(t, env, "OPTION_F", "foo:-default") + envShouldContain(t, env, "OPTION_G", ":-default") + envShouldContain(t, env, "OPTION_H", "") + envShouldContain(t, env, "OPTION_I", "default") + envShouldContain(t, env, "OPTION_J", "${BAR:-default}") + envShouldContain(t, env, "OPTION_K", "default/bar") + envShouldContain(t, env, "OPTION_L", "${BAR:-default}/bar") + envShouldContain(t, env, "OPTION_M", "foo") + envShouldContain(t, env, "OPTION_N", "foo") + envShouldContain(t, env, "OPTION_O", "${OPTION_A:-default}") + envShouldContain(t, env, "OPTION_P", "foo/bar") + envShouldContain(t, env, "OPTION_Q", "${OPTION_A:-default}/bar") + envShouldContain(t, env, "OPTION_R", "") // this is actually invalid in bash, but what to do here? + envShouldContain(t, env, "OPTION_S", ":-") +}