Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inherit variables from environment #149

Closed
wants to merge 5 commits into from
Closed
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
1 change: 1 addition & 0 deletions fixtures/inherited-not-found.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VARIABLE_NOT_FOUND
1 change: 1 addition & 0 deletions fixtures/inherited.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PATH
120 changes: 94 additions & 26 deletions godotenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import (

const doubleQuoteSpecialChars = "\\\n\r\"!$`"

// Load will read your env file(s) and load them into ENV for this process.
// LoadWithLookupFn will read your env file(s), lookup the inherited variables with `lookupFn`
//
// and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main)
//
Expand All @@ -39,18 +41,33 @@ const doubleQuoteSpecialChars = "\\\n\r\"!$`"
// 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) {
func LoadWithLookupFn(lookupFn func(string) (string, bool), filenames ...string) (err error) {
filenames = filenamesOrDefault(filenames)

for _, filename := range filenames {
err = loadFile(filename, false)
err = loadFile(filename, false, lookupFn)
if err != nil {
return // return early on a spazout
}
}
return
}

// 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 LoadWithLookupFn(nil, 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)
Expand All @@ -66,7 +83,7 @@ func Overload(filenames ...string) (err error) {
filenames = filenamesOrDefault(filenames)

for _, filename := range filenames {
err = loadFile(filename, true)
err = loadFile(filename, true, nil)
if err != nil {
return // return early on a spazout
}
Expand All @@ -81,7 +98,7 @@ func Read(filenames ...string) (envMap map[string]string, err error) {
envMap = make(map[string]string)

for _, filename := range filenames {
individualEnvMap, individualErr := readFile(filename)
individualEnvMap, individualErr := readFile(filename, noLookupFn)

if individualErr != nil {
err = individualErr
Expand All @@ -96,32 +113,61 @@ func Read(filenames ...string) (envMap map[string]string, err error) {
return
}

// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (envMap map[string]string, err error) {
// ReadWithLookup all env (with same file loading semantics as Load) but return values as
// a map rather than automatically writing values into env
func ReadWithLookup(lookupFn func(string)(string, bool), 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
}

// ParseWithLookup reads an env file from io.Reader resolving variables with lookupFn, returning a map of keys and values.
func ParseWithLookup(r io.Reader, lookupFn func(string)(string, bool)) (map[string]string, error) {
envMap := make(map[string]string)

var lines []string
scanner := bufio.NewScanner(r)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}

if err = scanner.Err(); err != nil {
return
if err := scanner.Err(); err != nil {
return envMap, err
}

for _, fullLine := range lines {
if !isIgnoredLine(fullLine) {
var key, value string
key, value, err = parseLine(fullLine, envMap)

key, value, err := parseLine(fullLine, envMap, lookupFn)
if err != nil {
return
return envMap, err
}
if key == "" {
continue
}
envMap[key] = value
}
}
return
return envMap, nil
}

// 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, noLookupFn)
ulyssessouza marked this conversation as resolved.
Show resolved Hide resolved
}

//Unmarshal reads an env file from a string, returning a map of keys and values.
Expand Down Expand Up @@ -187,8 +233,8 @@ func filenamesOrDefault(filenames []string) []string {
return filenames
}

func loadFile(filename string, overload bool) error {
envMap, err := readFile(filename)
func loadFile(filename string, overload bool, lookupFn func(string)(string, bool)) error {
envMap, err := readFile(filename, lookupFn)
if err != nil {
return err
}
Expand All @@ -209,23 +255,26 @@ func loadFile(filename string, overload bool) error {
return nil
}

func readFile(filename string) (envMap map[string]string, err error) {
func readFile(filename string, lookupFn func(string)(string, bool)) (envMap map[string]string, err error) {
file, err := os.Open(filename)
if err != nil {
return
}
defer file.Close()

return Parse(file)
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) {
func parseLine(line string, envMap map[string]string, lookupFn func(string)(string, bool)) (key string, value string, err error) {
if len(line) == 0 {
err = errors.New("zero length string")
return
}
if lookupFn == nil {
lookupFn = noLookupFn
}

// ditch the comments (but keep quoted hashes)
if strings.Contains(line, "#") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already there, but looks tricky (thinking of things like ${FOO##something}) https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html

Expand Down Expand Up @@ -258,30 +307,49 @@ func parseLine(line string, envMap map[string]string) (key string, value string,
splitString = strings.SplitN(line, ":", 2)
}

if len(splitString) != 2 {
err = errors.New("Can't separate key from value")
// Parse the key
key = strings.TrimSpace(strings.TrimPrefix(splitString[0], "export "))
key = exportRegex.ReplaceAllString(key, "$1")
if err = validateVariableName(key); err != nil {
return
}

// Parse the key
key = splitString[0]
if strings.HasPrefix(key, "export") {
key = strings.TrimPrefix(key, "export")
// Environment inherited variable
if firstEquals < 0 && firstColon < 0 {
value = ""
v, ok := lookupFn(strings.TrimSpace(key))
if ok {
value = v
}
return
}
key = strings.TrimSpace(key)

key = exportRegex.ReplaceAllString(splitString[0], "$1")
if len(splitString) != 2 {
err = errors.New("Can't separate key from value")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errors should not start with a capital; perhaps change "Can't" to "failed to"

return
}

// Parse the value
value = parseValue(splitString[1], envMap)
return
}

func validateVariableName(key string) error {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we actually need a validation for this? Note that there's no strict rules for variable names; this regex would also exclude Windows %VARIABLE%.

We made this mistake in the Docker Engine, and had to revert; moby/moby#16608

Generally, I would consider the variable name to be:

  • "anything" before the = (if present)
  • except when starting with # (comment)
  • and white space stripped

To be a "valid" variable.

Code that consumes this library can decide what to do with those (if anything); eg for docker we pass any variable to the runtime, and have the kernel/shell decide if it's accepted.

key = strings.TrimSpace(strings.TrimPrefix(key, "export "))
if !variableNameRegex.MatchString(key) {
return fmt.Errorf("invalid variable name %q", key)
}
return nil
}

var (
singleQuotesRegex = regexp.MustCompile(`\A'(.*)'\z`)
doubleQuotesRegex = regexp.MustCompile(`\A"(.*)"\z`)
escapeRegex = regexp.MustCompile(`\\.`)
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
variableNameRegex = regexp.MustCompile(`^[-_\\.a-zA-Z0-9]+$`)

noLookupFn = func(string)(string, bool) {return "", true}
)

func parseValue(value string, envMap map[string]string) string {
Expand Down
63 changes: 58 additions & 5 deletions godotenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
var noopPresets = make(map[string]string)

func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) {
key, value, _ := parseLine(rawEnvLine, noopPresets)
key, value, _ := parseLine(rawEnvLine, noopPresets, noLookupFn)
if key != expectedKey || value != expectedValue {
t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, expectedKey, expectedValue, key, value)
}
Expand Down Expand Up @@ -83,6 +83,33 @@ func TestReadPlainEnv(t *testing.T) {
}

envMap, err := Read(envFileName)
if err != nil {
t.Errorf("Error reading file: %q", err)
}

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 TestInheritedEnvVariable(t *testing.T) {
pathVariable, ok := os.LookupEnv("PATH")
if !ok {
t.Error("Error getting path variable")
}

envFileName := "fixtures/inherited.env"
expectedValues := map[string]string{
"PATH": pathVariable,
}

envMap, err := ReadWithLookup(os.LookupEnv, envFileName)
if err != nil {
t.Error("Error reading file")
}
Expand All @@ -98,6 +125,31 @@ func TestReadPlainEnv(t *testing.T) {
}
}

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 TestParse(t *testing.T) {
envMap, err := Parse(bytes.NewReader([]byte("ONE=1\nTWO='2'\nTHREE = \"3\"")))
expectedValues := map[string]string{
Expand Down Expand Up @@ -358,7 +410,6 @@ func TestParsing(t *testing.T) {
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")

Expand All @@ -367,12 +418,14 @@ func TestParsing(t *testing.T) {
parseAndCompare(t, " KEY=value", "KEY", "value")
parseAndCompare(t, "\tKEY=value", "KEY", "value")

parseAndCompare(t, "KEY-DASH=value", "KEY-DASH", "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)
k, v, err := parseLine(badlyFormattedLine, noopPresets, noLookupFn)
if err == nil {
t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine)
t.Errorf("Expected \"%v\" to return error, but it didn't. key=%q, value=%q", badlyFormattedLine, k, v)
}
}

Expand Down Expand Up @@ -454,7 +507,7 @@ 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)
env, err := readFile(fixtureFilename, noLookupFn)
if err != nil {
t.Errorf("Expected '%s' to read without error (%v)", fixtureFilename, err)
}
Expand Down