Skip to content

Commit 485e3a4

Browse files
authored
shell,nix: fix shell PATH to prefer nix packages (#51)
This fixes an issue where the user's rc (~/.bashrc, ~/.zshrc, etc.) files were prepending to the devbox shell PATH. This can cause the shell to unexpectedly run the non-devbox version of programs. It's especially noticeable with some version managers that point the PATH at their shims so they can control which version of a program is run. The basic steps that result in this issue are: 1. Devbox launches Nix, which sets the PATH to point to the Nix store. 2. From within Nix, we launch the user's default shell. 3. The shell runs the user's init files and stomps on the Nix PATH. Fixing this is a little tricky because there's no straightforward way to run additional commands _after_ the shell runs the init files. Instead, we need to copy the user's init files and append to them. Doing this requires knowing how each shell handles initialization, so we need to special-case the more common shells by performing the following steps: 1. Attempt to detect the user's default shell via the SHELL env var (we may add more sophisticated detection later). 2. If the shell is recognized as bash, zsh, ksh, dash, ash or sh, copy over the user's corresponding init files to a temp directory and append our own commands to it. 3. Build an `exec` shell command that invokes the new shell and tells it to run our temp init file instead of the user's. If we can't detect the shell, we fall back to launching a vanilla Nix shell. If we don't recognize the detected shell, we fall back to launching it without overriding the init files. Finally, since we now have a way of running our own commands in the user's shell, we can change the PS1 prompt to show the user that they're in devbox. Fixes #17, #25, #44, #46.
1 parent 36333f9 commit 485e3a4

File tree

4 files changed

+207
-10
lines changed

4 files changed

+207
-10
lines changed

.golangci.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,19 @@ linters:
1919
- stylecheck
2020
- typecheck
2121
- unconvert
22-
- unparam
22+
- unparam
2323
- unused
2424
- usestdlibvars
2525
- varnamelen
2626
# - wrapcheck If we're going to use github.com/pkg/errors we should probably turn this on?
27-
2827
# We'd like to have the following linter enabled, but it's broken for Go
2928
# 1.19 as of golangci-lint v1.48.0. Re-enable it when this issue is
3029
# fixed: https://github.com/golangci/golangci-lint/issues/2649
3130
# - structcheck
32-
issues:
33-
exclude:
3431

3532
linters-settings:
33+
errorlint:
34+
errorf: false
3635
varnamelen:
3736
max-distance: 10
3837
ignore-decls:
@@ -48,6 +47,8 @@ linters-settings:
4847
- m map[string]int
4948
- ns string
5049
- r *http.Request
50+
- sh *Shell
51+
- sh *shell.Shell
5152
- t testing.T
5253
- w http.ResponseWriter
5354
- w io.Writer

nix/nix.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,59 @@ import (
1010
"os"
1111
"os/exec"
1212
"strings"
13+
14+
"go.jetpack.io/devbox/shell"
1315
)
1416

1517
func Shell(path string) error {
16-
cmd := exec.Command("nix-shell", path)
17-
// Default to the shell already being used.
18-
shell := os.Getenv("SHELL")
19-
if shell != "" {
20-
cmd.Args = append(cmd.Args, "--command", shell)
18+
// nix-shell only runs bash, which isn't great if the user has a
19+
// different default shell. Here we try to detect what their current
20+
// shell is, and then `exec` it to replace the bash process inside
21+
// nix-shell.
22+
sh, err := shell.Detect()
23+
if err != nil {
24+
// Fall back to running the vanilla Nix bash shell.
25+
return runFallbackShell(path)
2126
}
27+
28+
// Naively running the user's shell has two problems:
29+
//
30+
// 1. The shell will source the user's rc file and potentially reorder
31+
// the PATH. This is especially a problem with some shims that prepend
32+
// their own directories to the front of the PATH, replacing the
33+
// Nix-installed packages.
34+
// 2. If their shell is bash, we end up double-sourcing their ~/.bashrc.
35+
// Once when nix-shell launches bash, and again when we exec it.
36+
//
37+
// To workaround this, first we store the current (outside of devbox)
38+
// PATH in ORIGINAL_PATH. Then we run a "pure" nix-shell to prevent it
39+
// from sourcing their ~/.bashrc. From inside the nix-shell (but before
40+
// launching the user's preferred shell) we store the PATH again in
41+
// PURE_NIX_PATH. When we're finally in the user's preferred shell, we
42+
// can use these env vars to set the PATH so that Nix packages are up
43+
// front, and all of the other programs come after.
44+
//
45+
// ORIGINAL_PATH is set by sh.StartCommand.
46+
// PURE_NIX_PATH is set by the shell hook in shell.nix.tmpl.
47+
_ = sh.SetInit(`
48+
# Update the $PATH so the user can keep using programs that live outside of Nix,
49+
# but prefer anything installed by Nix.
50+
export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"
51+
52+
# Prepend to the prompt to make it clear we're in a devbox shell.
53+
export PS1="(devbox) $PS1"
54+
`)
55+
56+
cmd := exec.Command("nix-shell", path)
57+
cmd.Args = append(cmd.Args, "--pure", "--command", sh.ExecCommand())
58+
cmd.Stdin = os.Stdin
59+
cmd.Stdout = os.Stdout
60+
cmd.Stderr = os.Stderr
61+
return cmd.Run()
62+
}
63+
64+
func runFallbackShell(path string) error {
65+
cmd := exec.Command("nix-shell", path)
2266
cmd.Stdin = os.Stdin
2367
cmd.Stdout = os.Stdout
2468
cmd.Stderr = os.Stderr

shell/shell.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
// Package shell detects the user's default shell and configures it to run in
5+
// Devbox.
6+
package shell
7+
8+
import (
9+
"bytes"
10+
"errors"
11+
"fmt"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
)
16+
17+
type name string
18+
19+
const (
20+
shUnknown name = ""
21+
shBash name = "bash"
22+
shZsh name = "zsh"
23+
shKsh name = "ksh"
24+
shPosix name = "posix"
25+
)
26+
27+
// Shell configures a user's shell to run in Devbox.
28+
type Shell struct {
29+
name name
30+
path string
31+
initFile string
32+
devboxInitFile string
33+
}
34+
35+
// Detect attempts to determine the user's default shell.
36+
func Detect() (*Shell, error) {
37+
path := os.Getenv("SHELL")
38+
if path == "" {
39+
return nil, errors.New("unable to detect the current shell")
40+
}
41+
42+
sh := &Shell{path: filepath.Clean(path)}
43+
base := filepath.Base(path)
44+
// Login shell
45+
if base[0] == '-' {
46+
base = base[1:]
47+
}
48+
switch base {
49+
case "bash":
50+
sh.name = shBash
51+
sh.initFile = rcfilePath(".bashrc")
52+
case "zsh":
53+
sh.name = shZsh
54+
sh.initFile = rcfilePath(".zshrc")
55+
case "ksh":
56+
sh.name = shKsh
57+
sh.initFile = rcfilePath(".kshrc")
58+
case "dash", "ash", "sh":
59+
sh.name = shPosix
60+
sh.initFile = os.Getenv("ENV")
61+
62+
// Just make up a name if there isn't already an init file set
63+
// so we have somewhere to put a new one.
64+
if sh.initFile == "" {
65+
sh.initFile = ".shinit"
66+
}
67+
default:
68+
sh.name = shUnknown
69+
}
70+
return sh, nil
71+
}
72+
73+
// rcfilePath returns the absolute path for an rcfile, which is usually in the
74+
// user's home directory. It doesn't guarantee that the file exists.
75+
func rcfilePath(basename string) string {
76+
home, err := os.UserHomeDir()
77+
if err != nil {
78+
return ""
79+
}
80+
return filepath.Join(home, basename)
81+
}
82+
83+
// SetInit configures the shell to run a script at startup. The script runs
84+
// after the user's usual init files. The script's environment will contain an
85+
// ORIGINAL_PATH environment variable, which will bet set to the PATH before
86+
// the user's init files have had a chance to modify it.
87+
func (s *Shell) SetInit(script string) error {
88+
script = strings.TrimSpace(script)
89+
if script == "" {
90+
return nil
91+
}
92+
93+
initFile, _ := os.ReadFile(s.initFile)
94+
initFile = bytes.TrimSpace(initFile)
95+
if len(initFile) > 0 {
96+
initFile = append(initFile, '\n', '\n')
97+
}
98+
99+
buf := bytes.NewBuffer(initFile)
100+
buf.WriteString(`
101+
102+
# Begin Devbox Shell Hook
103+
104+
`)
105+
buf.WriteString(script)
106+
buf.WriteString(`
107+
108+
# End Devbox Shell Hook
109+
`)
110+
111+
// We need a temp dir (as opposed to a temp file) because zsh uses
112+
// ZDOTDIR to point to a new directory containing the .zshrc.
113+
tmp, err := os.MkdirTemp("", "devbox")
114+
if err != nil {
115+
return fmt.Errorf("create temp dir for shell init file: %v", err)
116+
}
117+
devboxInitFile := filepath.Join(tmp, filepath.Base(s.initFile))
118+
if err := os.WriteFile(devboxInitFile, buf.Bytes(), 0600); err != nil {
119+
return fmt.Errorf("write to shell init file: %v", err)
120+
}
121+
s.devboxInitFile = devboxInitFile
122+
return nil
123+
}
124+
125+
// ExecCommand is a command that replaces the current shell with s.
126+
func (s *Shell) ExecCommand() string {
127+
if s.devboxInitFile == "" {
128+
return "exec " + s.path
129+
}
130+
131+
switch s.name {
132+
case shBash:
133+
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" %s --rcfile "%s"`,
134+
os.Getenv("PATH"), s.path, s.devboxInitFile)
135+
case shZsh:
136+
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ZDOTDIR="%s" %s`,
137+
os.Getenv("PATH"), filepath.Dir(s.devboxInitFile), s.path)
138+
case shKsh, shPosix:
139+
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ENV="%s" %s `,
140+
os.Getenv("PATH"), s.devboxInitFile, s.path)
141+
default:
142+
return "exec " + s.path
143+
}
144+
}

tmpl/shell.nix.tmpl

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,18 @@ mkShell {
1313
export name="devbox"
1414
export IN_NIX_SHELL=0
1515
export DEVBOX_SHELL_ENABLED=1
16+
17+
# We set PURE_NIX_PATH in case the user's init files in the devbox shell
18+
# end up modifying PATH.
19+
export PURE_NIX_PATH="$PATH"
20+
21+
# Make sure we include a basic path for the devbox shell. Otherwise it may
22+
# fail to start.
23+
export PATH=$PATH:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin
1624
'';
1725
packages = [
1826
{{- range .Packages}}
1927
{{.}}
2028
{{end -}}
2129
];
22-
}
30+
}

0 commit comments

Comments
 (0)