diff --git a/internal/presenters/presenter_local_finding.go b/internal/presenters/presenter_local_finding.go index 7d9bb0e11..a3497802d 100644 --- a/internal/presenters/presenter_local_finding.go +++ b/internal/presenters/presenter_local_finding.go @@ -8,11 +8,11 @@ import ( "strings" "text/template" - "github.com/snyk/go-application-framework/internal/utils" "github.com/snyk/go-application-framework/pkg/configuration" "github.com/snyk/go-application-framework/pkg/local_workflows/content_type" "github.com/snyk/go-application-framework/pkg/local_workflows/local_models" "github.com/snyk/go-application-framework/pkg/runtimeinfo" + "github.com/snyk/go-application-framework/pkg/utils" ) const DefaultMimeType = "text/cli" diff --git a/pkg/envvars/environment.go b/pkg/envvars/environment.go index 67a41a40a..10c85fa69 100644 --- a/pkg/envvars/environment.go +++ b/pkg/envvars/environment.go @@ -20,62 +20,107 @@ const ( ShellEnvVarName = "SHELL" ) -// LoadConfiguredEnvironment updates the environment with local configuration. Precedence as follows: -// 1. std folder-based config files -// 2. given command-line parameter config file -// 3. std config file in home directory -// 4. global shell configuration +// GetCurrentEnvironment reads all current environment variables into a map. +func GetCurrentEnvironment() map[string]string { + currentEnv := make(map[string]string) + for _, envKeyValueString := range os.Environ() { + envKeyValuePair := strings.SplitN(envKeyValueString, "=", 2) + if len(envKeyValuePair) != 2 { + continue + } + currentEnv[envKeyValuePair[0]] = envKeyValuePair[1] + } + return currentEnv +} + +// SetEnvironmentDifferences Sets the environment variables that have changed since the current env snapshot. +func SetEnvironmentDifferences(currentEnv map[string]string, newEnv map[string]string) { + for newEnvName, newEnvValue := range newEnv { + if currentEnv[newEnvName] != newEnvValue { + _ = os.Setenv(newEnvName, newEnvValue) // we can't do anything with the error + } + } +} + +// LoadConfiguredEnvironment updates the environment with user and local configuration. +// First Bash's env is read (as a fallback), then the user's preferred SHELL's env is read, then the configuration files. +// The Bash env PATH is appended to the existing PATH (as a fallback), any other new PATH read is prepended (preferential). +// See LoadShellEnvironment and LoadConfigFiles. func LoadConfiguredEnvironment(customConfigFiles []string, workingDirectory string) { - bashOutput := getEnvFromShell("bash") + currentEnv := GetCurrentEnvironment() + newEnv := ReadShellEnvironment(currentEnv) + newEnv = ReadConfigFiles(newEnv, customConfigFiles, workingDirectory) + SetEnvironmentDifferences(currentEnv, newEnv) +} - // this is applied at the end always, as it does not overwrite existing variables - defer func() { _ = gotenv.Apply(strings.NewReader(bashOutput)) }() //nolint:errcheck // we can't do anything with the error +// ReadShellEnvironment reads the user's shell environment with special handling of PATHs. +// First Bash's env is read (as a fallback), then the user's preferred SHELL's env is read. +// The Bash env PATH is appended to the existing PATH (as a fallback), the user's preferred SHELL's env PATH is prepended (preferential). +func ReadShellEnvironment(currentEnv map[string]string) map[string]string { + bashEnvOutput := getEnvFromShell("bash") + bashEnv := gotenv.Parse(strings.NewReader(bashEnvOutput)) - env := gotenv.Parse(strings.NewReader(bashOutput)) - specificShell, ok := env[ShellEnvVarName] + preferredShell, ok := bashEnv[ShellEnvVarName] if ok { - fromSpecificShell := getEnvFromShell(specificShell) - _ = gotenv.Apply(strings.NewReader(fromSpecificShell)) //nolint:errcheck // we can't do anything with the error - } + preferredShellEnvOutput := getEnvFromShell(preferredShell) + preferredShellEnv := gotenv.Parse(strings.NewReader(preferredShellEnvOutput)) - // process config files - for _, file := range customConfigFiles { - if !filepath.IsAbs(file) { - file = filepath.Join(workingDirectory, file) - } - loadFile(file) + currentEnv = MergeEnvs(preferredShellEnv, currentEnv) } + + currentEnv = MergeEnvs(currentEnv, bashEnv) + + return currentEnv } -func loadFile(fileName string) { - // preserve path - previousPath := os.Getenv(PathEnvVarName) +var sdkVarNames = []string{"JAVA_HOME", "GOROOT"} + +// ReadConfigFiles loads environment variables from configuration files. +// With special handling for PATH and SDK environment variables. +// The resultant PATH is constructed as follows: +// 1. Config file PATH entries (highest precedence) +// 2. SDK bin directories (if SDK variables like JAVA_HOME, GOROOT are set by the config file) +// 3. Previous PATH entries (lowest precedence) +func ReadConfigFiles(currentEnv map[string]string, customConfigFiles []string, workingDirectory string) map[string]string { + for _, configFile := range customConfigFiles { + if !filepath.IsAbs(configFile) { + configFile = filepath.Join(workingDirectory, configFile) + } - // overwrite existing variables with file config - err := gotenv.OverLoad(fileName) - if err != nil { - return + configEnv, err := gotenv.Read(configFile) + if err != nil { + continue + } + + // Check if SDK variables were set by this config file and append their bin directories + for _, sdkVar := range sdkVarNames { + if configEnvSDKValue, ok := configEnv[sdkVar]; ok { + configEnv[PathEnvVarName] = MergePaths(configEnv[PathEnvVarName], filepath.Join(configEnvSDKValue, "bin")) + } + } + + currentEnv = MergeEnvs(configEnv, currentEnv) } - // add previous path to the end of the new - UpdatePath(previousPath, false) + return currentEnv } // guard against command injection var shellWhiteList = map[string]bool{ - "bash": true, - "/bin/zsh": true, - "/bin/sh": true, - "/bin/fish": true, - "/bin/csh": true, - "/bin/ksh": true, - "/bin/bash": true, - "/usr/bin/zsh": true, - "/usr/bin/sh": true, - "/usr/bin/fish": true, - "/usr/bin/csh": true, - "/usr/bin/ksh": true, - "/usr/bin/bash": true, + "bash": true, + "/bin/zsh": true, + "/bin/sh": true, + "/bin/fish": true, + "/bin/csh": true, + "/bin/ksh": true, + "/bin/bash": true, + "/usr/bin/zsh": true, + "/usr/bin/sh": true, + "/usr/bin/fish": true, + "/usr/bin/csh": true, + "/usr/bin/ksh": true, + "/usr/bin/bash": true, + "/opt/homebrew/bin/bash": true, } func getEnvFromShell(shell string) string { @@ -100,6 +145,13 @@ func getEnvFromShell(shell string) string { return string(env) } +// MergeEnvs merges two sets of environment variables, including PATHs. +func MergeEnvs(preferentialEnv map[string]string, leastPreferentialEnv map[string]string) map[string]string { + mergedEnv := utils.MergeMaps(leastPreferentialEnv, preferentialEnv) + mergedEnv[PathEnvVarName] = MergePaths(preferentialEnv[PathEnvVarName], leastPreferentialEnv[PathEnvVarName]) + return mergedEnv +} + // UpdatePath prepends or appends the extension to the current path. // For append, if the entry is already there, it will not be re-added / moved. // For prepend, if the entry is already there, it will be correctly re-prioritized to the front. @@ -109,29 +161,25 @@ func getEnvFromShell(shell string) string { // prepend bool whether to pre- or append func UpdatePath(pathExtension string, prepend bool) string { currentPath := os.Getenv(PathEnvVarName) - - if pathExtension == "" { - return currentPath - } - - if currentPath == "" { - _ = os.Setenv(PathEnvVarName, pathExtension) - return pathExtension - } - - currentPathEntries := strings.Split(currentPath, string(os.PathListSeparator)) - addPathEntries := strings.Split(pathExtension, string(os.PathListSeparator)) - - var combinedSliceWithDuplicates []string + var newPath string if prepend { - combinedSliceWithDuplicates = append(addPathEntries, currentPathEntries...) + newPath = MergePaths(pathExtension, currentPath) } else { - combinedSliceWithDuplicates = append(currentPathEntries, addPathEntries...) + newPath = MergePaths(currentPath, pathExtension) } + _ = os.Setenv(PathEnvVarName, newPath) + return newPath +} + +// MergePaths appends the leastPreferentialPath to the preferentialPath while removing duplicates. +func MergePaths(preferentialPath string, leastPreferentialPath string) string { + preferentialPathEntries := strings.Split(preferentialPath, string(os.PathListSeparator)) + leastPreferentialPathEntries := strings.Split(leastPreferentialPath, string(os.PathListSeparator)) - newPathSlice := utils.Dedupe(combinedSliceWithDuplicates) + combinedSliceWithDuplicates := append(preferentialPathEntries, leastPreferentialPathEntries...) + + newPathSlice := utils.DedupeWithoutBlanks(combinedSliceWithDuplicates) newPath := strings.Join(newPathSlice, string(os.PathListSeparator)) - _ = os.Setenv(PathEnvVarName, newPath) return newPath } diff --git a/pkg/envvars/environment_test.go b/pkg/envvars/environment_test.go index 517908de9..f07d6d9e0 100644 --- a/pkg/envvars/environment_test.go +++ b/pkg/envvars/environment_test.go @@ -8,6 +8,7 @@ import ( "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -117,16 +118,6 @@ func TestUpdatePathWithDefaults(t *testing.T) { }) } -func TestLoadFile(t *testing.T) { - t.Run("should load given config file", func(t *testing.T) { - uniqueEnvVar, fileName := setupTestFile(t, "env-file", t.TempDir()) - - loadFile(fileName) - - require.Equal(t, uniqueEnvVar, os.Getenv(uniqueEnvVar)) - }) -} - func TestLoadConfiguredEnvironment(t *testing.T) { t.Run("should load default config files", func(t *testing.T) { dir := t.TempDir() @@ -151,6 +142,193 @@ func TestLoadConfiguredEnvironment(t *testing.T) { }) } +func TestReadConfigFiles(t *testing.T) { + t.Run("should load config files and prepend PATH", func(t *testing.T) { + dir := t.TempDir() + originalPathValue := "original_path" + initialEnv := map[string]string{ + "PATH": originalPathValue, + } + + // Create a config file with PATH entry + configFileName := filepath.Join(dir, ".snyk.env") + configContent := []byte("TEST_VAR=test_value\nPATH=config" + pathListSep + "file\n") + err := os.WriteFile(configFileName, configContent, 0660) + require.NoError(t, err) + + files := []string{configFileName} + finalEnv := ReadConfigFiles(initialEnv, files, dir) + + // Verify environment variable was set + require.Equal(t, "test_value", finalEnv["TEST_VAR"]) + + // Verify PATH was prepended (config path should come first) + expectedPath := "config" + pathListSep + "file" + pathListSep + originalPathValue + require.Equal(t, expectedPath, finalEnv["PATH"]) + }) + + t.Run("should handle relative config file paths", func(t *testing.T) { + dir := t.TempDir() + originalPathValue := "original_path" + initialEnv := map[string]string{ + "PATH": originalPathValue, + } + + // Create a config file with relative path + configFileName := ".test.env" + configFilePath := filepath.Join(dir, configFileName) + configContent := []byte("PATH=relative" + pathListSep + "path\n") + err := os.WriteFile(configFilePath, configContent, 0660) + require.NoError(t, err) + + files := []string{configFileName} // relative path + finalEnv := ReadConfigFiles(initialEnv, files, dir) + + // Verify PATH was prepended + expectedPath := "relative" + pathListSep + "path" + pathListSep + originalPathValue + require.Equal(t, expectedPath, finalEnv["PATH"]) + }) + + t.Run("should add config file PATH and SDK bin directories to PATH (in that precedence order)", func(t *testing.T) { + dir := t.TempDir() + originalPathValue := "original_path" + initialEnv := map[string]string{ + "PATH": originalPathValue, + "JAVA_HOME": "", + "GOROOT": "system_go", + } + + // Create a config file that sets SDK variables and PATH + configFileName := filepath.Join(dir, ".snyk.env") + configFilePathValue := "config_bin" + configFileJavaHomeValue := "project_java" + configFileGoRootValue := "project_go" + configContent := []byte("JAVA_HOME=" + configFileJavaHomeValue + "\nGOROOT=" + configFileGoRootValue + "\nPATH=" + configFilePathValue + "\n") + err := os.WriteFile(configFileName, configContent, 0660) + require.NoError(t, err) + + // Act + finalEnv := ReadConfigFiles(initialEnv, []string{configFileName}, dir) + + // Verify SDK variables were set + assert.Equal(t, "project_java", finalEnv["JAVA_HOME"]) + assert.Equal(t, "project_go", finalEnv["GOROOT"]) + + // Verify PATH order: config PATH, then SDK bins, then original PATH + // Build expected paths using platform-appropriate separators + javaHomeBin := filepath.Join(configFileJavaHomeValue, "bin") + goRootBin := filepath.Join(configFileGoRootValue, "bin") + expectedPath := configFilePathValue + pathListSep + javaHomeBin + pathListSep + goRootBin + pathListSep + originalPathValue + assert.Equal(t, expectedPath, finalEnv["PATH"]) + }) + + t.Run("should not add SDK bin directories when SDK variables are pre-existing and not overridden", func(t *testing.T) { + dir := t.TempDir() + originalPathValue := "original_path" + preExistingJavaHome := "system_java" + preExistingGoRoot := "system_go" + initialEnv := map[string]string{ + "PATH": originalPathValue, + "JAVA_HOME": preExistingJavaHome, + "GOROOT": preExistingGoRoot, + } + + // Create a config file that doesn't change these SDK variables + configFileName := filepath.Join(dir, ".snyk.env") + configContent := []byte("OTHER_VAR=other_value\n") + err := os.WriteFile(configFileName, configContent, 0660) + require.NoError(t, err) + + // Act + finalEnv := ReadConfigFiles(initialEnv, []string{configFileName}, dir) + + // Verify SDK variables are unchanged + assert.Equal(t, preExistingJavaHome, finalEnv["JAVA_HOME"]) + assert.Equal(t, preExistingGoRoot, finalEnv["GOROOT"]) + + // Verify PATH was not modified by SDK bin directories + assert.Equal(t, originalPathValue, finalEnv["PATH"]) + }) + + t.Run("should add bin directories only for SDK variables changed by config files", func(t *testing.T) { + dir := t.TempDir() + originalPathValue := "original_path" + preExistingJavaHome := "system_java" + initialEnv := map[string]string{ + "PATH": originalPathValue, + "JAVA_HOME": preExistingJavaHome, + } + + // Create a config file that only changes GOROOT + configFileName := filepath.Join(dir, ".snyk.env") + configFileGoRootValue := "project_go" + configContent := []byte("GOROOT=" + configFileGoRootValue + "\n") + err := os.WriteFile(configFileName, configContent, 0660) + require.NoError(t, err) + + // Act + finalEnv := ReadConfigFiles(initialEnv, []string{configFileName}, dir) + + // Verify JAVA_HOME is unchanged, GOROOT is changed + assert.Equal(t, preExistingJavaHome, finalEnv["JAVA_HOME"]) + assert.Equal(t, configFileGoRootValue, finalEnv["GOROOT"]) + + // Verify only GOROOT/bin was added to PATH (appended after the config path, which is empty) + // Build expected path using platform-appropriate separators + goRootBin := filepath.Join(configFileGoRootValue, "bin") + expectedPath := goRootBin + pathListSep + originalPathValue + assert.Equal(t, expectedPath, finalEnv["PATH"]) + }) + + t.Run("should re-prioritize SDK bin directories when config file sets same value", func(t *testing.T) { + dir := t.TempDir() + javaHomeValue := "project_java" + javaHomeBinPath := filepath.Join(javaHomeValue, "bin") + systemBin := "system_bin" + usrBin := "usr_bin" + originalPathValue := systemBin + pathListSep + javaHomeBinPath + pathListSep + usrBin + initialEnv := map[string]string{ + "PATH": originalPathValue, + "JAVA_HOME": javaHomeValue, + } + + // Create a config file that sets JAVA_HOME to the same value + configFileName := filepath.Join(dir, ".snyk.env") + configFilePathValue := "config_path" + configContent := []byte("JAVA_HOME=" + javaHomeValue + "\nPATH=" + configFilePathValue + "\n") + err := os.WriteFile(configFileName, configContent, 0660) + require.NoError(t, err) + + // Act + finalEnv := ReadConfigFiles(initialEnv, []string{configFileName}, dir) + + // Verify JAVA_HOME is still the same value + assert.Equal(t, javaHomeValue, finalEnv["JAVA_HOME"]) + + // Verify PATH re-prioritization: config PATH, then JAVA_HOME/bin, then the original PATH with JAVA_HOME/bin deduplicated out + // Build expected path using platform-appropriate separators + javaHomeBin := filepath.Join(javaHomeValue, "bin") + expectedPath := configFilePathValue + pathListSep + javaHomeBin + pathListSep + systemBin + pathListSep + usrBin + assert.Equal(t, expectedPath, finalEnv["PATH"]) + }) +} + +func TestReadShellEnvironment(t *testing.T) { + t.Run("should read shell environment and still contain original PATH", func(t *testing.T) { + originalPathValue := "original_path" + initialEnv := map[string]string{ + "PATH": originalPathValue, + } + + // Note: This test will only work properly on non-Windows systems + // and when a shell is available. On Windows or in environments + // without shell access, the function will be a no-op. + finalEnv := ReadShellEnvironment(initialEnv) + + assert.Contains(t, finalEnv["PATH"], originalPathValue) + }) +} + func setupTestFile(t *testing.T, fileName string, dir string) (string, string) { t.Helper() uniqueEnvVar := strconv.Itoa(rand.Int()) diff --git a/pkg/utils/array.go b/pkg/utils/array.go index 7d54b0b8f..11c8f5924 100644 --- a/pkg/utils/array.go +++ b/pkg/utils/array.go @@ -80,10 +80,25 @@ func Merge(input1 []string, input2 []string) []string { // Example: // // mySlice := []string{"apple", "banana", "apple", "cherry", "banana", "date"} -// dedupedSlice := dedupe(mySlice) +// dedupedSlice := Dedupe(mySlice) // fmt.Println(dedupedSlice) // Output: [apple banana cherry date] func Dedupe(s []string) []string { - seen := make(map[string]bool) + return dedupe(map[string]bool{}, s) +} + +// DedupeWithoutBlanks removes duplicate entries and empty strings from a given slice. +// Returns a new, deduplicated slice. +// +// Example: +// +// mySlice := []string{"apple", "", "banana", "apple", "cherry", "", "banana", "date"} +// dedupedSlice := DedupeWithoutBlanks(mySlice) +// fmt.Println(dedupedSlice) // Output: [apple banana cherry date] +func DedupeWithoutBlanks(s []string) []string { + return dedupe(map[string]bool{"": true}, s) +} + +func dedupe(seen map[string]bool, s []string) []string { var result []string for _, str := range s { if _, ok := seen[str]; !ok { diff --git a/internal/utils/maps.go b/pkg/utils/maps.go similarity index 100% rename from internal/utils/maps.go rename to pkg/utils/maps.go diff --git a/internal/utils/maps_test.go b/pkg/utils/maps_test.go similarity index 100% rename from internal/utils/maps_test.go rename to pkg/utils/maps_test.go