diff --git a/internal/nix/install.go b/internal/nix/install.go index d714b8b5646..642b27d9848 100644 --- a/internal/nix/install.go +++ b/internal/nix/install.go @@ -16,13 +16,18 @@ import ( "github.com/mattn/go-isatty" "go.jetify.com/devbox/internal/boxcli/usererr" - "go.jetify.com/devbox/internal/cmdutil" "go.jetify.com/devbox/internal/fileutil" "go.jetify.com/devbox/nix" ) func BinaryInstalled() bool { - return cmdutil.Exists("nix") + // Use the same resolver that Devbox uses to run nix commands (searching + // $PATH and well-known install locations), rather than a bare $PATH lookup. + // Otherwise Devbox can wrongly report nix as missing when it exists at a + // known location but isn't on $PATH — e.g. on NixOS, or when a non-POSIX + // login shell such as fish hasn't sourced the Nix profile (issue #2787). + _, err := nix.Default.LookPath() + return err == nil } func dirExistsAndIsNotEmpty(dir string) bool { diff --git a/nix/nix.go b/nix/nix.go index 0e37730ef89..ab0223a4df0 100644 --- a/nix/nix.go +++ b/nix/nix.go @@ -34,6 +34,11 @@ func System() string { return Default.System() } +// LookPath calls [Nix.LookPath] on the default Nix installation. +func LookPath() (string, error) { + return Default.LookPath() +} + // Version calls [Nix.Version] on the default Nix installation. func Version() string { return Default.Version() @@ -88,24 +93,46 @@ func (n *Nix) resolvePath() (string, error) { return path, nil } - try := []string{ - "/nix/var/nix/profiles/default/bin/nix", - "/run/current-system/sw/bin", - } - for _, path := range try { + for _, path := range nixBinaryFallbackPaths() { stat, err := os.Stat(path) - if err == nil { - // Is it executable and not a directory? - m := stat.Mode() - if !m.IsDir() && m.Perm()&0o111 != 0 { - n.lookPath.Store(&path) - return path, nil - } + if err != nil { + continue + } + // Is it an executable file (and not a directory)? + m := stat.Mode() + if !m.IsDir() && m.Perm()&0o111 != 0 { + n.lookPath.Store(&path) + return path, nil } } return "", pathErr } +// LookPath returns the absolute path to the nix executable. It searches $PATH +// (after attempting to source the Nix profile) and, failing that, the +// well-known installation locations in [nixBinaryFallbackPaths]. It returns an +// error if nix cannot be found. +// +// Because Devbox invokes nix by absolute path, a non-error result here means +// nix commands will run even when nix is not on $PATH — which happens, for +// example, when the login shell has not sourced the Nix profile (common with +// non-POSIX shells such as fish). +func (n *Nix) LookPath() (string, error) { + return n.resolvePath() +} + +// nixBinaryFallbackPaths returns well-known absolute paths to the nix +// executable, searched in order when nix is not found on $PATH. Each entry must +// point at the nix binary itself, not the directory that contains it. +func nixBinaryFallbackPaths() []string { + return []string{ + "/nix/var/nix/profiles/default/bin/nix", + // On NixOS, nix is provided through the current system profile rather + // than /nix/var/nix/profiles/default. + "/run/current-system/sw/bin/nix", + } +} + func (n *Nix) logger() *slog.Logger { if n.Logger == nil { return slog.Default() diff --git a/nix/nix_test.go b/nix/nix_test.go index 1604edf39a6..8e44dc6a774 100644 --- a/nix/nix_test.go +++ b/nix/nix_test.go @@ -2,6 +2,7 @@ package nix import ( + "path/filepath" "slices" "testing" ) @@ -204,3 +205,25 @@ func TestVersionInfoAtLeast(t *testing.T) { info.AtLeast(v) }) } + +func TestNixBinaryFallbackPaths(t *testing.T) { + paths := nixBinaryFallbackPaths() + if len(paths) == 0 { + t.Fatal("expected at least one fallback path") + } + // Every fallback must point at the nix binary itself, not a directory that + // contains it. resolvePath rejects directories, so a bare bin dir here + // would silently never match (regression guard for issue #2787). + for _, p := range paths { + if filepath.Base(p) != "nix" { + t.Errorf("fallback path %q must end in the nix binary, got base %q", p, filepath.Base(p)) + } + if !filepath.IsAbs(p) { + t.Errorf("fallback path %q must be absolute", p) + } + } + // The NixOS system-profile location must be covered. + if !slices.Contains(paths, "/run/current-system/sw/bin/nix") { + t.Errorf("expected NixOS system-profile nix path in fallbacks, got %v", paths) + } +}