Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func GetEnvFromFile(currentEnv map[string]string, workingDir string, filename st

file, err := os.Open(dotEnvFile)
if err != nil {
return envMap, err
return envMap, errors.Wrapf(err, "failed to read %s", dotEnvFile)
}
defer file.Close()

Expand All @@ -250,7 +250,7 @@ func GetEnvFromFile(currentEnv map[string]string, workingDir string, filename st
return v, true
})
if err != nil {
return envMap, err
return envMap, errors.Wrapf(err, "failed to read %s", dotEnvFile)
}
for k, v := range env {
envMap[k] = v
Expand Down
8 changes: 7 additions & 1 deletion dotenv/fixtures/invalid1.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
# some comments
foo="
a
multine
value
"
INVALID LINE
foo=bar
zot=qix
106 changes: 1 addition & 105 deletions dotenv/godotenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,9 @@ package dotenv

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"

"github.com/compose-spec/compose-go/template"
Expand Down Expand Up @@ -72,21 +68,6 @@ func Load(filenames ...string) error {
return load(false, filenames...)
}

// Overload will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main).
//
// If you call Overload without any args it will default to loading .env in the current path.
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// godotenv.Overload("fileone", "filetwo")
//
// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefilly set all vars.
func Overload(filenames ...string) error {
return load(true, filenames...)
}

func load(overload bool, filenames ...string) error {
filenames = filenamesOrDefault(filenames)
for _, filename := range filenames {
Expand Down Expand Up @@ -128,82 +109,13 @@ func Read(filenames ...string) (map[string]string, error) {
return ReadWithLookup(nil, filenames...)
}

// Unmarshal reads an env file from a string, returning a map of keys and values.
func Unmarshal(str string) (map[string]string, error) {
return UnmarshalBytes([]byte(str))
}

// UnmarshalBytes parses env file from byte slice of chars, returning a map of keys and values.
func UnmarshalBytes(src []byte) (map[string]string, error) {
return UnmarshalBytesWithLookup(src, nil)
}

// UnmarshalBytesWithLookup parses env file from byte slice of chars, returning a map of keys and values.
func UnmarshalBytesWithLookup(src []byte, lookupFn LookupFn) (map[string]string, error) {
out := make(map[string]string)
err := parseBytes(src, out, lookupFn)
err := newParser().parseBytes(src, out, lookupFn)
return out, err
}

// Exec loads env vars from the specified filenames (empty map falls back to default)
// then executes the cmd specified.
//
// Simply hooks up os.Stdin/err/out to the command and calls Run()
//
// If you want more fine grained control over your command it's recommended
// that you use `Load()` or `Read()` and the `os/exec` package yourself.
//
// Deprecated: Use the `os/exec` package directly.
func Exec(filenames []string, cmd string, cmdArgs []string) error {
if err := Load(filenames...); err != nil {
return err
}

command := exec.Command(cmd, cmdArgs...)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = os.Stderr
return command.Run()
}

// Write serializes the given environment and writes it to a file
//
// Deprecated: The serialization functions are untested and unmaintained.
func Write(envMap map[string]string, filename string) error {
//goland:noinspection GoDeprecation
content, err := Marshal(envMap)
if err != nil {
return err
}
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(content + "\n")
if err != nil {
return err
}
return file.Sync()
}

// Marshal outputs the given environment as a dotenv-formatted environment file.
// Each line is in the format: KEY="VALUE" where VALUE is backslash-escaped.
//
// Deprecated: The serialization functions are untested and unmaintained.
func Marshal(envMap map[string]string) (string, error) {
lines := make([]string, 0, len(envMap))
for k, v := range envMap {
if d, err := strconv.Atoi(v); err == nil {
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
} else {
lines = append(lines, fmt.Sprintf(`%s="%s"`, k, doubleQuoteEscape(v))) // nolint // Cannot use %q here
}
}
sort.Strings(lines)
return strings.Join(lines, "\n"), nil
}

func filenamesOrDefault(filenames []string) []string {
if len(filenames) == 0 {
return []string{".env"}
Expand Down Expand Up @@ -255,19 +167,3 @@ func expandVariables(value string, envMap map[string]string, lookupFn LookupFn)
}
return retVal, nil
}

// Deprecated: only used by unsupported/untested code for Marshal/Write.
func doubleQuoteEscape(line string) string {
const doubleQuoteSpecialChars = "\\\n\r\"!$`"
for _, c := range doubleQuoteSpecialChars {
toReplace := "\\" + string(c)
if c == '\n' {
toReplace = `\n`
}
if c == '\r' {
toReplace = `\r`
}
line = strings.ReplaceAll(line, string(c), toReplace)
}
return line
}
37 changes: 3 additions & 34 deletions dotenv/godotenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,13 @@ func TestLoadWithNoArgsLoadsDotEnv(t *testing.T) {
}
}

func TestOverloadWithNoArgsOverloadsDotEnv(t *testing.T) {
err := Overload()
pathError := err.(*os.PathError)
if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" {
t.Errorf("Didn't try and open .env by default")
}
}

func TestLoadFileNotFound(t *testing.T) {
err := Load("somefilethatwillneverexistever.env")
if err == nil {
t.Error("File wasn't found but Load didn't return an error")
}
}

func TestOverloadFileNotFound(t *testing.T) {
err := Overload("somefilethatwillneverexistever.env")
if err == nil {
t.Error("File wasn't found but Overload didn't return an error")
}
}

func TestReadPlainEnv(t *testing.T) {
envFileName := "fixtures/plain.env"
expectedValues := map[string]string{
Expand Down Expand Up @@ -139,20 +124,6 @@ func TestLoadDoesNotOverride(t *testing.T) {
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, presets)
}

func TestOverloadDoesOverride(t *testing.T) {
envFileName := "fixtures/plain.env"

// ensure NO overload
presets := map[string]string{
"OPTION_A": "do_not_override",
}

expectedValues := map[string]string{
"OPTION_A": "1",
}
loadEnvAndCompareValues(t, Overload, envFileName, expectedValues, presets)
}

func TestLoadPlainEnv(t *testing.T) {
envFileName := "fixtures/plain.env"
expectedValues := map[string]string{
Expand Down Expand Up @@ -494,7 +465,7 @@ func TestLinesToIgnore(t *testing.T) {

for n, c := range cases {
t.Run(n, func(t *testing.T) {
got := string(getStatementStart([]byte(c.input)))
got := string(newParser().getStatementStart([]byte(c.input)))
if got != c.want {
t.Errorf("Expected:\t %q\nGot:\t %q", c.want, got)
}
Expand All @@ -513,10 +484,8 @@ func TestErrorReadDirectory(t *testing.T) {

func TestErrorParsing(t *testing.T) {
envFileName := "fixtures/invalid1.env"
envMap, err := Read(envFileName)
if err == nil {
t.Errorf("Expected error, got %v", envMap)
}
_, err := Read(envFileName)
assert.ErrorContains(t, err, "line 7: key cannot contain a space")
}

func TestInheritedEnvVariableSameSize(t *testing.T) {
Expand Down
46 changes: 31 additions & 15 deletions dotenv/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,34 @@ var (
exportRegex = regexp.MustCompile(`^export\s+`)
)

func parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error {
type parser struct {
line int
}

func newParser() *parser {
return &parser{
line: 1,
}
}

func (p *parser) parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error {
cutset := src
if lookupFn == nil {
lookupFn = noLookupFn
}
for {
cutset = getStatementStart(cutset)
cutset = p.getStatementStart(cutset)
if cutset == nil {
// reached end of file
break
}

key, left, inherited, err := locateKeyName(cutset)
key, left, inherited, err := p.locateKeyName(cutset)
if err != nil {
return err
}
if strings.Contains(key, " ") {
return errors.New("key cannot contain a space")
return fmt.Errorf("line %d: key cannot contain a space", p.line)
}

if inherited {
Expand All @@ -50,7 +60,7 @@ func parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error {
continue
}

value, left, err := extractVarValue(left, out, lookupFn)
value, left, err := p.extractVarValue(left, out, lookupFn)
if err != nil {
return err
}
Expand All @@ -65,8 +75,8 @@ func parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error {
// getStatementPosition returns position of statement begin.
//
// It skips any comment line or non-whitespace character.
func getStatementStart(src []byte) []byte {
pos := indexOfNonSpaceChar(src)
func (p *parser) getStatementStart(src []byte) []byte {
pos := p.indexOfNonSpaceChar(src)
if pos == -1 {
return nil
}
Expand All @@ -81,12 +91,11 @@ func getStatementStart(src []byte) []byte {
if pos == -1 {
return nil
}

return getStatementStart(src[pos:])
return p.getStatementStart(src[pos:])
}

// locateKeyName locates and parses key name and returns rest of slice
func locateKeyName(src []byte) (string, []byte, bool, error) {
func (p *parser) locateKeyName(src []byte) (string, []byte, bool, error) {
var key string
var inherited bool
// trim "export" and space at beginning
Expand Down Expand Up @@ -116,8 +125,8 @@ loop:
}

return "", nil, inherited, fmt.Errorf(
`unexpected character %q in variable name near %q`,
string(char), string(src))
`line %d: unexpected character %q in variable name`,
p.line, string(char))
}
}

Expand All @@ -132,11 +141,12 @@ loop:
}

// extractVarValue extracts variable value and returns rest of slice
func extractVarValue(src []byte, envMap map[string]string, lookupFn LookupFn) (string, []byte, error) {
func (p *parser) extractVarValue(src []byte, envMap map[string]string, lookupFn LookupFn) (string, []byte, error) {
quote, isQuoted := hasQuotePrefix(src)
if !isQuoted {
// unquoted value - read until new line
value, rest, _ := bytes.Cut(src, []byte("\n"))
p.line++

// Remove inline comments on unquoted lines
value, _, _ = bytes.Cut(value, []byte(" #"))
Expand All @@ -147,6 +157,9 @@ func extractVarValue(src []byte, envMap map[string]string, lookupFn LookupFn) (s

// lookup quoted string terminator
for i := 1; i < len(src); i++ {
if src[i] == '\n' {
p.line++
}
if char := src[i]; char != quote {
continue
}
Expand Down Expand Up @@ -177,7 +190,7 @@ func extractVarValue(src []byte, envMap map[string]string, lookupFn LookupFn) (s
valEndIndex = len(src)
}

return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
return "", nil, fmt.Errorf("line %d: unterminated quoted value %s", p.line, src[:valEndIndex])
}

func expandEscapes(str string) string {
Expand Down Expand Up @@ -212,8 +225,11 @@ func expandEscapes(str string) string {
return out
}

func indexOfNonSpaceChar(src []byte) int {
func (p *parser) indexOfNonSpaceChar(src []byte) int {
return bytes.IndexFunc(src, func(r rune) bool {
if r == '\n' {
p.line++
}
return !unicode.IsSpace(r)
})
}
Expand Down
2 changes: 1 addition & 1 deletion loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string, l

fileVars, err := dotenv.ParseWithLookup(bytes.NewBuffer(b), resolve)
if err != nil {
return err
return errors.Wrapf(err, "Failed to load %s", filePath)
}
env := types.MappingWithEquals{}
for k, v := range fileVars {
Expand Down