Repository: compose-spec/compose-go
Pull request #215 (2022-01-03 07:27:46)

import godotenv as a subpackage
This makes https://github.com/compose-spec/godotenv a subpackage in compose-go, as we diverged from original forked joho/godotenv to match compose requirements. This will also makes test and release process simpler

note: MIT license uses by godotenv allows relicensing into Apache. Another option would be to relicense compose-go as MIT.

see https://cloud-native.slack.com/archives/C01162AJ23C/p1639033869024300 for context

mat007: I don’t think we can or should do it quite like that, we probably want at least to retain the original license file and copyright, adding that file to the folder should be enough: https://github.com/joho/godotenv/blob/master/LICENCE
Also what can’t re-license because we’re not the copyright holders.

ndeloof: > we can’t re-license godotenv

we can, this is allowed by MIT license
> Permission is hereby granted to deal in the Software .. without limitation the rights to ... sublicense

but I'm fine to include the original LICENSE file in this subdirectory if you feel this is required
mat007: >> we can’t re-license godotenv

> we can, this is allowed by MIT license

It allows anyone to sublicense, which is a little different 😬 

Patch:
diff --git a/cli/options.go b/cli/options.go
index 85f9cd3..bde6a77 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -24,10 +24,10 @@ import (
 	"regexp"
 	"strings"
 
+	"github.com/compose-spec/compose-go/dotenv"
 	"github.com/compose-spec/compose-go/errdefs"
 	"github.com/compose-spec/compose-go/loader"
 	"github.com/compose-spec/compose-go/types"
-	"github.com/compose-spec/godotenv"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
@@ -220,7 +220,7 @@ func WithDotEnv(o *ProjectOptions) error {
 	defer file.Close()
 
 	notInEnvSet := make(map[string]interface{})
-	env, err := godotenv.ParseWithLookup(file, func(k string) (string, bool) {
+	env, err := dotenv.ParseWithLookup(file, func(k string) (string, bool) {
 		v, ok := os.LookupEnv(k)
 		if !ok {
 			notInEnvSet[k] = nil
diff --git a/dotenv/LICENSE b/dotenv/LICENSE
new file mode 100644
index 0000000..9390caf
--- /dev/null
+++ b/dotenv/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2013 John Barton
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/dotenv/fixtures/equals.env b/dotenv/fixtures/equals.env
new file mode 100644
index 0000000..594c532
--- /dev/null
+++ b/dotenv/fixtures/equals.env
@@ -0,0 +1,2 @@
+export OPTION_A='postgres://localhost:5432/database?sslmode=disable'
+
diff --git a/dotenv/fixtures/exported.env b/dotenv/fixtures/exported.env
new file mode 100644
index 0000000..5821377
--- /dev/null
+++ b/dotenv/fixtures/exported.env
@@ -0,0 +1,2 @@
+export OPTION_A=2
+export OPTION_B='\n'
diff --git a/dotenv/fixtures/inherited-multi-var.env b/dotenv/fixtures/inherited-multi-var.env
new file mode 100644
index 0000000..2a2025e
--- /dev/null
+++ b/dotenv/fixtures/inherited-multi-var.env
@@ -0,0 +1,3 @@
+foo=bar
+VAR_TO_BE_LOADED_FROM_OS_ENV
+bar=baz
diff --git a/dotenv/fixtures/inherited-not-found.env b/dotenv/fixtures/inherited-not-found.env
new file mode 100644
index 0000000..62589e0
--- /dev/null
+++ b/dotenv/fixtures/inherited-not-found.env
@@ -0,0 +1,1 @@
+VARIABLE_NOT_FOUND
diff --git a/dotenv/fixtures/inherited-single-var.env b/dotenv/fixtures/inherited-single-var.env
new file mode 100644
index 0000000..6803be6
--- /dev/null
+++ b/dotenv/fixtures/inherited-single-var.env
@@ -0,0 +1,1 @@
+VAR_TO_BE_LOADED_FROM_OS_ENV
diff --git a/dotenv/fixtures/invalid1.env b/dotenv/fixtures/invalid1.env
new file mode 100644
index 0000000..38f7e0e
--- /dev/null
+++ b/dotenv/fixtures/invalid1.env
@@ -0,0 +1,2 @@
+INVALID LINE
+foo=bar
diff --git a/dotenv/fixtures/plain.env b/dotenv/fixtures/plain.env
new file mode 100644
index 0000000..43f7e44
--- /dev/null
+++ b/dotenv/fixtures/plain.env
@@ -0,0 +1,7 @@
+OPTION_A=1
+OPTION_B=2
+OPTION_C= 3
+OPTION_D =4
+OPTION_E = 5
+OPTION_F = 
+OPTION_G=
\ No newline at end of file
diff --git a/dotenv/fixtures/quoted.env b/dotenv/fixtures/quoted.env
new file mode 100644
index 0000000..7c4c19c
--- /dev/null
+++ b/dotenv/fixtures/quoted.env
@@ -0,0 +1,14 @@
+OPTION_A='1'
+OPTION_B='2'
+OPTION_C=''
+OPTION_D='\n'
+OPTION_E="1"
+OPTION_F="2"
+OPTION_G=""
+OPTION_H="\n"
+OPTION_I = "echo 'asd'"
+OPTION_J = 'first line
+second line
+third line
+and so on'
+OPTION_Z = "last value"
diff --git a/dotenv/fixtures/substitutions.env b/dotenv/fixtures/substitutions.env
new file mode 100644
index 0000000..44337a9
--- /dev/null
+++ b/dotenv/fixtures/substitutions.env
@@ -0,0 +1,5 @@
+OPTION_A=1
+OPTION_B=${OPTION_A}
+OPTION_C=$OPTION_B
+OPTION_D=${OPTION_A}${OPTION_B}
+OPTION_E=${OPTION_NOT_DEFINED}
diff --git a/dotenv/fixtures/unquoted.env b/dotenv/fixtures/unquoted.env
new file mode 100644
index 0000000..f83a4b6
--- /dev/null
+++ b/dotenv/fixtures/unquoted.env
@@ -0,0 +1,5 @@
+OPTION_A = "some quoted phrase"
+OPTION_B=first one with an unquoted phrase
+OPTION_C = then another one with an unquoted phrase
+OPTION_D = then another one with an unquoted phrase special è char
+OPTION_E = "then another one quoted phrase"
diff --git a/dotenv/godotenv.go b/dotenv/godotenv.go
new file mode 100644
index 0000000..479831a
--- /dev/null
+++ b/dotenv/godotenv.go
@@ -0,0 +1,375 @@
+// Package dotenv is a go port of the ruby dotenv library (https://github.com/bkeepers/dotenv)
+//
+// Examples/readme can be found on the github page at https://github.com/joho/godotenv
+//
+// The TL;DR is that you make a .env file that looks something like
+//
+// 		SOME_ENV_VAR=somevalue
+//
+// and then in your go code you can call
+//
+// 		godotenv.Load()
+//
+// and all the env vars declared in .env will be available through os.Getenv("SOME_ENV_VAR")
+package dotenv
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+const doubleQuoteSpecialChars = "\\\n\r\"!$`"
+
+// LookupFn represents a lookup function to resolve variables from
+type LookupFn func(string) (string, bool)
+
+var noLookupFn = func(s string) (string, bool) {
+	return "", false
+}
+
+// Parse reads an env file from io.Reader, returning a map of keys and values.
+func Parse(r io.Reader) (map[string]string, error) {
+	return ParseWithLookup(r, nil)
+}
+
+// ParseWithLookup reads an env file from io.Reader, returning a map of keys and values.
+func ParseWithLookup(r io.Reader, lookupFn LookupFn) (map[string]string, error) {
+	data, err := ioutil.ReadAll(r)
+	if err != nil {
+		return nil, err
+	}
+
+	return UnmarshalBytesWithLookup(data, lookupFn)
+}
+
+// Load 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 Load 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.Load("fileone", "filetwo")
+//
+// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults
+func Load(filenames ...string) (err 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) (err error) {
+	return load(true, filenames...)
+}
+
+func load(overload bool, filenames ...string) (err error) {
+	filenames = filenamesOrDefault(filenames)
+
+	for _, filename := range filenames {
+		err = loadFile(filename, overload)
+		if err != nil {
+			return // return early on a spazout
+		}
+	}
+	return
+}
+
+// ReadWithLookup gets all env vars from the files and/or lookup function and return values as
+// a map rather than automatically writing values into env
+func ReadWithLookup(lookupFn LookupFn, filenames ...string) (envMap map[string]string, err error) {
+	filenames = filenamesOrDefault(filenames)
+	envMap = make(map[string]string)
+
+	for _, filename := range filenames {
+		individualEnvMap, individualErr := readFile(filename, lookupFn)
+
+		if individualErr != nil {
+			err = individualErr
+			return // return early on a spazout
+		}
+
+		for key, value := range individualEnvMap {
+			envMap[key] = value
+		}
+	}
+
+	return
+}
+
+// Read all env (with same file loading semantics as Load) but return values as
+// a map rather than automatically writing values into env
+func Read(filenames ...string) (envMap map[string]string, err error) {
+	return ReadWithLookup(nil, filenames...)
+}
+
+// Unmarshal reads an env file from a string, returning a map of keys and values.
+func Unmarshal(str string) (envMap map[string]string, err 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)
+	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.
+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
+func Write(envMap map[string]string, filename string) error {
+	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.
+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)))
+		}
+	}
+	sort.Strings(lines)
+	return strings.Join(lines, "\n"), nil
+}
+
+func filenamesOrDefault(filenames []string) []string {
+	if len(filenames) == 0 {
+		return []string{".env"}
+	}
+	return filenames
+}
+
+func loadFile(filename string, overload bool) error {
+	envMap, err := readFile(filename, nil)
+	if err != nil {
+		return err
+	}
+
+	currentEnv := map[string]bool{}
+	rawEnv := os.Environ()
+	for _, rawEnvLine := range rawEnv {
+		key := strings.Split(rawEnvLine, "=")[0]
+		currentEnv[key] = true
+	}
+
+	for key, value := range envMap {
+		if !currentEnv[key] || overload {
+			_ = os.Setenv(key, value)
+		}
+	}
+
+	return nil
+}
+
+func readFile(filename string, lookupFn LookupFn) (envMap map[string]string, err error) {
+	file, err := os.Open(filename)
+	if err != nil {
+		return
+	}
+	defer file.Close()
+
+	return ParseWithLookup(file, lookupFn)
+}
+
+var exportRegex = regexp.MustCompile(`^\s*(?:export\s+)?(.*?)\s*$`)
+
+func parseLine(line string, envMap map[string]string) (key string, value string, err error) {
+	return parseLineWithLookup(line, envMap, nil)
+}
+func parseLineWithLookup(line string, envMap map[string]string, lookupFn LookupFn) (key string, value string, err error) {
+	if len(line) == 0 {
+		err = errors.New("zero length string")
+		return
+	}
+
+	// ditch the comments (but keep quoted hashes)
+	if strings.Contains(line, "#") {
+		segmentsBetweenHashes := strings.Split(line, "#")
+		quotesAreOpen := false
+		var segmentsToKeep []string
+		for _, segment := range segmentsBetweenHashes {
+			if strings.Count(segment, "\"") == 1 || strings.Count(segment, "'") == 1 {
+				if quotesAreOpen {
+					quotesAreOpen = false
+					segmentsToKeep = append(segmentsToKeep, segment)
+				} else {
+					quotesAreOpen = true
+				}
+			}
+
+			if len(segmentsToKeep) == 0 || quotesAreOpen {
+				segmentsToKeep = append(segmentsToKeep, segment)
+			}
+		}
+
+		line = strings.Join(segmentsToKeep, "#")
+	}
+
+	firstEquals := strings.Index(line, "=")
+	firstColon := strings.Index(line, ":")
+	splitString := strings.SplitN(line, "=", 2)
+	if firstColon != -1 && (firstColon < firstEquals || firstEquals == -1) {
+		// This is a yaml-style line
+		splitString = strings.SplitN(line, ":", 2)
+	}
+
+	if len(splitString) != 2 {
+		err = errors.New("can't separate key from value")
+		return
+	}
+	key = exportRegex.ReplaceAllString(splitString[0], "$1")
+
+	// Parse the value
+	value = parseValue(splitString[1], envMap, lookupFn)
+	return
+}
+
+var (
+	singleQuotesRegex  = regexp.MustCompile(`\A'(.*)'\z`)
+	doubleQuotesRegex  = regexp.MustCompile(`\A"(.*)"\z`)
+	escapeRegex        = regexp.MustCompile(`\\.`)
+	unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
+)
+
+func parseValue(value string, envMap map[string]string, lookupFn LookupFn) string {
+
+	// trim
+	value = strings.Trim(value, " ")
+
+	// check if we've got quoted values or possible escapes
+	if len(value) > 1 {
+		singleQuotes := singleQuotesRegex.FindStringSubmatch(value)
+
+		doubleQuotes := doubleQuotesRegex.FindStringSubmatch(value)
+
+		if singleQuotes != nil || doubleQuotes != nil {
+			// pull the quotes off the edges
+			value = value[1 : len(value)-1]
+		}
+
+		if doubleQuotes != nil {
+			// expand newlines
+			value = escapeRegex.ReplaceAllStringFunc(value, func(match string) string {
+				c := strings.TrimPrefix(match, `\`)
+				switch c {
+				case "n":
+					return "\n"
+				case "r":
+					return "\r"
+				default:
+					return match
+				}
+			})
+			// unescape characters
+			value = unescapeCharsRegex.ReplaceAllString(value, "$1")
+		}
+
+		if singleQuotes == nil {
+			value = expandVariables(value, envMap, lookupFn)
+		}
+	}
+
+	return value
+}
+
+var expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
+
+func expandVariables(v string, envMap map[string]string, lookupFn LookupFn) string {
+	return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
+		submatch := expandVarRegex.FindStringSubmatch(s)
+
+		if submatch == nil {
+			return s
+		}
+		if submatch[1] == "\\" || submatch[2] == "(" {
+			return submatch[0][1:]
+		} else if submatch[4] != "" {
+			// first check if we have defined this already earlier
+			if envMap[submatch[4]] != "" {
+				return envMap[submatch[4]]
+			}
+			if lookupFn == nil {
+				return ""
+			}
+			// if we have not defined it, check the lookup function provided
+			// by the user
+			s2, ok := lookupFn(submatch[4])
+			if ok {
+				return s2
+			}
+			return ""
+		}
+		return s
+	})
+}
+
+func doubleQuoteEscape(line string) string {
+	for _, c := range doubleQuoteSpecialChars {
+		toReplace := "\\" + string(c)
+		if c == '\n' {
+			toReplace = `\n`
+		}
+		if c == '\r' {
+			toReplace = `\r`
+		}
+		line = strings.Replace(line, string(c), toReplace, -1)
+	}
+	return line
+}
diff --git a/dotenv/parser.go b/dotenv/parser.go
new file mode 100644
index 0000000..e657c8d
--- /dev/null
+++ b/dotenv/parser.go
@@ -0,0 +1,234 @@
+package dotenv
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"strings"
+	"unicode"
+)
+
+const (
+	charComment       = '#'
+	prefixSingleQuote = '\''
+	prefixDoubleQuote = '"'
+
+	exportPrefix = "export"
+)
+
+func parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error {
+	cutset := src
+	for {
+		cutset = getStatementStart(cutset)
+		if cutset == nil {
+			// reached end of file
+			break
+		}
+
+		key, left, inherited, err := locateKeyName(cutset)
+		if err != nil {
+			return err
+		}
+		if strings.Contains(key, " ") {
+			return errors.New("key cannot contain a space")
+		}
+
+		if inherited {
+			if lookupFn == nil {
+				lookupFn = noLookupFn
+			}
+
+			value, ok := lookupFn(key)
+			if ok {
+				out[key] = value
+				cutset = left
+				continue
+			}
+		}
+
+		value, left, err := extractVarValue(left, out, lookupFn)
+		if err != nil {
+			return err
+		}
+
+		out[key] = value
+		cutset = left
+	}
+
+	return nil
+}
+
+// getStatementPosition returns position of statement begin.
+//
+// It skips any comment line or non-whitespace character.
+func getStatementStart(src []byte) []byte {
+	pos := indexOfNonSpaceChar(src)
+	if pos == -1 {
+		return nil
+	}
+
+	src = src[pos:]
+	if src[0] != charComment {
+		return src
+	}
+
+	// skip comment section
+	pos = bytes.IndexFunc(src, isCharFunc('\n'))
+	if pos == -1 {
+		return nil
+	}
+
+	return getStatementStart(src[pos:])
+}
+
+// locateKeyName locates and parses key name and returns rest of slice
+func locateKeyName(src []byte) (key string, cutset []byte, inherited bool, err error) {
+	// trim "export" and space at beginning
+	src = bytes.TrimLeftFunc(bytes.TrimPrefix(src, []byte(exportPrefix)), isSpace)
+
+	// locate key name end and validate it in single loop
+	offset := 0
+loop:
+	for i, char := range src {
+		rchar := rune(char)
+		if isSpace(rchar) {
+			continue
+		}
+
+		switch char {
+		case '=', ':', '\n':
+			// library also supports yaml-style value declaration
+			key = string(src[0:i])
+			offset = i + 1
+			inherited = char == '\n'
+			break loop
+		case '_':
+		default:
+			// variable name should match [A-Za-z0-9_]
+			if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) {
+				continue
+			}
+
+			return "", nil, inherited, fmt.Errorf(
+				`unexpected character %q in variable name near %q`,
+				string(char), string(src))
+		}
+	}
+
+	if len(src) == 0 {
+		return "", nil, inherited, errors.New("zero length string")
+	}
+
+	// trim whitespace
+	key = strings.TrimRightFunc(key, unicode.IsSpace)
+	cutset = bytes.TrimLeftFunc(src[offset:], isSpace)
+	return key, cutset, inherited, nil
+}
+
+// extractVarValue extracts variable value and returns rest of slice
+func extractVarValue(src []byte, envMap map[string]string, lookupFn LookupFn) (value string, rest []byte, err error) {
+	quote, isQuoted := hasQuotePrefix(src)
+	if !isQuoted {
+		// unquoted value - read until new line
+		end := bytes.IndexFunc(src, isNewLine)
+		var rest []byte
+		var value string
+		if end < 0 {
+			value := strings.TrimRightFunc(string(src), unicode.IsSpace)
+			rest = nil
+			return expandVariables(value, envMap, lookupFn), rest, nil
+		}
+
+		value = strings.TrimRightFunc(string(src[0:end]), unicode.IsSpace)
+		rest = src[end:]
+		return expandVariables(value, envMap, lookupFn), rest, nil
+	}
+
+	// lookup quoted string terminator
+	for i := 1; i < len(src); i++ {
+		if char := src[i]; char != quote {
+			continue
+		}
+
+		// skip escaped quote symbol (\" or \', depends on quote)
+		if prevChar := src[i-1]; prevChar == '\\' {
+			continue
+		}
+
+		// trim quotes
+		trimFunc := isCharFunc(rune(quote))
+		value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc))
+		if quote == prefixDoubleQuote {
+			// unescape newlines for double quote (this is compat feature)
+			// and expand environment variables
+			value = expandVariables(expandEscapes(value), envMap, lookupFn)
+		}
+
+		return value, src[i+1:], nil
+	}
+
+	// return formatted error if quoted string is not terminated
+	valEndIndex := bytes.IndexFunc(src, isCharFunc('\n'))
+	if valEndIndex == -1 {
+		valEndIndex = len(src)
+	}
+
+	return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
+}
+
+func expandEscapes(str string) string {
+	out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string {
+		c := strings.TrimPrefix(match, `\`)
+		switch c {
+		case "n":
+			return "\n"
+		case "r":
+			return "\r"
+		default:
+			return match
+		}
+	})
+	return unescapeCharsRegex.ReplaceAllString(out, "$1")
+}
+
+func indexOfNonSpaceChar(src []byte) int {
+	return bytes.IndexFunc(src, func(r rune) bool {
+		return !unicode.IsSpace(r)
+	})
+}
+
+// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
+func hasQuotePrefix(src []byte) (quote byte, isQuoted bool) {
+	if len(src) == 0 {
+		return 0, false
+	}
+
+	switch prefix := src[0]; prefix {
+	case prefixDoubleQuote, prefixSingleQuote:
+		return prefix, true
+	default:
+		return 0, false
+	}
+}
+
+func isCharFunc(char rune) func(rune) bool {
+	return func(v rune) bool {
+		return v == char
+	}
+}
+
+// isSpace reports whether the rune is a space character but not line break character
+//
+// this differs from unicode.IsSpace, which also applies line break as space
+func isSpace(r rune) bool {
+	switch r {
+	case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
+		return true
+	}
+	return false
+}
+
+// isNewLine reports whether the rune is a new line character
+func isNewLine(r rune) bool {
+	return r == '\n'
+}
diff --git a/go.mod b/go.mod
index 25f793e..ddbd482 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,6 @@ module github.com/compose-spec/compose-go
 go 1.16
 
 require (
-	github.com/compose-spec/godotenv v1.1.1
 	github.com/distribution/distribution/v3 v3.0.0-20210316161203-a01c71e2477e
 	github.com/docker/go-connections v0.4.0
 	github.com/docker/go-units v0.4.0
diff --git a/go.sum b/go.sum
index e6677bf..a51dac7 100644
--- a/go.sum
+++ b/go.sum
@@ -14,8 +14,6 @@ github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR
 github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
 github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
 github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
-github.com/compose-spec/godotenv v1.1.1 h1:lp+WpAInnw06YN9sV/XLUOV/9z4C+6wjJdWlrdVac7o=
-github.com/compose-spec/godotenv v1.1.1/go.mod h1:zF/3BOa18Z24tts5qnO/E9YURQanJTBUf7nlcCTNsyc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
diff --git a/loader/loader.go b/loader/loader.go
index b55b005..0093851 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -27,11 +27,11 @@ import (
 	"strings"
 	"time"
 
+	"github.com/compose-spec/compose-go/dotenv"
 	interp "github.com/compose-spec/compose-go/interpolation"
 	"github.com/compose-spec/compose-go/schema"
 	"github.com/compose-spec/compose-go/template"
 	"github.com/compose-spec/compose-go/types"
-	"github.com/compose-spec/godotenv"
 	"github.com/docker/go-units"
 	"github.com/mattn/go-shellwords"
 	"github.com/mitchellh/mapstructure"
@@ -559,7 +559,7 @@ func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string, l
 				return err
 			}
 			defer file.Close()
-			fileVars, err := godotenv.ParseWithLookup(file, godotenv.LookupFn(lookupEnv))
+			fileVars, err := dotenv.ParseWithLookup(file, dotenv.LookupFn(lookupEnv))
 			if err != nil {
 				return err
 			}
diff --git a/scripts/validate/fileheader b/scripts/validate/fileheader
index 72e8e37..ce32e87 100755
--- a/scripts/validate/fileheader
+++ b/scripts/validate/fileheader
@@ -25,4 +25,4 @@ fi
 
 BASEPATH="${1-}"
 
-ltag -t "${BASEPATH}scripts/validate/template" --excludes "validate" --check -v
+ltag -t "${BASEPATH}scripts/validate/template" --excludes "validate dotenv" --check -v


Test patch:
diff --git a/dotenv/godotenv_test.go b/dotenv/godotenv_test.go
new file mode 100644
index 0000000..7fa718f
--- /dev/null
+++ b/dotenv/godotenv_test.go
@@ -0,0 +1,613 @@
+package dotenv
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+var noopPresets = make(map[string]string)
+
+func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) {
+	key, value, _ := parseLine(rawEnvLine, noopPresets)
+	if key != expectedKey || value != expectedValue {
+		t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, expectedKey, expectedValue, key, value)
+	}
+}
+
+func loadEnvAndCompareValues(t *testing.T, loader func(files ...string) error, envFileName string, expectedValues map[string]string, presets map[string]string) {
+	// first up, clear the env
+	os.Clearenv()
+
+	for k, v := range presets {
+		os.Setenv(k, v)
+	}
+
+	err := loader(envFileName)
+	if err != nil {
+		t.Fatalf("Error loading %v", envFileName)
+	}
+
+	for k, expected := range expectedValues {
+		actual := strings.ReplaceAll(os.Getenv(k), "\r\n", "\n")
+		if actual != expected {
+			t.Errorf("Mismatch for key '%v': expected '%v' got '%v'", k, expected, actual)
+		}
+	}
+}
+
+func TestLoadWithNoArgsLoadsDotEnv(t *testing.T) {
+	err := Load()
+	pathError := err.(*os.PathError)
+	if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" {
+		t.Errorf("Didn't try and open .env by default")
+	}
+}
+
+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{
+		"OPTION_A": "1",
+		"OPTION_B": "2",
+		"OPTION_C": "3",
+		"OPTION_D": "4",
+		"OPTION_E": "5",
+		"OPTION_F": "",
+		"OPTION_G": "",
+	}
+
+	envMap, err := Read(envFileName)
+	if err != nil {
+		t.Error("Error reading file")
+	}
+
+	if len(envMap) != len(expectedValues) {
+		t.Error("Didn't get the right size map back")
+	}
+
+	for key, value := range expectedValues {
+		if envMap[key] != value {
+			t.Error("Read got one of the keys wrong")
+		}
+	}
+}
+
+func TestParse(t *testing.T) {
+	envMap, err := Parse(bytes.NewReader([]byte("ONE=1\nTWO='2'\nTHREE = \"3\"")))
+	expectedValues := map[string]string{
+		"ONE":   "1",
+		"TWO":   "2",
+		"THREE": "3",
+	}
+	if err != nil {
+		t.Fatalf("error parsing env: %v", err)
+	}
+	for key, value := range expectedValues {
+		if envMap[key] != value {
+			t.Errorf("expected %s to be %s, got %s", key, value, envMap[key])
+		}
+	}
+}
+
+func TestLoadDoesNotOverride(t *testing.T) {
+	envFileName := "fixtures/plain.env"
+
+	// ensure NO overload
+	presets := map[string]string{
+		"OPTION_A": "do_not_override",
+		"OPTION_B": "",
+	}
+
+	expectedValues := map[string]string{
+		"OPTION_A": "do_not_override",
+		"OPTION_B": "",
+	}
+	loadEnvAndCompareValues(t, Load, envFileName, expectedValues, presets)
+}
+
+func TestOveroadDoesOverride(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{
+		"OPTION_A": "1",
+		"OPTION_B": "2",
+		"OPTION_C": "3",
+		"OPTION_D": "4",
+		"OPTION_E": "5",
+	}
+
+	loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
+}
+
+func TestLoadExportedEnv(t *testing.T) {
+	envFileName := "fixtures/exported.env"
+	expectedValues := map[string]string{
+		"OPTION_A": "2",
+		"OPTION_B": "\\n",
+	}
+
+	loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
+}
+
+func TestLoadEqualsEnv(t *testing.T) {
+	envFileName := "fixtures/equals.env"
+	expectedValues := map[string]string{
+		"OPTION_A": "postgres://localhost:5432/database?sslmode=disable",
+	}
+
+	loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
+}
+
+func TestLoadQuotedEnv(t *testing.T) {
+	envFileName := "fixtures/quoted.env"
+	expectedValues := map[string]string{
+		"OPTION_A": "1",
+		"OPTION_B": "2",
+		"OPTION_C": "",
+		"OPTION_D": "\\n",
+		"OPTION_E": "1",
+		"OPTION_F": "2",
+		"OPTION_G": "",
+		"OPTION_H": "\n",
+		"OPTION_I": "echo 'asd'",
+		"OPTION_J": `first line
+second line
+third line
+and so on`,
+		"OPTION_Z": "last value",
+	}
+
+	loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
+}
+
+func TestLoadUnquotedEnv(t *testing.T) {
+	envFileName := "fixtures/unquoted.env"
+	expectedValues := map[string]string{
+		"OPTION_A": "some quoted phrase",
+		"OPTION_B": "first one with an unquoted phrase",
+		"OPTION_C": "then another one with an unquoted phrase",
+		"OPTION_D": "then another one with an unquoted phrase special è char",
+		"OPTION_E": "then another one quoted phrase",
+	}
+
+	loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
+}
+
+func TestSubstitutions(t *testing.T) {
+	envFileName := "fixtures/substitutions.env"
+	expectedValues := map[string]string{
+		"OPTION_A": "1",
+		"OPTION_B": "1",
+		"OPTION_C": "1",
+		"OPTION_D": "11",
+		"OPTION_E": "",
+	}
+
+	loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
+}
+
+func TestExpanding(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected map[string]string
+	}{
+		{
+			"expands variables found in values",
+			"FOO=test\nBAR=$FOO",
+			map[string]string{"FOO": "test", "BAR": "test"},
+		},
+		{
+			"parses variables wrapped in brackets",
+			"FOO=test\nBAR=${FOO}bar",
+			map[string]string{"FOO": "test", "BAR": "testbar"},
+		},
+		{
+			"expands undefined variables to an empty string",
+			"BAR=$FOO",
+			map[string]string{"BAR": ""},
+		},
+		{
+			"expands variables in double quoted strings",
+			"FOO=test\nBAR=\"quote $FOO\"",
+			map[string]string{"FOO": "test", "BAR": "quote test"},
+		},
+		{
+			"does not expand variables in single quoted strings",
+			"BAR='quote $FOO'",
+			map[string]string{"BAR": "quote $FOO"},
+		},
+		{
+			"does not expand escaped variables",
+			`FOO="foo\$BAR"`,
+			map[string]string{"FOO": "foo$BAR"},
+		},
+		{
+			"does not expand escaped variables",
+			`FOO="foo\${BAR}"`,
+			map[string]string{"FOO": "foo${BAR}"},
+		},
+		{
+			"does not expand escaped variables",
+			"FOO=test\nBAR=\"foo\\${FOO} ${FOO}\"",
+			map[string]string{"FOO": "test", "BAR": "foo${FOO} test"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			env, err := Parse(strings.NewReader(tt.input))
+			if err != nil {
+				t.Errorf("Error: %s", err.Error())
+			}
+			for k, v := range tt.expected {
+				if strings.Compare(env[k], v) != 0 {
+					t.Errorf("Expected: %s, Actual: %s", v, env[k])
+				}
+			}
+		})
+	}
+
+}
+
+func TestVariableStringValueSeparator(t *testing.T) {
+	input := "TEST_URLS=\"stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443\""
+	want := map[string]string{
+		"TEST_URLS": "stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443",
+	}
+	got, err := Parse(strings.NewReader(input))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(got) != len(want) {
+		t.Fatalf(
+			"unexpected value:\nwant:\n\t%#v\n\ngot:\n\t%#v", want, got)
+	}
+
+	for k, wantVal := range want {
+		gotVal, ok := got[k]
+		if !ok {
+			t.Fatalf("key %q doesn't present in result", k)
+		}
+		if wantVal != gotVal {
+			t.Fatalf(
+				"mismatch in %q value:\nwant:\n\t%s\n\ngot:\n\t%s", k,
+				wantVal, gotVal)
+		}
+	}
+}
+
+func TestActualEnvVarsAreLeftAlone(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("OPTION_A", "actualenv")
+	_ = Load("fixtures/plain.env")
+
+	if os.Getenv("OPTION_A") != "actualenv" {
+		t.Error("An ENV var set earlier was overwritten")
+	}
+}
+
+func TestParsing(t *testing.T) {
+	// unquoted values
+	parseAndCompare(t, "FOO=bar", "FOO", "bar")
+
+	// parses values with spaces around equal sign
+	parseAndCompare(t, "FOO =bar", "FOO", "bar")
+	parseAndCompare(t, "FOO= bar", "FOO", "bar")
+
+	// parses double quoted values
+	parseAndCompare(t, `FOO="bar"`, "FOO", "bar")
+
+	// parses single quoted values
+	parseAndCompare(t, "FOO='bar'", "FOO", "bar")
+
+	// parses escaped double quotes
+	parseAndCompare(t, `FOO="escaped\"bar"`, "FOO", `escaped"bar`)
+
+	// parses single quotes inside double quotes
+	parseAndCompare(t, `FOO="'d'"`, "FOO", `'d'`)
+
+	// parses yaml style options
+	parseAndCompare(t, "OPTION_A: 1", "OPTION_A", "1")
+
+	//parses yaml values with equal signs
+	parseAndCompare(t, "OPTION_A: Foo=bar", "OPTION_A", "Foo=bar")
+
+	// parses non-yaml options with colons
+	parseAndCompare(t, "OPTION_A=1:B", "OPTION_A", "1:B")
+
+	// parses export keyword
+	parseAndCompare(t, "export OPTION_A=2", "OPTION_A", "2")
+	parseAndCompare(t, `export OPTION_B='\n'`, "OPTION_B", "\\n")
+	parseAndCompare(t, "export exportFoo=2", "exportFoo", "2")
+	parseAndCompare(t, "exportFOO=2", "exportFOO", "2")
+	parseAndCompare(t, "export_FOO =2", "export_FOO", "2")
+	parseAndCompare(t, "export.FOO= 2", "export.FOO", "2")
+	parseAndCompare(t, "export\tOPTION_A=2", "OPTION_A", "2")
+	parseAndCompare(t, "  export OPTION_A=2", "OPTION_A", "2")
+	parseAndCompare(t, "\texport OPTION_A=2", "OPTION_A", "2")
+
+	// it 'expands newlines in quoted strings' do
+	// expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz")
+	parseAndCompare(t, `FOO="bar\nbaz"`, "FOO", "bar\nbaz")
+
+	// it 'parses varibales with "." in the name' do
+	// expect(env('FOO.BAR=foobar')).to eql('FOO.BAR' => 'foobar')
+	parseAndCompare(t, "FOO.BAR=foobar", "FOO.BAR", "foobar")
+
+	// it 'parses varibales with several "=" in the value' do
+	// expect(env('FOO=foobar=')).to eql('FOO' => 'foobar=')
+	parseAndCompare(t, "FOO=foobar=", "FOO", "foobar=")
+
+	// it 'strips unquoted values' do
+	// expect(env('foo=bar ')).to eql('foo' => 'bar') # not 'bar '
+	parseAndCompare(t, "FOO=bar ", "FOO", "bar")
+
+	// it 'ignores inline comments' do
+	// expect(env("foo=bar # this is foo")).to eql('foo' => 'bar')
+	parseAndCompare(t, "FOO=bar # this is foo", "FOO", "bar")
+
+	// it 'allows # in quoted value' do
+	// expect(env('foo="bar#baz" # comment')).to eql('foo' => 'bar#baz')
+	parseAndCompare(t, `FOO="bar#baz" # comment`, "FOO", "bar#baz")
+	parseAndCompare(t, "FOO='bar#baz' # comment", "FOO", "bar#baz")
+	parseAndCompare(t, `FOO="bar#baz#bang" # comment`, "FOO", "bar#baz#bang")
+
+	// it 'parses # in quoted values' do
+	// expect(env('foo="ba#r"')).to eql('foo' => 'ba#r')
+	// expect(env("foo='ba#r'")).to eql('foo' => 'ba#r')
+	parseAndCompare(t, `FOO="ba#r"`, "FOO", "ba#r")
+	parseAndCompare(t, "FOO='ba#r'", "FOO", "ba#r")
+
+	//newlines and backslashes should be escaped
+	parseAndCompare(t, `FOO="bar\n\ b\az"`, "FOO", "bar\n baz")
+	parseAndCompare(t, `FOO="bar\\\n\ b\az"`, "FOO", "bar\\\n baz")
+	parseAndCompare(t, `FOO="bar\\r\ b\az"`, "FOO", "bar\\r baz")
+
+	parseAndCompare(t, `="value"`, "", "value")
+	parseAndCompare(t, `KEY="`, "KEY", "\"")
+	parseAndCompare(t, `KEY="value`, "KEY", "\"value")
+
+	// leading whitespace should be ignored
+	parseAndCompare(t, " KEY =value", "KEY", "value")
+	parseAndCompare(t, "   KEY=value", "KEY", "value")
+	parseAndCompare(t, "\tKEY=value", "KEY", "value")
+
+	// it 'throws an error if line format is incorrect' do
+	// expect{env('lol$wut')}.to raise_error(Dotenv::FormatError)
+	badlyFormattedLine := "lol$wut"
+	_, _, err := parseLine(badlyFormattedLine, noopPresets)
+	if err == nil {
+		t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine)
+	}
+}
+
+func TestLinesToIgnore(t *testing.T) {
+	cases := map[string]struct {
+		input string
+		want  string
+	}{
+		"Line with nothing but line break": {
+			input: "\n",
+		},
+		"Line with nothing but windows-style line break": {
+			input: "\r\n",
+		},
+		"Line full of whitespace": {
+			input: "\t\t ",
+		},
+		"Comment": {
+			input: "# Comment",
+		},
+		"Indented comment": {
+			input: "\t # comment",
+		},
+		"non-ignored value": {
+			input: `export OPTION_B='\n'`,
+			want:  `export OPTION_B='\n'`,
+		},
+	}
+
+	for n, c := range cases {
+		t.Run(n, func(t *testing.T) {
+			got := string(getStatementStart([]byte(c.input)))
+			if got != c.want {
+				t.Errorf("Expected:\t %q\nGot:\t %q", c.want, got)
+			}
+		})
+	}
+}
+
+func TestErrorReadDirectory(t *testing.T) {
+	envFileName := "fixtures/"
+	envMap, err := Read(envFileName)
+
+	if err == nil {
+		t.Errorf("Expected error, got %v", envMap)
+	}
+}
+
+func TestErrorParsing(t *testing.T) {
+	envFileName := "fixtures/invalid1.env"
+	envMap, err := Read(envFileName)
+	if err == nil {
+		t.Errorf("Expected error, got %v", envMap)
+	}
+}
+
+func TestWrite(t *testing.T) {
+	writeAndCompare := func(env string, expected string) {
+		envMap, _ := Unmarshal(env)
+		actual, _ := Marshal(envMap)
+		if expected != actual {
+			t.Errorf("Expected '%v' (%v) to write as '%v', got '%v' instead.", env, envMap, expected, actual)
+		}
+	}
+	//just test some single lines to show the general idea
+	//TestRoundtrip makes most of the good assertions
+
+	//values are always double-quoted
+	writeAndCompare(`key=value`, `key="value"`)
+	//double-quotes are escaped
+	writeAndCompare(`key=va"lu"e`, `key="va\"lu\"e"`)
+	//but single quotes are left alone
+	writeAndCompare(`key=va'lu'e`, `key="va'lu'e"`)
+	// newlines, backslashes, and some other special chars are escaped
+	writeAndCompare(`foo="\n\r\\r!"`, `foo="\n\r\\r\!"`)
+	// lines should be sorted
+	writeAndCompare("foo=bar\nbaz=buzz", "baz=\"buzz\"\nfoo=\"bar\"")
+	// integers should not be quoted
+	writeAndCompare(`key="10"`, `key=10`)
+
+}
+
+func TestRoundtrip(t *testing.T) {
+	fixtures := []string{"equals.env", "exported.env", "plain.env", "quoted.env"}
+	for _, fixture := range fixtures {
+		fixtureFilename := fmt.Sprintf("fixtures/%s", fixture)
+		env, err := readFile(fixtureFilename, nil)
+		if err != nil {
+			t.Errorf("Expected '%s' to read without error (%v)", fixtureFilename, err)
+		}
+		rep, err := Marshal(env)
+		if err != nil {
+			t.Errorf("Expected '%s' to Marshal (%v)", fixtureFilename, err)
+		}
+		roundtripped, err := Unmarshal(rep)
+		if err != nil {
+			t.Errorf("Expected '%s' to Mashal and Unmarshal (%v)", fixtureFilename, err)
+		}
+		if !reflect.DeepEqual(env, roundtripped) {
+			t.Errorf("Expected '%s' to roundtrip as '%v', got '%v' instead", fixtureFilename, env, roundtripped)
+		}
+
+	}
+}
+
+func TestInheritedEnvVariablSameSize(t *testing.T) {
+	const envKey = "VAR_TO_BE_LOADED_FROM_OS_ENV"
+	const envVal = "SOME_RANDOM_VALUE"
+	os.Setenv(envKey, envVal)
+
+	envFileName := "fixtures/inherited-multi-var.env"
+	expectedValues := map[string]string{
+		envKey: envVal,
+		"foo":  "bar",
+		"bar":  "baz",
+	}
+
+	envMap, err := ReadWithLookup(os.LookupEnv, envFileName)
+	if err != nil {
+		t.Error("Error reading file")
+	}
+	if len(envMap) != len(expectedValues) {
+		t.Error("Didn't get the right size map back")
+	}
+	for key, value := range expectedValues {
+		if envMap[key] != value {
+			t.Errorf("Read got one of the keys wrong, [%q]->%q", key, envMap[key])
+		}
+	}
+}
+
+func TestInheritedEnvVariablSingleVar(t *testing.T) {
+	const envKey = "VAR_TO_BE_LOADED_FROM_OS_ENV"
+	const envVal = "SOME_RANDOM_VALUE"
+	os.Setenv(envKey, envVal)
+
+	envFileName := "fixtures/inherited-single-var.env"
+	expectedValues := map[string]string{
+		envKey: envVal,
+	}
+
+	envMap, err := ReadWithLookup(os.LookupEnv, envFileName)
+	if err != nil {
+		t.Error("Error reading file")
+	}
+	if len(envMap) != len(expectedValues) {
+		t.Error("Didn't get the right size map back")
+	}
+	for key, value := range expectedValues {
+		if envMap[key] != value {
+			t.Errorf("Read got one of the keys wrong, [%q]->%q", key, envMap[key])
+		}
+	}
+}
+
+func TestInheritedEnvVariableNotFound(t *testing.T) {
+	envMap, err := Read("fixtures/inherited-not-found.env")
+	if envMap["VARIABLE_NOT_FOUND"] != "" || err != nil {
+		t.Errorf("Expected 'VARIABLE_NOT_FOUND' to be '' with no errors")
+	}
+}
+
+func TestInheritedEnvVariableNotFoundWithLookup(t *testing.T) {
+	notFoundMap := make(map[string]interface{})
+	envMap, err := ReadWithLookup(func(v string) (string, bool) {
+		envVar, ok := os.LookupEnv(v)
+		if !ok {
+			notFoundMap[v] = nil
+		}
+		return envVar, ok
+	}, "fixtures/inherited-not-found.env")
+	if envMap["VARIABLE_NOT_FOUND"] != "" || err != nil {
+		t.Errorf("Expected 'VARIABLE_NOT_FOUND' to be '' with no errors")
+	}
+	_, ok := notFoundMap["VARIABLE_NOT_FOUND"]
+	if !ok {
+		t.Errorf("Expected 'VARIABLE_NOT_FOUND' to be in the set of not found variables")
+	}
+}
+
+func TestExpendingEnvironmentWithLookup(t *testing.T) {
+	rawEnvLine := "TEST=$ME"
+	expectedValue := "YES"
+	key, value, _ := parseLineWithLookup(rawEnvLine, noopPresets, func(s string) (string, bool) {
+		if s == "ME" {
+			return expectedValue, true
+		}
+		return "NO", false
+	})
+	if value != "YES" {
+		t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, key, expectedValue, key, value)
+	}
+}
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 4709b2e..e167e8f 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -139,10 +139,10 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 				},
 				EndpointMode: "dnsrr",
 			},
-      DeviceCgroupRules: []string{
-        "c 1:3 mr",
-        "a 7:* rmw",
-      },
+			DeviceCgroupRules: []string{
+				"c 1:3 mr",
+				"a 7:* rmw",
+			},
 			Devices:    []string{"/dev/ttyUSB0:/dev/ttyUSB0"},
 			DNS:        []string{"8.8.8.8", "9.9.9.9"},
 			DNSSearch:  []string{"dc1.example.com", "dc2.example.com"},