Repository: compose-spec/compose-go
Pull request #125 (2021-04-20 13:13:25)

get COMPOSE_* from env sources as set by options
fixes https://github.com/docker/compose-cli/issues/1570


Patch:
diff --git a/cli/options.go b/cli/options.go
index 4eff1e1..dca023a 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -121,14 +121,9 @@ func WithDotEnv(o *ProjectOptions) error {
 		}
 		dotEnvFile = filepath.Join(dir, dotEnvFile)
 	}
-	s, err := os.Stat(dotEnvFile)
-	if os.IsNotExist(err) {
+	if _, err := os.Stat(dotEnvFile); os.IsNotExist(err) {
 		return nil
 	}
-	if s.IsDir() {
-		return nil
-	}
-
 	file, err := os.Open(dotEnvFile)
 	if err != nil {
 		return err
@@ -228,7 +223,6 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 
 // getConfigPathsFromOptions retrieves the config files for project based on project options
 func getConfigPathsFromOptions(options *ProjectOptions) ([]string, error) {
-	paths := []string{}
 	pwd := options.WorkingDir
 	if pwd == "" {
 		wd, err := os.Getwd()
@@ -239,36 +233,16 @@ func getConfigPathsFromOptions(options *ProjectOptions) ([]string, error) {
 	}
 
 	if len(options.ConfigPaths) != 0 {
-		for _, f := range options.ConfigPaths {
-			if f == "-" {
-				paths = append(paths, f)
-				continue
-			}
-			if !filepath.IsAbs(f) {
-				f = filepath.Join(pwd, f)
-			}
-			if _, err := os.Stat(f); err != nil {
-				return nil, err
-			}
-			if !filepath.IsAbs(f) {
-				fAbs, err := filepath.Abs(f)
-				if err != nil {
-					return nil, err
-				}
-				f = fAbs
-			}
-			paths = append(paths, f)
-		}
-		return paths, nil
+		return absolutePaths(options.ConfigPaths, pwd)
 	}
 
-	sep := os.Getenv(ComposeFileSeparator)
+	sep := options.Environment[ComposeFileSeparator]
 	if sep == "" {
 		sep = string(os.PathListSeparator)
 	}
-	f := os.Getenv(ComposeFilePath)
+	f := options.Environment[ComposeFilePath]
 	if f != "" {
-		return strings.Split(f, sep), nil
+		return absolutePaths(strings.Split(f, sep), pwd)
 	}
 
 	for {
@@ -295,6 +269,24 @@ func getConfigPathsFromOptions(options *ProjectOptions) ([]string, error) {
 	}
 }
 
+func absolutePaths(p []string, pwd string) ([]string, error) {
+	var paths []string
+	for _, f := range p {
+		if f == "-" {
+			paths = append(paths, f)
+			continue
+		}
+		if !filepath.IsAbs(f) {
+			f = filepath.Join(pwd, f)
+		}
+		if _, err := os.Stat(f); err != nil {
+			return paths, err
+		}
+		paths = append(paths, f)
+	}
+	return paths, nil
+}
+
 func parseConfigs(configPaths []string) ([]types.ConfigFile, error) {
 	files := []types.ConfigFile{}
 	for _, f := range configPaths {
diff --git a/compatibility/services.go b/compatibility/services.go
index 39bca64..88f2fa7 100644
--- a/compatibility/services.go
+++ b/compatibility/services.go
@@ -668,8 +668,8 @@ func (c *AllowList) CheckSecurityOpt(service *types.ServiceConfig) {
 }
 
 func (c *AllowList) CheckShmSize(service *types.ServiceConfig) {
-	if !c.supported("services.shm_size") && service.ShmSize != 0 {
-		service.ShmSize = 0
+	if !c.supported("services.shm_size") && service.ShmSize != "" {
+		service.ShmSize = ""
 		c.Unsupported("services.shm_size")
 	}
 }
diff --git a/envfile/envfile.go b/envfile/envfile.go
new file mode 100644
index 0000000..839f04c
--- /dev/null
+++ b/envfile/envfile.go
@@ -0,0 +1,104 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package envfile
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"os"
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/compose-spec/compose-go/types"
+)
+
+const whiteSpaces = " \t"
+
+// ErrBadKey typed error for bad environment variable
+type ErrBadKey struct {
+	msg string
+}
+
+func (e ErrBadKey) Error() string {
+	return fmt.Sprintf("poorly formatted environment: %s", e.msg)
+}
+
+// Parse reads a file with environment variables enumerated by lines
+//
+// ``Environment variable names used by the utilities in the Shell and
+// Utilities volume of IEEE Std 1003.1-2001 consist solely of uppercase
+// letters, digits, and the '_' (underscore) from the characters defined in
+// Portable Character Set and do not begin with a digit. *But*, other
+// characters may be permitted by an implementation; applications shall
+// tolerate the presence of such names.''
+// -- http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap08.html
+//
+// As of #16585, it's up to application inside docker to validate or not
+// environment variables, that's why we just strip leading whitespace and
+// nothing more.
+// Converts ["key=value"] to {"key":"value"} but set unset keys - the ones with no "=" in them - to nil
+// We use this in cases where we need to distinguish between FOO=  and FOO
+// where the latter case just means FOO was mentioned but not given a value
+func Parse(filename string) (types.MappingWithEquals, error) {
+	vars := types.MappingWithEquals{}
+	fh, err := os.Open(filename)
+	if err != nil {
+		return vars, err
+	}
+	defer fh.Close()
+
+	scanner := bufio.NewScanner(fh)
+	currentLine := 0
+	utf8bom := []byte{0xEF, 0xBB, 0xBF}
+	for scanner.Scan() {
+		scannedBytes := scanner.Bytes()
+		if !utf8.Valid(scannedBytes) {
+			return vars, fmt.Errorf("env file %s contains invalid utf8 bytes at line %d: %v", filename, currentLine+1, scannedBytes)
+		}
+		// We trim UTF8 BOM
+		if currentLine == 0 {
+			scannedBytes = bytes.TrimPrefix(scannedBytes, utf8bom)
+		}
+		// trim the line from all leading whitespace first
+		line := strings.TrimLeftFunc(string(scannedBytes), unicode.IsSpace)
+		currentLine++
+		// line is not empty, and not starting with '#'
+		if len(line) > 0 && !strings.HasPrefix(line, "#") {
+			data := strings.SplitN(line, "=", 2)
+
+			// trim the front of a variable, but nothing else
+			variable := strings.TrimLeft(data[0], whiteSpaces)
+			if strings.ContainsAny(variable, whiteSpaces) {
+				return vars, ErrBadKey{fmt.Sprintf("variable '%s' contains whitespaces", variable)}
+			}
+			if len(variable) == 0 {
+				return vars, ErrBadKey{fmt.Sprintf("no variable name on line '%s'", line)}
+			}
+
+			if len(data) > 1 {
+				// pass the value through, no trimming
+				vars[variable] = &data[1]
+			} else {
+				// variable was not given a value but declared
+				vars[strings.TrimSpace(line)] = nil
+			}
+		}
+	}
+	return vars, scanner.Err()
+}
diff --git a/loader/loader.go b/loader/loader.go
index 65d3ab9..cf82aa9 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -27,13 +27,14 @@ import (
 	"strings"
 	"time"
 
+	"github.com/imdario/mergo"
+
+	"github.com/compose-spec/compose-go/envfile"
 	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"
 	units "github.com/docker/go-units"
-	"github.com/imdario/mergo"
-	"github.com/joho/godotenv"
 	shellwords "github.com/mattn/go-shellwords"
 	"github.com/mitchellh/mapstructure"
 	"github.com/pkg/errors"
@@ -190,15 +191,14 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 	}
 
 	project := &types.Project{
-		Name:        opts.Name,
-		WorkingDir:  configDetails.WorkingDir,
-		Services:    model.Services,
-		Networks:    model.Networks,
-		Volumes:     model.Volumes,
-		Secrets:     model.Secrets,
-		Configs:     model.Configs,
-		Environment: configDetails.Environment,
-		Extensions:  model.Extensions,
+		Name:       opts.Name,
+		WorkingDir: configDetails.WorkingDir,
+		Services:   model.Services,
+		Networks:   model.Networks,
+		Volumes:    model.Volumes,
+		Secrets:    model.Secrets,
+		Configs:    model.Configs,
+		Extensions: model.Extensions,
 	}
 
 	if !opts.SkipNormalization {
@@ -527,21 +527,11 @@ func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string, l
 	if len(serviceConfig.EnvFile) > 0 {
 		for _, file := range serviceConfig.EnvFile {
 			filePath := absPath(workingDir, file)
-			file, err := os.Open(filePath)
+			fileVars, err := envfile.Parse(filePath)
 			if err != nil {
 				return err
 			}
-			defer file.Close()
-			fileVars, err := godotenv.Parse(file)
-			if err != nil {
-				return err
-			}
-			env := types.MappingWithEquals{}
-			for k, v := range fileVars {
-				v := v
-				env[k] = &v
-			}
-			environment.OverrideBy(env.Resolve(lookupEnv).RemoveEmpty())
+			environment.OverrideBy(fileVars.Resolve(lookupEnv).RemoveEmpty())
 		}
 	}
 
diff --git a/loader/normalize.go b/loader/normalize.go
index 4ec1811..871ebfa 100644
--- a/loader/normalize.go
+++ b/loader/normalize.go
@@ -18,8 +18,6 @@ package loader
 
 import (
 	"fmt"
-	"net/url"
-	"path/filepath"
 
 	"github.com/compose-spec/compose-go/errdefs"
 	"github.com/compose-spec/compose-go/types"
@@ -49,28 +47,6 @@ func normalize(project *types.Project) error {
 			s.PullPolicy = types.PullPolicyMissing
 		}
 
-		fn := func(s string) (string, bool) {
-			v, ok := project.Environment[s]
-			return v, ok
-		}
-
-		if s.Build != nil {
-			if s.Build.Dockerfile == "" {
-				s.Build.Dockerfile = "Dockerfile"
-			}
-			_, err := url.ParseRequestURI(s.Build.Context)
-			if err != nil {
-				if !filepath.IsAbs(s.Build.Context) {
-					s.Build.Context = filepath.Join(project.WorkingDir, s.Build.Context)
-				}
-				if !filepath.IsAbs(s.Build.Dockerfile) {
-					s.Build.Dockerfile = filepath.Join(s.Build.Context, s.Build.Dockerfile)
-				}
-			}
-			s.Build.Args = s.Build.Args.Resolve(fn)
-		}
-		s.Environment = s.Environment.Resolve(fn)
-
 		err := relocateLogDriver(s)
 		if err != nil {
 			return err
diff --git a/types/project.go b/types/project.go
index 43c9400..fd8495e 100644
--- a/types/project.go
+++ b/types/project.go
@@ -29,16 +29,15 @@ import (
 
 // Project is the result of loading a set of compose files
 type Project struct {
-	Name         string            `yaml:"-" json:"-"`
-	WorkingDir   string            `yaml:"-" json:"-"`
-	Services     Services          `json:"services"`
-	Networks     Networks          `yaml:",omitempty" json:"networks,omitempty"`
-	Volumes      Volumes           `yaml:",omitempty" json:"volumes,omitempty"`
-	Secrets      Secrets           `yaml:",omitempty" json:"secrets,omitempty"`
-	Configs      Configs           `yaml:",omitempty" json:"configs,omitempty"`
-	Extensions   Extensions        `yaml:",inline" json:"-"` // https://github.com/golang/go/issues/6213
-	ComposeFiles []string          `yaml:"-" json:"-"`
-	Environment  map[string]string `yaml:"-" json:"-"`
+	Name         string     `yaml:"-" json:"-"`
+	WorkingDir   string     `yaml:"-" json:"-"`
+	Services     Services   `json:"services"`
+	Networks     Networks   `yaml:",omitempty" json:"networks,omitempty"`
+	Volumes      Volumes    `yaml:",omitempty" json:"volumes,omitempty"`
+	Secrets      Secrets    `yaml:",omitempty" json:"secrets,omitempty"`
+	Configs      Configs    `yaml:",omitempty" json:"configs,omitempty"`
+	Extensions   Extensions `yaml:",inline" json:"-"` // https://github.com/golang/go/issues/6213
+	ComposeFiles []string   `yaml:"-" json:"-"`
 
 	// DisabledServices track services which have been disable as profile is not active
 	DisabledServices Services `yaml:"-" json:"-"`
diff --git a/types/types.go b/types/types.go
index 752911a..5440cc4 100644
--- a/types/types.go
+++ b/types/types.go
@@ -155,7 +155,7 @@ type ServiceConfig struct {
 	Scale           int                              `yaml:",omitempty" json:"scale,omitempty"`
 	Secrets         []ServiceSecretConfig            `yaml:",omitempty" json:"secrets,omitempty"`
 	SecurityOpt     []string                         `mapstructure:"security_opt" yaml:"security_opt,omitempty" json:"security_opt,omitempty"`
-	ShmSize         UnitBytes                        `mapstructure:"shm_size" yaml:"shm_size,omitempty" json:"shm_size,omitempty"`
+	ShmSize         string                           `mapstructure:"shm_size" yaml:"shm_size,omitempty" json:"shm_size,omitempty"`
 	StdinOpen       bool                             `mapstructure:"stdin_open" yaml:"stdin_open,omitempty" json:"stdin_open,omitempty"`
 	StopGracePeriod *Duration                        `mapstructure:"stop_grace_period" yaml:"stop_grace_period,omitempty" json:"stop_grace_period,omitempty"`
 	StopSignal      string                           `mapstructure:"stop_signal" yaml:"stop_signal,omitempty" json:"stop_signal,omitempty"`
@@ -764,13 +764,12 @@ type FileObjectConfig struct {
 }
 
 const (
-	// ServiceConditionCompletedSuccessfully is the type for waiting until a service has completed successfully (exit code 0).
-	ServiceConditionCompletedSuccessfully = "service_completed_successfully"
-
-	// ServiceConditionHealthy is the type for waiting until a service is healthy.
+	// TypeServiceConditionHealthy is the type for waiting until a service is
+	// healthy.
 	ServiceConditionHealthy = "service_healthy"
 
-	// ServiceConditionStarted is the type for waiting until a service has started (default).
+	// TypeServiceConditionHealthy is the type for waiting until a service has
+	// started.
 	ServiceConditionStarted = "service_started"
 )
 


Test patch:
diff --git a/cli/options_test.go b/cli/options_test.go
index fb2dafd..dc2fae5 100644
--- a/cli/options_test.go
+++ b/cli/options_test.go
@@ -25,37 +25,47 @@ import (
 )
 
 func TestProjectName(t *testing.T) {
-	opts, err := NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithName("my_project"))
-	assert.NilError(t, err)
-	p, err := ProjectFromOptions(opts)
-	assert.NilError(t, err)
-	assert.Equal(t, p.Name, "my_project")
+	t.Run("by name", func(t *testing.T) {
+		opts, err := NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithName("my_project"))
+		assert.NilError(t, err)
+		p, err := ProjectFromOptions(opts)
+		assert.NilError(t, err)
+		assert.Equal(t, p.Name, "my_project")
+	})
 
-	opts, err = NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithWorkingDirectory("."))
-	assert.NilError(t, err)
-	p, err = ProjectFromOptions(opts)
-	assert.NilError(t, err)
-	assert.Equal(t, p.Name, "cli")
+	t.Run("by working dir", func(t *testing.T) {
+		opts, err := NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithWorkingDirectory("."))
+		assert.NilError(t, err)
+		p, err := ProjectFromOptions(opts)
+		assert.NilError(t, err)
+		assert.Equal(t, p.Name, "cli")
+	})
 
-	opts, err = NewProjectOptions([]string{"testdata/simple/compose.yaml"})
-	assert.NilError(t, err)
-	p, err = ProjectFromOptions(opts)
-	assert.NilError(t, err)
-	assert.Equal(t, p.Name, "simple")
+	t.Run("by compose file parent dir", func(t *testing.T) {
+		opts, err := NewProjectOptions([]string{"testdata/simple/compose.yaml"})
+		assert.NilError(t, err)
+		p, err := ProjectFromOptions(opts)
+		assert.NilError(t, err)
+		assert.Equal(t, p.Name, "simple")
+	})
 
-	os.Setenv("COMPOSE_PROJECT_NAME", "my_project_from_env")
-	defer os.Unsetenv("COMPOSE_PROJECT_NAME")
-	opts, err = NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithOsEnv)
-	assert.NilError(t, err)
-	p, err = ProjectFromOptions(opts)
-	assert.NilError(t, err)
-	assert.Equal(t, p.Name, "my_project_from_env")
+	t.Run("by COMPOSE_PROJECT_NAME", func(t *testing.T) {
+		os.Setenv("COMPOSE_PROJECT_NAME", "my_project_from_env")
+		defer os.Unsetenv("COMPOSE_PROJECT_NAME")
+		opts, err := NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithOsEnv)
+		assert.NilError(t, err)
+		p, err := ProjectFromOptions(opts)
+		assert.NilError(t, err)
+		assert.Equal(t, p.Name, "my_project_from_env")
+	})
 
-	opts, err = NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithDotEnv)
-	assert.NilError(t, err)
-	p, err = ProjectFromOptions(opts)
-	assert.NilError(t, err)
-	assert.Equal(t, p.Name, "my_project_from_dot_env")
+	t.Run("by .env", func(t *testing.T) {
+		opts, err := NewProjectOptions(nil, WithWorkingDirectory("testdata/env-file"), WithDotEnv)
+		assert.NilError(t, err)
+		p, err := ProjectFromOptions(opts)
+		assert.NilError(t, err)
+		assert.Equal(t, p.Name, "my_project_from_dot_env")
+	})
 
 }
 
diff --git a/cli/testdata/env-file/.env b/cli/testdata/env-file/.env
new file mode 100644
index 0000000..7171c68
--- /dev/null
+++ b/cli/testdata/env-file/.env
@@ -0,0 +1,2 @@
+COMPOSE_FILE=compose-with-env-file.yaml
+COMPOSE_PROJECT_NAME=my_project_from_dot_env
\ No newline at end of file
diff --git a/cli/testdata/simple/.env b/cli/testdata/simple/.env
index 7997544..459aa3f 100644
--- a/cli/testdata/simple/.env
+++ b/cli/testdata/simple/.env
@@ -1,2 +1,1 @@
 PUBLIC_PORT=8000
-COMPOSE_PROJECT_NAME=my_project_from_dot_env
\ No newline at end of file
diff --git a/envfile/envfile_test.go b/envfile/envfile_test.go
new file mode 100644
index 0000000..b050df4
--- /dev/null
+++ b/envfile/envfile_test.go
@@ -0,0 +1,203 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package envfile
+
+import (
+	"bufio"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/compose-spec/compose-go/types"
+	is "gotest.tools/v3/assert/cmp"
+
+	"gotest.tools/v3/assert"
+)
+
+func tmpFileWithContent(content string, t *testing.T) string {
+	tmpFile, err := ioutil.TempFile("", "envfile-test")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer tmpFile.Close()
+
+	_, err = tmpFile.WriteString(content)
+	assert.NilError(t, err)
+	return tmpFile.Name()
+}
+
+// Test Parse for a file with a few well formatted lines
+func TestParseEnvFileGoodFile(t *testing.T) {
+	content := `foo=bar
+    baz=quux
+# comment
+
+_foobar=foobaz
+with.dots=working
+and_underscore=working too
+`
+	// Adding a newline + a line with pure whitespace.
+	// This is being done like this instead of the block above
+	// because it's common for editors to trim trailing whitespace
+	// from lines, which becomes annoying since that's the
+	// exact thing we need to test.
+	content += "\n    \t  "
+	tmpFile := tmpFileWithContent(content, t)
+	defer os.Remove(tmpFile)
+
+	lines, err := Parse(tmpFile)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expectedLines := types.MappingWithEquals{
+		"foo":            strPtr("bar"),
+		"baz":            strPtr("quux"),
+		"_foobar":        strPtr("foobaz"),
+		"with.dots":      strPtr("working"),
+		"and_underscore": strPtr("working too"),
+	}
+
+	assert.Check(t, is.DeepEqual(expectedLines, lines), "lines not equal to expectedLines")
+}
+
+func strPtr(s string) *string {
+	return &s
+}
+
+// Test Parse for an empty file
+func TestParseEnvFileEmptyFile(t *testing.T) {
+	tmpFile := tmpFileWithContent("", t)
+	defer os.Remove(tmpFile)
+
+	lines, err := Parse(tmpFile)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(lines) != 0 {
+		t.Fatal("lines not empty; expected empty")
+	}
+}
+
+// Test Parse for a non existent file
+func TestParseEnvFileNonExistentFile(t *testing.T) {
+	_, err := Parse("foo_bar_baz")
+	if err == nil {
+		t.Fatal("Parse succeeded; expected failure")
+	}
+	if _, ok := err.(*os.PathError); !ok {
+		t.Fatalf("Expected a PathError, got [%v]", err)
+	}
+}
+
+// Test Parse for a badly formatted file
+func TestParseEnvFileBadlyFormattedFile(t *testing.T) {
+	content := `foo=bar
+    f   =quux
+`
+
+	tmpFile := tmpFileWithContent(content, t)
+	defer os.Remove(tmpFile)
+
+	_, err := Parse(tmpFile)
+	if err == nil {
+		t.Fatalf("Expected an ErrBadKey, got nothing")
+	}
+	if _, ok := err.(ErrBadKey); !ok {
+		t.Fatalf("Expected an ErrBadKey, got [%v]", err)
+	}
+	expectedMessage := "poorly formatted environment: variable 'f   ' contains whitespaces"
+	if err.Error() != expectedMessage {
+		t.Fatalf("Expected [%v], got [%v]", expectedMessage, err.Error())
+	}
+}
+
+// Test Parse for a file with a line exceeding bufio.MaxScanTokenSize
+func TestParseEnvFileLineTooLongFile(t *testing.T) {
+	content := strings.Repeat("a", bufio.MaxScanTokenSize+42)
+	content = fmt.Sprint("foo=", content)
+
+	tmpFile := tmpFileWithContent(content, t)
+	defer os.Remove(tmpFile)
+
+	_, err := Parse(tmpFile)
+	if err == nil {
+		t.Fatal("Parse succeeded; expected failure")
+	}
+}
+
+// Parse with a random file, pass through
+func TestParseEnvFileRandomFile(t *testing.T) {
+	content := `first line
+another invalid line`
+	tmpFile := tmpFileWithContent(content, t)
+	defer os.Remove(tmpFile)
+
+	_, err := Parse(tmpFile)
+	if err == nil {
+		t.Fatalf("Expected an ErrBadKey, got nothing")
+	}
+	if _, ok := err.(ErrBadKey); !ok {
+		t.Fatalf("Expected an ErrBadKey, got [%v]", err)
+	}
+	expectedMessage := "poorly formatted environment: variable 'first line' contains whitespaces"
+	if err.Error() != expectedMessage {
+		t.Fatalf("Expected [%v], got [%v]", expectedMessage, err.Error())
+	}
+}
+
+// Parse with environment variable import definitions
+func TestParseEnvVariableDefinitionsFile(t *testing.T) {
+	content := `# comment=
+UNDEFINED_VAR
+DEFINED_VAR
+`
+	tmpFile := tmpFileWithContent(content, t)
+	defer os.Remove(tmpFile)
+
+	variables, err := Parse(tmpFile)
+	if nil != err {
+		t.Fatal("There must not be any error")
+	}
+	variables.Resolve(func(key string) (string, bool) {
+		if key == "DEFINED_VAR" {
+			return "VALUE", true
+		}
+		return "", false
+	})
+
+	if variables["DEFINED_VAR"] == nil || *variables["DEFINED_VAR"] != "VALUE" {
+		t.Fatal("the DEFINED_VAR variable is not properly imported as the first variable (but it is the only one to import)")
+	}
+}
+
+// Parse with empty variable name
+func TestParseEnvVariableWithNoNameFile(t *testing.T) {
+	content := `# comment=
+=blank variable names are an error case
+`
+	tmpFile := tmpFileWithContent(content, t)
+	defer os.Remove(tmpFile)
+
+	_, err := Parse(tmpFile)
+	if nil == err {
+		t.Fatal("if a variable has no name parsing an environment file must fail")
+	}
+}
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 4ded0e0..5448520 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -556,7 +556,6 @@ services:
         soft: $theint
     privileged: $thebool
     read_only: $thebool
-    shm_size: 2gb
     stdin_open: ${thebool}
     tty: $thebool
     volumes:
@@ -598,9 +597,8 @@ networks:
 	workingDir, err := os.Getwd()
 	assert.NilError(t, err)
 	expected := &types.Project{
-		Name:        "",
-		Environment: map[string]string{"thebool": "true", "thefloat": "3.14", "theint": "555"},
-		WorkingDir:  workingDir,
+		Name:       "",
+		WorkingDir: workingDir,
 		Services: []types.ServiceConfig{
 			{
 				Name: "web",
@@ -648,7 +646,6 @@ networks:
 				},
 				Privileged: true,
 				ReadOnly:   true,
-				ShmSize:    types.UnitBytes(2 * 1024 * 1024 * 1024),
 				StdinOpen:  true,
 				Tty:        true,
 				Volumes: []types.ServiceVolumeConfig{
diff --git a/loader/normalize_test.go b/loader/normalize_test.go
index d145fb7..a448ab6 100644
--- a/loader/normalize_test.go
+++ b/loader/normalize_test.go
@@ -17,22 +17,15 @@
 package loader
 
 import (
-	"path/filepath"
-	"strings"
 	"testing"
 
 	"github.com/compose-spec/compose-go/types"
-	"gopkg.in/yaml.v2"
 	"gotest.tools/v3/assert"
 )
 
 func TestNormalizeNetworkNames(t *testing.T) {
 	project := types.Project{
-		Name:       "myProject",
-		WorkingDir: "/some/path",
-		Environment: map[string]string{
-			"FOO": "BAR",
-		},
+		Name: "myProject",
 		Networks: types.Networks{
 			"myExternalnet": {
 				Name:     "myExternalnet", // this is automaticaly setup by loader for externa networks before reaching normalization
@@ -43,46 +36,25 @@ func TestNormalizeNetworkNames(t *testing.T) {
 				Name: "CustomName",
 			},
 		},
-		Services: []types.ServiceConfig{
-			{
-				Name: "foo",
-				Build: &types.BuildConfig{
-					Context: "foo",
-					Args: map[string]*string{
-						"FOO": nil,
-						"ZOT": nil,
-					},
-				},
+	}
+
+	expected := types.Project{
+		Name: "myProject",
+		Networks: types.Networks{
+			"default": {Name: "myProject_default"},
+			"myExternalnet": {
+				Name:     "myExternalnet",
+				External: types.External{External: true},
+			},
+			"mynet": {Name: "myProject_mynet"},
+			"myNamedNet": {
+				Name: "CustomName",
 			},
 		},
 	}
-
-	expected := strings.ReplaceAll(`services:
-  foo:
-    build:
-      context: /some/path/foo
-      dockerfile: /some/path/foo/Dockerfile
-      args:
-        FOO: BAR
-        ZOT: null
-    networks:
-      default: null
-networks:
-  default:
-    name: myProject_default
-  myExternalnet:
-    name: myExternalnet
-    external: true
-  myNamedNet:
-    name: CustomName
-  mynet:
-    name: myProject_mynet
-`, "/", string(filepath.Separator))
 	err := normalize(&project)
 	assert.NilError(t, err)
-	marshal, err := yaml.Marshal(project)
-	assert.NilError(t, err)
-	assert.DeepEqual(t, expected, string(marshal))
+	assert.DeepEqual(t, expected, project)
 }
 
 func TestNormalizeVolumes(t *testing.T) {