Skip to content

Commit

Permalink
nix: allow user's shellrc to update PATH; add tests for shellrc templ…
Browse files Browse the repository at this point in the history
…ate (jetify-com#106)

At the end of the devbox shellrc file, we're setting:

	export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"

This will overwrite anything that the user's shellrc might've added to
PATH. Change ORIGINAL_PATH to PATH to preserve those changes. There's
also no need for ORIGINAL_PATH to be an environment variable, so set it
directly in the template instead.

To prevent regressions, some tests that generate a devbox shellrc file
and compare it to an expected golden file. This ensures we don't
unknowingly change the way the shellrc is generated.

If we do intentionally change the devbox shellrc, the tests can
automatically update the golden files with -update:

	go test ./nix -update

It's up to the author of the change to verify that any new shellrc files
still work in a real shell.
  • Loading branch information
gcurtis authored Sep 14, 2022
1 parent 50dd4ce commit d3ad586
Show file tree
Hide file tree
Showing 15 changed files with 400 additions and 20 deletions.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ require (
cuelang.org/go v0.4.3
github.com/bmatcuk/doublestar/v4 v4.2.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/fatih/color v1.13.0
github.com/google/go-cmp v0.4.0
github.com/imdario/mergo v0.3.13
github.com/pelletier/go-toml/v2 v2.0.5
github.com/pkg/errors v0.9.1
Expand All @@ -24,7 +26,6 @@ require (
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/cockroachdb/apd/v2 v2.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/google/uuid v1.2.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/kr/text v0.2.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
Expand Down Expand Up @@ -81,6 +82,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
40 changes: 31 additions & 9 deletions nix/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,28 +118,33 @@ func (s *Shell) Run(nixPath string) error {

// execCommand is a command that replaces the current shell with s.
func (s *Shell) execCommand() string {
shellrc, err := writeDevboxShellrc(s.userShellrcPath, s.UserInitHook)
shellrc, err := writeDevboxShellrc(s.userShellrcPath, s.UserInitHook, os.Environ())
if err != nil {
debug.Log("Failed to write devbox shellrc: %v", err)
return "exec " + s.binPath
}

switch s.name {
case shBash:
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" %s --rcfile "%s"`,
os.Getenv("PATH"), s.binPath, shellrc)
return fmt.Sprintf(`exec %s --rcfile "%s"`, s.binPath, shellrc)
case shZsh:
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ZDOTDIR="%s" %s`,
os.Getenv("PATH"), filepath.Dir(shellrc), s.binPath)
return fmt.Sprintf(`exec /usr/bin/env ZDOTDIR="%s" %s`, filepath.Dir(shellrc), s.binPath)
case shKsh, shPosix:
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ENV="%s" %s `,
os.Getenv("PATH"), shellrc, s.binPath)
return fmt.Sprintf(`exec /usr/bin/env ENV="%s" %s`, shellrc, s.binPath)
default:
return "exec " + s.binPath
}
}

func writeDevboxShellrc(userShellrcPath string, userHook string) (path string, err error) {
func writeDevboxShellrc(userShellrcPath string, userHook string, env []string) (path string, err error) {
if userShellrcPath == "" {
// If this happens, then there's a bug with how we detect shells
// and their shellrc paths. If the shell is unknown or we can't
// determine the shellrc path, then we should launch a fallback
// shell instead.
panic("writeDevboxShellrc called with an empty user shellrc path; use the fallback shell instead")
}

// We need a temp dir (as opposed to a temp file) because zsh uses
// ZDOTDIR to point to a new directory containing the .zshrc.
tmp, err := os.MkdirTemp("", "devbox")
Expand All @@ -154,7 +159,22 @@ func writeDevboxShellrc(userShellrcPath string, userHook string) (path string, e
userShellrc = []byte{}
}

path = filepath.Join(tmp, filepath.Base(userShellrcPath))
var envPath []string
for _, kv := range env {
key, val, _ := strings.Cut(kv, "=")
if key == "PATH" {
envPath = filepath.SplitList(val)
break
}
}

// If the user already has a shellrc file, then give the devbox shellrc
// file the same name. Otherwise, use an arbitrary name of "shellrc".
shellrcName := "shellrc"
if userShellrcPath != "" {
shellrcName = filepath.Base(userShellrcPath)
}
path = filepath.Join(tmp, shellrcName)
shellrcf, err := os.Create(path)
if err != nil {
return "", fmt.Errorf("write to shell init file: %v", err)
Expand All @@ -167,10 +187,12 @@ func writeDevboxShellrc(userShellrcPath string, userHook string) (path string, e
}()

err = shellrcTmpl.Execute(shellrcf, struct {
Paths []string
OriginalInit string
OriginalInitPath string
UserHook string
}{
Paths: envPath,
OriginalInit: string(bytes.TrimSpace(userShellrc)),
OriginalInitPath: filepath.Clean(userShellrcPath),
UserHook: strings.TrimSpace(userHook),
Expand Down
89 changes: 89 additions & 0 deletions nix/shell_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package nix

import (
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
)

// update overwrites golden files with the new test results.
var update = flag.Bool("update", false, "update the golden files with the test results")

func TestWriteDevboxShellrc(t *testing.T) {
testdirs, err := filepath.Glob("testdata/shellrc/*")
if err != nil {
t.Fatal("Error globbing testdata:", err)
}

// Load up all the necessary data from each testdata/shellrc directory
// into a slice of tests cases.
tests := make([]struct {
name string
env []string
hook string
shellrcPath string
goldShellrcPath string
goldShellrc []byte
}, len(testdirs))
for i, path := range testdirs {
test := &tests[i]
test.name = filepath.Base(path)
if b, err := os.ReadFile(filepath.Join(path, "env")); err == nil {
test.env = strings.Split(string(b), "\n")
}
if b, err := os.ReadFile(filepath.Join(path, "hook")); err == nil {
test.hook = string(b)
}
test.shellrcPath = filepath.Join(path, "shellrc")
if _, err := os.Stat(test.shellrcPath); errors.Is(err, os.ErrNotExist) {
test.shellrcPath = "noshellrc"
}
test.goldShellrcPath = filepath.Join(path, "shellrc.golden")
test.goldShellrc, err = os.ReadFile(test.goldShellrcPath)
if err != nil {
t.Fatal("Got error reading golden file:", err)
}
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotPath, err := writeDevboxShellrc(test.shellrcPath, test.hook, test.env)
if err != nil {
t.Fatal("Got writeDevboxShellrc error:", err)
}
gotShellrc, err := os.ReadFile(gotPath)
if err != nil {
t.Fatalf("Got error reading generated shellrc at %s: %v", gotPath, err)
}

// Overwrite the golden file if the -update flag was
// set, and then proceed normally. The test should
// always pass in this case.
if *update {
err = os.WriteFile(test.goldShellrcPath, gotShellrc, 0666)
if err != nil {
t.Error("Error updating golden files:", err)
}
}
goldShellrc, err := os.ReadFile(test.goldShellrcPath)
if err != nil {
t.Fatal("Got error reading golden file:", err)
}
diff := cmp.Diff(goldShellrc, gotShellrc)
if diff != "" {
t.Errorf(strings.TrimSpace(`
Generated shellrc != shellrc.golden (-shellrc.golden +shellrc):
%s
If the new shellrc is correct, you can update the golden file with:
go test -run "^%s$" -update`), diff, t.Name())
}
})
}
}
33 changes: 23 additions & 10 deletions nix/shellrc.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,34 @@
// before and after the user's shellrc. These commands are in the
// "Devbox Pre/Post-init Hook" sections.
//
// The devbox pre/post-init hooks assume two environment variables are already
// set:
// The devbox pre/post-init hooks assume a PURE_NIX_PATH environment variable is
// already set by the shell hook in shell.nix.tmpl. It preserves the PATH set by
// Nix's "pure" shell mode.
//
// - ORIGINAL_PATH - embedded into the command built by Shell.execCommand. It
// preserves the PATH at the time `devbox shell` is invoked.
// - PURE_NIX_PATH - set by the shell hook in shell.nix.tmpl. It preserves the
// PATH set by Nix's "pure" shell mode.
// This file is useful for debugging shell errors, so try to keep the generated
// content readable.

*/ -}}

# Begin Devbox Pre-init Hook

# Update the $PATH so that the user's init script has access to all of their
# non-devbox programs.
export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"
# Don't allow the user's shellrc to re-source Nix since we're already in Nix.
export __ETC_PROFILE_NIX_SOURCED=1

# Put the Nix packages at the beginning of the PATH to give them priority over
# programs outside of devbox.
export PATH="$PURE_NIX_PATH"

{{- if .Paths }}

# Append any paths that were in the environment at the time the user launched
# devbox. This gives the shell access to non-devbox programs, while still
# preferring the ones that Nix installed.
{{- range .Paths }}
PATH="$PATH:{{ . }}"
{{- end }}

{{- end }}

# End Devbox Pre-init Hook

Expand All @@ -44,7 +57,7 @@ export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"

# Update the $PATH again so that the Nix packages take priority over the
# programs outside of devbox.
export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"
export PATH="$PURE_NIX_PATH:$PATH"

# Prepend to the prompt to make it clear we're in a devbox shell.
export PS1="(devbox) $PS1"
Expand Down
1 change: 1 addition & 0 deletions nix/testdata/shellrc/basic/env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PATH=/a/test/path
1 change: 1 addition & 0 deletions nix/testdata/shellrc/basic/hook
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
echo "Hello from a devbox shell hook!"
37 changes: 37 additions & 0 deletions nix/testdata/shellrc/basic/shellrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Set up the prompt

autoload -Uz promptinit
promptinit
#prompt adam1

setopt histignorealldups sharehistory

# Use emacs keybindings even if our EDITOR is set to vi
bindkey -e

# Keep 1000 lines of history within the shell and save it to ~/.zsh_history:
HISTSIZE=1000
SAVEHIST=1000
HISTFILE=~/.zsh_history

# Use modern completion system
autoload -Uz compinit
compinit

zstyle ':completion:*' auto-description 'specify: %d'
zstyle ':completion:*' completer _expand _complete _correct _approximate
zstyle ':completion:*' format 'Completing %d'
zstyle ':completion:*' group-name ''
zstyle ':completion:*' menu select=2
eval "$(dircolors -b)"
zstyle ':completion:*:default' list-colors ${(s.:.)LS_COLORS}
zstyle ':completion:*' list-colors ''
zstyle ':completion:*' list-prompt %SAt %p: Hit TAB for more, or the character to insert%s
zstyle ':completion:*' matcher-list '' 'm:{a-z}={A-Z}' 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=* l:|=*'
zstyle ':completion:*' menu select=long
zstyle ':completion:*' select-prompt %SScrolling active: current selection at %p%s
zstyle ':completion:*' use-compctl false
zstyle ':completion:*' verbose true

zstyle ':completion:*:*:kill:*:processes' list-colors '=(#b) #([0-9]#)*=0=01;31'
zstyle ':completion:*:kill:*' command 'ps -u $USER -o pid,%cpu,tty,cputime,cmd'
74 changes: 74 additions & 0 deletions nix/testdata/shellrc/basic/shellrc.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Begin Devbox Pre-init Hook

# Don't allow the user's shellrc to re-source Nix since we're already in Nix.
export __ETC_PROFILE_NIX_SOURCED=1

# Put the Nix packages at the beginning of the PATH to give them priority over
# programs outside of devbox.
export PATH="$PURE_NIX_PATH"

# Append any paths that were in the environment at the time the user launched
# devbox. This gives the shell access to non-devbox programs, while still
# preferring the ones that Nix installed.
PATH="$PATH:/a/test/path"

# End Devbox Pre-init Hook

# Begin testdata/shellrc/basic/shellrc

# Set up the prompt

autoload -Uz promptinit
promptinit
#prompt adam1

setopt histignorealldups sharehistory

# Use emacs keybindings even if our EDITOR is set to vi
bindkey -e

# Keep 1000 lines of history within the shell and save it to ~/.zsh_history:
HISTSIZE=1000
SAVEHIST=1000
HISTFILE=~/.zsh_history

# Use modern completion system
autoload -Uz compinit
compinit

zstyle ':completion:*' auto-description 'specify: %d'
zstyle ':completion:*' completer _expand _complete _correct _approximate
zstyle ':completion:*' format 'Completing %d'
zstyle ':completion:*' group-name ''
zstyle ':completion:*' menu select=2
eval "$(dircolors -b)"
zstyle ':completion:*:default' list-colors ${(s.:.)LS_COLORS}
zstyle ':completion:*' list-colors ''
zstyle ':completion:*' list-prompt %SAt %p: Hit TAB for more, or the character to insert%s
zstyle ':completion:*' matcher-list '' 'm:{a-z}={A-Z}' 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=* l:|=*'
zstyle ':completion:*' menu select=long
zstyle ':completion:*' select-prompt %SScrolling active: current selection at %p%s
zstyle ':completion:*' use-compctl false
zstyle ':completion:*' verbose true

zstyle ':completion:*:*:kill:*:processes' list-colors '=(#b) #([0-9]#)*=0=01;31'
zstyle ':completion:*:kill:*' command 'ps -u $USER -o pid,%cpu,tty,cputime,cmd'

# End testdata/shellrc/basic/shellrc

# Begin Devbox Post-init Hook

# Update the $PATH again so that the Nix packages take priority over the
# programs outside of devbox.
export PATH="$PURE_NIX_PATH:$PATH"

# Prepend to the prompt to make it clear we're in a devbox shell.
export PS1="(devbox) $PS1"

# End Devbox Post-init Hook

# Begin Devbox User Hook

echo "Hello from a devbox shell hook!"

# End Devbox User Hook
1 change: 1 addition & 0 deletions nix/testdata/shellrc/nohook/env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PATH=/a/test/path
Loading

0 comments on commit d3ad586

Please sign in to comment.