Skip to content

Commit c11031a

Browse files
authored
[shellenv] fix PATH nesting for devbox-project and devbox-global (#1508)
1 parent 240c507 commit c11031a

File tree

13 files changed

+353
-130
lines changed

13 files changed

+353
-130
lines changed

internal/boxcli/shellenv.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import (
1515

1616
type shellEnvCmdFlags struct {
1717
envFlag
18-
config configFlags
19-
runInitHook bool
20-
install bool
21-
pure bool
18+
config configFlags
19+
runInitHook bool
20+
install bool
21+
pure bool
22+
preservePathStack bool
2223
}
2324

2425
func shellEnvCmd() *cobra.Command {
@@ -48,6 +49,11 @@ func shellEnvCmd() *cobra.Command {
4849

4950
command.Flags().BoolVar(
5051
&flags.pure, "pure", false, "If this flag is specified, devbox creates an isolated environment inheriting almost no variables from the current environment. A few variables, in particular HOME, USER and DISPLAY, are retained.")
52+
command.Flags().BoolVar(
53+
&flags.preservePathStack, "preserve-path-stack", false,
54+
"Preserves existing PATH order if this project's environment is already in PATH. "+
55+
"Useful if you want to avoid overshadowing another devbox project that is already active")
56+
_ = command.Flags().MarkHidden("preserve-path-stack")
5157

5258
flags.config.register(command)
5359
flags.envFlag.register(command)
@@ -63,10 +69,11 @@ func shellEnvFunc(cmd *cobra.Command, flags shellEnvCmdFlags) (string, error) {
6369
return "", err
6470
}
6571
box, err := devbox.Open(&devopt.Opts{
66-
Dir: flags.config.path,
67-
Stderr: cmd.ErrOrStderr(),
68-
Pure: flags.pure,
69-
Env: env,
72+
Dir: flags.config.path,
73+
Stderr: cmd.ErrOrStderr(),
74+
PreservePathStack: flags.preservePathStack,
75+
Pure: flags.pure,
76+
Env: env,
7077
})
7178
if err != nil {
7279
return "", err

internal/envir/util.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package envir
55

66
import (
77
"os"
8+
"slices"
89
"strconv"
10+
"strings"
911
)
1012

1113
func IsDevboxCloud() bool {
@@ -43,3 +45,31 @@ func GetValueOrDefault(key, def string) string {
4345

4446
return val
4547
}
48+
49+
// MapToPairs creates a slice of environment variable "key=value" pairs from a
50+
// map.
51+
func MapToPairs(m map[string]string) []string {
52+
pairs := make([]string, len(m))
53+
i := 0
54+
for k, v := range m {
55+
pairs[i] = k + "=" + v
56+
i++
57+
}
58+
slices.Sort(pairs) // for reproducibility
59+
return pairs
60+
}
61+
62+
// PairsToMap creates a map from a slice of "key=value" environment variable
63+
// pairs. Note that maps are not ordered, which can affect the final variable
64+
// values when pairs contains duplicate keys.
65+
func PairsToMap(pairs []string) map[string]string {
66+
vars := make(map[string]string, len(pairs))
67+
for _, p := range pairs {
68+
k, v, ok := strings.Cut(p, "=")
69+
if !ok {
70+
continue
71+
}
72+
vars[k] = v
73+
}
74+
return vars
75+
}

internal/impl/devbox.go

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"io"
1111
"io/fs"
12+
"maps"
1213
"os"
1314
"os/exec"
1415
"path/filepath"
@@ -21,6 +22,7 @@ import (
2122
"github.com/pkg/errors"
2223
"github.com/samber/lo"
2324
"go.jetpack.io/devbox/internal/devpkg"
25+
"go.jetpack.io/devbox/internal/impl/envpath"
2426
"go.jetpack.io/devbox/internal/impl/generate"
2527
"go.jetpack.io/devbox/internal/searcher"
2628
"go.jetpack.io/devbox/internal/shellgen"
@@ -59,6 +61,7 @@ type Devbox struct {
5961
nix nix.Nixer
6062
projectDir string
6163
pluginManager *plugin.Manager
64+
preservePathStack bool
6265
pure bool
6366
allowInsecureAdds bool
6467
customProcessComposeFile string
@@ -89,6 +92,7 @@ func Open(opts *devopt.Opts) (*Devbox, error) {
8992
projectDir: projectDir,
9093
pluginManager: plugin.NewManager(),
9194
stderr: opts.Stderr,
95+
preservePathStack: opts.PreservePathStack,
9296
pure: opts.Pure,
9397
customProcessComposeFile: opts.CustomProcessComposeFile,
9498
allowInsecureAdds: opts.AllowInsecureAdds,
@@ -317,7 +321,7 @@ func (d *Devbox) EnvVars(ctx context.Context) ([]string, error) {
317321
if err != nil {
318322
return nil, err
319323
}
320-
return mapToPairs(envs), nil
324+
return envir.MapToPairs(envs), nil
321325
}
322326

323327
func (d *Devbox) ShellEnvHash(ctx context.Context) (string, error) {
@@ -782,18 +786,10 @@ func (d *Devbox) computeNixEnv(ctx context.Context, usePrintDevEnvCache bool) (m
782786
}
783787
}
784788

785-
currentEnvPath := env["PATH"]
786-
debug.Log("current environment PATH is: %s", currentEnvPath)
787-
// Use the original path, if available. If not available, set it for future calls.
788-
// See https://github.com/jetpack-io/devbox/issues/687
789-
// We add the project dir hash to ensure that we don't have conflicts
790-
// between different projects (including global)
791-
// (moving a project would change the hash and that's fine)
792-
originalPath, ok := env[d.ogPathKey()]
793-
if !ok {
794-
env[d.ogPathKey()] = currentEnvPath
795-
originalPath = currentEnvPath
796-
}
789+
debug.Log("current environment PATH is: %s", env["PATH"])
790+
791+
originalEnv := make(map[string]string, len(env))
792+
maps.Copy(originalEnv, env)
797793

798794
vaf, err := d.nix.PrintDevEnv(ctx, &nix.PrintDevEnvArgs{
799795
FlakesFilePath: d.nixFlakesFilePath(),
@@ -853,13 +849,13 @@ func (d *Devbox) computeNixEnv(ctx context.Context, usePrintDevEnvCache bool) (m
853849

854850
addEnvIfNotPreviouslySetByDevbox(env, pluginEnv)
855851

856-
env["PATH"] = JoinPathLists(
852+
env["PATH"] = envpath.JoinPathLists(
857853
nix.ProfileBinPath(d.projectDir),
858854
env["PATH"],
859855
)
860856

861857
if !d.OmitBinWrappersFromPath {
862-
env["PATH"] = JoinPathLists(
858+
env["PATH"] = envpath.JoinPathLists(
863859
filepath.Join(d.projectDir, plugin.WrapperBinPath),
864860
env["PATH"],
865861
)
@@ -899,14 +895,18 @@ func (d *Devbox) computeNixEnv(ctx context.Context, usePrintDevEnvCache bool) (m
899895
})
900896
debug.Log("PATH after filtering with buildInputs (%v) is: %s", buildInputs, nixEnvPath)
901897

902-
env["PATH"] = JoinPathLists(nixEnvPath, originalPath)
898+
pathStack := envpath.Stack(env, originalEnv)
899+
pathStack.Push(env, d.projectDirHash(), nixEnvPath, d.preservePathStack)
900+
env["PATH"] = pathStack.Path(env)
901+
debug.Log("New path stack is: %s", pathStack)
902+
903903
debug.Log("computed environment PATH is: %s", env["PATH"])
904904

905905
d.setCommonHelperEnvVars(env)
906906

907907
if !d.pure {
908908
// preserve the original XDG_DATA_DIRS by prepending to it
909-
env["XDG_DATA_DIRS"] = JoinPathLists(env["XDG_DATA_DIRS"], os.Getenv("XDG_DATA_DIRS"))
909+
env["XDG_DATA_DIRS"] = envpath.JoinPathLists(env["XDG_DATA_DIRS"], os.Getenv("XDG_DATA_DIRS"))
910910
}
911911

912912
for k, v := range d.env {
@@ -941,10 +941,6 @@ func (d *Devbox) nixEnv(ctx context.Context) (map[string]string, error) {
941941
return d.computeNixEnv(ctx, usePrintDevEnvCache)
942942
}
943943

944-
func (d *Devbox) ogPathKey() string {
945-
return "DEVBOX_OG_PATH_" + d.projectDirHash()
946-
}
947-
948944
func (d *Devbox) nixPrintDevEnvCachePath() string {
949945
return filepath.Join(d.projectDir, ".devbox/.nix-print-dev-env-cache")
950946
}
@@ -1120,8 +1116,8 @@ var ignoreDevEnvVar = map[string]bool{
11201116
// common setups (e.g. gradio, rust)
11211117
func (d *Devbox) setCommonHelperEnvVars(env map[string]string) {
11221118
profileLibDir := filepath.Join(d.projectDir, nix.ProfilePath, "lib")
1123-
env["LD_LIBRARY_PATH"] = JoinPathLists(profileLibDir, env["LD_LIBRARY_PATH"])
1124-
env["LIBRARY_PATH"] = JoinPathLists(profileLibDir, env["LIBRARY_PATH"])
1119+
env["LD_LIBRARY_PATH"] = envpath.JoinPathLists(profileLibDir, env["LD_LIBRARY_PATH"])
1120+
env["LIBRARY_PATH"] = envpath.JoinPathLists(profileLibDir, env["LIBRARY_PATH"])
11251121
}
11261122

11271123
// NixBins returns the paths to all the nix binaries that are installed by
@@ -1197,7 +1193,7 @@ func (d *Devbox) parseEnvAndExcludeSpecialCases(currentEnv []string) (map[string
11971193
return nil, err
11981194
}
11991195
includedInPath = append(includedInPath, dotdevboxBinPath(d))
1200-
env["PATH"] = JoinPathLists(includedInPath...)
1196+
env["PATH"] = envpath.JoinPathLists(includedInPath...)
12011197
}
12021198
return env, nil
12031199
}

internal/impl/devbox_test.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/bmatcuk/doublestar/v4"
1515
"github.com/stretchr/testify/assert"
1616
"github.com/stretchr/testify/require"
17+
"go.jetpack.io/devbox/internal/impl/envpath"
1718

1819
"go.jetpack.io/devbox/internal/devconfig"
1920
"go.jetpack.io/devbox/internal/envir"
@@ -85,10 +86,9 @@ func TestComputeNixPathIsIdempotent(t *testing.T) {
8586
assert.NotEmpty(t, path, "path should not be nil")
8687

8788
t.Setenv("PATH", path)
88-
t.Setenv(
89-
"DEVBOX_OG_PATH_"+devbox.projectDirHash(),
90-
env["DEVBOX_OG_PATH_"+devbox.projectDirHash()],
91-
)
89+
t.Setenv(envpath.InitPathEnv, env[envpath.InitPathEnv])
90+
t.Setenv(envpath.PathStackEnv, env[envpath.PathStackEnv])
91+
t.Setenv(envpath.Key(devbox.projectDirHash()), env[envpath.Key(devbox.projectDirHash())])
9292

9393
env, err = devbox.computeNixEnv(ctx, false /*use cache*/)
9494
require.NoError(t, err, "computeNixEnv should not fail")
@@ -108,10 +108,9 @@ func TestComputeNixPathWhenRemoving(t *testing.T) {
108108
assert.Contains(t, path, "/tmp/my/path", "path should contain /tmp/my/path")
109109

110110
t.Setenv("PATH", path)
111-
t.Setenv(
112-
"DEVBOX_OG_PATH_"+devbox.projectDirHash(),
113-
env["DEVBOX_OG_PATH_"+devbox.projectDirHash()],
114-
)
111+
t.Setenv(envpath.InitPathEnv, env[envpath.InitPathEnv])
112+
t.Setenv(envpath.PathStackEnv, env[envpath.PathStackEnv])
113+
t.Setenv(envpath.Key(devbox.projectDirHash()), env[envpath.Key(devbox.projectDirHash())])
115114

116115
devbox.nix.(*testNix).path = ""
117116
env, err = devbox.computeNixEnv(ctx, false /*use cache*/)

internal/impl/devopt/devboxopts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type Opts struct {
88
AllowInsecureAdds bool
99
Dir string
1010
Env map[string]string
11+
PreservePathStack bool
1112
Pure bool
1213
IgnoreWarnings bool
1314
CustomProcessComposeFile string

internal/impl/envpath/pathlists.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package envpath
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
)
7+
8+
// JoinPathLists joins and cleans PATH-style strings of
9+
// [os.ListSeparator] delimited paths. To clean a path list, it splits it and
10+
// does the following for each element:
11+
//
12+
// 1. Applies [filepath.Clean].
13+
// 2. Removes the path if it's relative (must begin with '/' and not be '.').
14+
// 3. Removes the path if it's a duplicate.
15+
func JoinPathLists(pathLists ...string) string {
16+
if len(pathLists) == 0 {
17+
return ""
18+
}
19+
20+
seen := make(map[string]bool)
21+
var cleaned []string
22+
for _, path := range pathLists {
23+
for _, path := range filepath.SplitList(path) {
24+
path = filepath.Clean(path)
25+
if path == "." || path[0] != '/' {
26+
// Remove empty paths and don't allow relative
27+
// paths for security reasons.
28+
continue
29+
}
30+
if !seen[path] {
31+
cleaned = append(cleaned, path)
32+
}
33+
seen[path] = true
34+
}
35+
}
36+
return strings.Join(cleaned, string(filepath.ListSeparator))
37+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package envpath
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestCleanEnvPath(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
inPath string
11+
outPath string
12+
}{
13+
{
14+
name: "NoEmptyPaths",
15+
inPath: "/usr/local/bin::",
16+
outPath: "/usr/local/bin",
17+
},
18+
{
19+
name: "NoRelativePaths",
20+
inPath: "/usr/local/bin:/usr/bin:../test:/bin:/usr/sbin:/sbin:.:..",
21+
outPath: "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
22+
},
23+
}
24+
for _, test := range tests {
25+
t.Run(test.name, func(t *testing.T) {
26+
got := JoinPathLists(test.inPath)
27+
if got != test.outPath {
28+
t.Errorf("Got incorrect cleaned PATH.\ngot: %s\nwant: %s", got, test.outPath)
29+
}
30+
})
31+
}
32+
}

0 commit comments

Comments
 (0)