diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index fdc25741b08..8f8d666ab93 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -115,7 +115,7 @@ func Open(opts *devopt.Opts) (*Devbox, error) { cfg: cfg, env: opts.Env, environment: environment, - nix: &nix.Nix{}, + nix: &nix.NixInstance{}, projectDir: filepath.Dir(cfg.Root.AbsRootPath), pluginManager: plugin.NewManager(), stderr: opts.Stderr, diff --git a/internal/devpkg/narinfo_cache.go b/internal/devpkg/narinfo_cache.go index e1feee2d0f1..b5bcff482c0 100644 --- a/internal/devpkg/narinfo_cache.go +++ b/internal/devpkg/narinfo_cache.go @@ -73,14 +73,6 @@ func FillNarInfoCache(ctx context.Context, packages ...*Package) error { return nil } - // Pre-compute values read in fillNarInfoCache - // so they can be read from multiple go-routines without locks - _, err := nix.Version() - if err != nil { - return err - } - _ = nix.System() - group, _ := errgroup.WithContext(ctx) for _, p := range eligiblePackages { pkg := p // copy the loop variable since its used in a closure below @@ -240,14 +232,9 @@ func (p *Package) sysInfoIfExists() (*lock.SystemInfo, error) { return nil, nil } - version, err := nix.Version() - if err != nil { - return nil, err - } - // disable for nix < 2.17 - if !version.AtLeast(nix.Version2_17) { - return nil, err + if !nix.AtLeast(nix.Version2_17) { + return nil, nil } entry, err := p.lockfile.Resolve(p.Raw) @@ -255,14 +242,12 @@ func (p *Package) sysInfoIfExists() (*lock.SystemInfo, error) { return nil, err } - userSystem := nix.System() - if entry.Systems == nil { return nil, nil } // Check if the user's system's info is present in the lockfile - sysInfo, ok := entry.Systems[userSystem] + sysInfo, ok := entry.Systems[nix.System()] if !ok { return nil, nil } diff --git a/internal/devpkg/package.go b/internal/devpkg/package.go index a9eea9a4ea9..7aa147286b3 100644 --- a/internal/devpkg/package.go +++ b/internal/devpkg/package.go @@ -390,7 +390,7 @@ func (p *Package) normalizePackageAttributePath() (string, error) { // We prefer nix.Search over just trying to parse the package's "URL" because // nix.Search will guarantee that the package exists for the current system. - var infos map[string]*nix.Info + var infos map[string]*nix.PkgInfo if p.IsDevboxPackage && !p.IsRunX() { // Perf optimization: For queries of the form nixpkgs/#foo, we can // use a nix.Search cache. diff --git a/internal/nix/config.go b/internal/nix/config.go index 9a67037b4cc..ba82c2be2c2 100644 --- a/internal/nix/config.go +++ b/internal/nix/config.go @@ -16,6 +16,7 @@ import ( "strings" "go.jetpack.io/devbox/internal/redact" + "go.jetpack.io/devbox/nix" ) // Config is a parsed Nix configuration. @@ -106,7 +107,7 @@ func (c Config) IsUserTrusted(ctx context.Context, username string) (bool, error } func IncludeDevboxConfig(ctx context.Context, username string) error { - info, _ := versionInfo() + info, _ := nix.Default.Info() path := cmp.Or(info.SystemConfig, "/etc/nix/nix.conf") includePath := filepath.Join(filepath.Dir(path), "devbox-nix.conf") b := fmt.Appendf(nil, "# This config was auto-generated by Devbox.\n\nextra-trusted-users = %s\n", username) diff --git a/internal/nix/install.go b/internal/nix/install.go index 32b0a483650..2f8e87e180d 100644 --- a/internal/nix/install.go +++ b/internal/nix/install.go @@ -21,8 +21,8 @@ import ( "go.jetpack.io/devbox/internal/build" "go.jetpack.io/devbox/internal/cmdutil" "go.jetpack.io/devbox/internal/fileutil" - "go.jetpack.io/devbox/internal/redact" "go.jetpack.io/devbox/internal/ux" + "go.jetpack.io/devbox/nix" ) const rootError = "warning: installing Nix as root is not supported by this script!" @@ -121,33 +121,23 @@ func EnsureNixInstalled(writer io.Writer, withDaemonFunc func() *bool) (err erro return } - var version VersionInfo - version, err = Version() - if err != nil { - err = redact.Errorf("nix: ensure install: get version: %w", err) - return - } - // ensure minimum nix version installed - if !version.AtLeast(MinVersion) { + if !nix.AtLeast(MinVersion) { err = usererr.New( "Devbox requires nix of version >= %s. Your version is %s. "+ "Please upgrade nix and try again.\n", MinVersion, - version, + nix.Version(), ) return } - // call ComputeSystem to ensure its value is internally cached so other - // callers can rely on just calling System - err = ComputeSystem() }() if BinaryInstalled() { return nil } if dirExists() { - if err = SourceNixEnv(); err != nil { + if _, err = SourceProfile(); err != nil { return err } else if BinaryInstalled() { return nil @@ -174,7 +164,7 @@ func EnsureNixInstalled(writer io.Writer, withDaemonFunc func() *bool) (err erro } // Source again - if err = SourceNixEnv(); err != nil { + if _, err = SourceProfile(); err != nil { return err } diff --git a/internal/nix/instance.go b/internal/nix/instance.go index 2ec612da00a..9142918044a 100644 --- a/internal/nix/instance.go +++ b/internal/nix/instance.go @@ -6,7 +6,7 @@ package nix import "context" // These make it easier to stub out nix for testing -type Nix struct{} +type NixInstance struct{} type Nixer interface { PrintDevEnv(ctx context.Context, args *PrintDevEnvArgs) (*PrintDevEnvOut, error) diff --git a/internal/nix/nix.go b/internal/nix/nix.go index 6af93f01018..b32c66d9b58 100644 --- a/internal/nix/nix.go +++ b/internal/nix/nix.go @@ -16,7 +16,6 @@ import ( "runtime" "runtime/trace" "strings" - "sync" "time" "github.com/pkg/errors" @@ -24,7 +23,6 @@ import ( "go.jetpack.io/devbox/internal/boxcli/usererr" "go.jetpack.io/devbox/internal/redact" "go.jetpack.io/devbox/nix/flake" - "golang.org/x/mod/semver" "go.jetpack.io/devbox/internal/debug" ) @@ -51,7 +49,7 @@ type PrintDevEnvArgs struct { // PrintDevEnv calls `nix print-dev-env -f ` and returns its output. The output contains // all the environment variables and bash functions required to create a nix shell. -func (*Nix) PrintDevEnv(ctx context.Context, args *PrintDevEnvArgs) (*PrintDevEnvOut, error) { +func (*NixInstance) PrintDevEnv(ctx context.Context, args *PrintDevEnvArgs) (*PrintDevEnvOut, error) { defer debug.FunctionTimer().End() defer trace.StartRegion(ctx, "nixPrintDevEnv").End() @@ -128,226 +126,10 @@ func ExperimentalFlags() []string { } } -func System() string { - if cachedSystem == "" { - // While this should have been initialized, we do a best-effort to avoid - // a panic. - if err := ComputeSystem(); err != nil { - panic(fmt.Sprintf( - "System called before being initialized by ComputeSystem: %v", - err, - )) - } - } - return cachedSystem -} - -var cachedSystem string - -func ComputeSystem() error { - // For Savil to debug "remove nixpkgs" feature. The Search api lacks x86-darwin info. - // So, I need to fake that I am x86-linux and inspect the output in generated devbox.lock - // and flake.nix files. - // This is also used by unit tests. - if cachedSystem != "" { - return nil - } - override := os.Getenv("__DEVBOX_NIX_SYSTEM") - if override != "" { - cachedSystem = override - } else { - cmd := command("eval", "--impure", "--raw", "--expr", "builtins.currentSystem") - out, err := cmd.Output(context.TODO()) - if err != nil { - return err - } - cachedSystem = string(out) - } - return nil -} - func SystemIsLinux() bool { return strings.Contains(System(), "linux") } -// All major Nix versions supported by Devbox. -const ( - Version2_12 = "2.12.0" - Version2_13 = "2.13.0" - Version2_14 = "2.14.0" - Version2_15 = "2.15.0" - Version2_16 = "2.16.0" - Version2_17 = "2.17.0" - Version2_18 = "2.18.0" - Version2_19 = "2.19.0" - Version2_20 = "2.20.0" - Version2_21 = "2.21.0" - Version2_22 = "2.22.0" - - MinVersion = Version2_12 -) - -// versionRegexp matches the first line of "nix --version" output. -// -// The semantic component is sourced from . -// It's been modified to tolerate Nix prerelease versions, which don't have a -// hyphen before the prerelease component and contain underscores. -var versionRegexp = regexp.MustCompile(`^(.+) \(.+\) ((?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:(?:-|pre)(?P(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$`) - -// preReleaseRegexp matches Nix prerelease version strings, which are not valid -// semvers. -var preReleaseRegexp = regexp.MustCompile(`pre(?P[0-9]+)_(?P[a-f0-9]{4,40})$`) - -// VersionInfo contains information about a Nix installation. -type VersionInfo struct { - // Name is the executed program name (the first element of argv). - Name string - - // Version is the semantic Nix version string. - Version string - - // System is the current Nix system. It follows the pattern - - // and does not use the same values as GOOS or GOARCH. - System string - - // ExtraSystems are other systems that the current machine supports. - // Usually set by the extra-platforms setting in nix.conf. - ExtraSystems []string - - // Features are the capabilities that the Nix binary was compiled with. - Features []string - - // SystemConfig is the path to the Nix system configuration file, - // usually /etc/nix/nix.conf. - SystemConfig string - - // UserConfigs is a list of paths to the user's Nix configuration files. - UserConfigs []string - - // StoreDir is the path to the Nix store directory, usually /nix/store. - StoreDir string - - // StateDir is the path to the Nix state directory, usually - // /nix/var/nix. - StateDir string - - // DataDir is the path to the Nix data directory, usually somewhere - // within the Nix store. This field is empty for Nix versions <= 2.12. - DataDir string -} - -func parseVersionInfo(data []byte) (VersionInfo, error) { - // Example nix --version --debug output from Nix versions 2.12 to 2.21. - // Version 2.12 omits the data directory, but they're otherwise - // identical. - // - // See https://github.com/NixOS/nix/blob/5b9cb8b3722b85191ee8cce8f0993170e0fc234c/src/libmain/shared.cc#L284-L305 - // - // nix (Nix) 2.21.2 - // System type: aarch64-darwin - // Additional system types: x86_64-darwin - // Features: gc, signed-caches - // System configuration file: /etc/nix/nix.conf - // User configuration files: /Users/nobody/.config/nix/nix.conf:/etc/xdg/nix/nix.conf - // Store directory: /nix/store - // State directory: /nix/var/nix - // Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share - - info := VersionInfo{} - if len(data) == 0 { - return info, redact.Errorf("empty nix --version output") - } - - lines := strings.Split(string(data), "\n") - matches := versionRegexp.FindStringSubmatch(lines[0]) - if len(matches) < 3 { - return info, redact.Errorf("parse nix version: %s", redact.Safe(lines[0])) - } - info.Name = matches[1] - info.Version = matches[2] - for _, line := range lines { - name, value, found := strings.Cut(line, ": ") - if !found { - continue - } - - switch name { - case "System type": - info.System = value - case "Additional system types": - info.ExtraSystems = strings.Split(value, ", ") - case "Features": - info.Features = strings.Split(value, ", ") - case "System configuration file": - info.SystemConfig = value - case "User configuration files": - info.UserConfigs = strings.Split(value, ":") - case "Store directory": - info.StoreDir = value - case "State directory": - info.StateDir = value - case "Data directory": - info.DataDir = value - } - } - return info, nil -} - -// AtLeast returns true if v.Version is >= version per semantic versioning. It -// always returns false if v.Version is empty or invalid, such as when the -// current Nix version cannot be parsed. It panics if version is an invalid -// semver. -func (v VersionInfo) AtLeast(version string) bool { - if !strings.HasPrefix(version, "v") { - version = "v" + version - } - if !semver.IsValid(version) { - panic(fmt.Sprintf("nix.atLeast: invalid version %q", version[1:])) - } - if semver.IsValid("v" + v.Version) { - return semver.Compare("v"+v.Version, version) >= 0 - } - - // If the version isn't a valid semver, check to see if it's a - // prerelease (e.g., 2.23.0pre20240526_7de033d6) and coerce it to a - // valid version (2.23.0-pre.20240526+7de033d6) so we can compare it. - prerelease := preReleaseRegexp.ReplaceAllString(v.Version, "-pre.$date+$commit") - return semver.Compare("v"+prerelease, version) >= 0 -} - -// version is the cached output of `nix --version --debug`. -var versionInfo = sync.OnceValues(runNixVersion) - -func runNixVersion() (VersionInfo, error) { - // Arbitrary timeout to make sure we don't take too long or hang. - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - // Intentionally don't use the nix.command function here. We use this to - // perform Nix version checks and don't want to pass any extra-features - // or flags that might be missing from old versions. - cmd := exec.CommandContext(ctx, "nix", "--version", "--debug") - out, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 { - return VersionInfo{}, redact.Errorf("nix command: %s: %q: %v", redact.Safe(cmd), exitErr.Stderr, err) - } - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return VersionInfo{}, redact.Errorf("nix command: %s: timed out while reading output: %v", redact.Safe(cmd), err) - } - return VersionInfo{}, redact.Errorf("nix command: %s: %v", redact.Safe(cmd), err) - } - - slog.Debug("nix --version --debug output", "out", out) - return parseVersionInfo(out) -} - -// Version returns the currently installed version of Nix. -func Version() (VersionInfo, error) { - return versionInfo() -} - var nixPlatforms = []string{ "aarch64-darwin", "aarch64-linux", diff --git a/internal/nix/nix_test.go b/internal/nix/nix_test.go index f3c6bbc45dd..f43c405346d 100644 --- a/internal/nix/nix_test.go +++ b/internal/nix/nix_test.go @@ -1,8 +1,6 @@ -//nolint:dupl package nix import ( - "slices" "testing" ) @@ -70,202 +68,3 @@ func TestParseInsecurePackagesFromExitError(t *testing.T) { t.Errorf("Expected package 'python-2.7.18.7', got %s", packages[0]) } } - -func TestParseVersionInfo(t *testing.T) { - raw := `nix (Nix) 2.21.2 -System type: aarch64-darwin -Additional system types: x86_64-darwin -Features: gc, signed-caches -System configuration file: /etc/nix/nix.conf -User configuration files: /Users/nobody/.config/nix/nix.conf:/etc/xdg/nix/nix.conf -Store directory: /nix/store -State directory: /nix/var/nix -Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share -` - - info, err := parseVersionInfo([]byte(raw)) - if err != nil { - t.Error("got parse error:", err) - } - if got, want := info.Name, "nix"; got != want { - t.Errorf("got Name = %q, want %q", got, want) - } - if got, want := info.Version, "2.21.2"; got != want { - t.Errorf("got Version = %q, want %q", got, want) - } - if got, want := info.System, "aarch64-darwin"; got != want { - t.Errorf("got System = %q, want %q", got, want) - } - if got, want := info.ExtraSystems, []string{"x86_64-darwin"}; !slices.Equal(got, want) { - t.Errorf("got ExtraSystems = %q, want %q", got, want) - } - if got, want := info.Features, []string{"gc", "signed-caches"}; !slices.Equal(got, want) { - t.Errorf("got Features = %q, want %q", got, want) - } - if got, want := info.SystemConfig, "/etc/nix/nix.conf"; got != want { - t.Errorf("got SystemConfig = %q, want %q", got, want) - } - if got, want := info.UserConfigs, []string{"/Users/nobody/.config/nix/nix.conf", "/etc/xdg/nix/nix.conf"}; !slices.Equal(got, want) { - t.Errorf("got UserConfigs = %q, want %q", got, want) - } - if got, want := info.StoreDir, "/nix/store"; got != want { - t.Errorf("got StoreDir = %q, want %q", got, want) - } - if got, want := info.StateDir, "/nix/var/nix"; got != want { - t.Errorf("got StateDir = %q, want %q", got, want) - } - if got, want := info.DataDir, "/nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share"; got != want { - t.Errorf("got DataDir = %q, want %q", got, want) - } -} - -func TestParseLixVersionInfo(t *testing.T) { - raw := `nix (Lix, like Nix) 2.90.0-beta.1 -System type: aarch64-darwin -Additional system types: x86_64-darwin -Features: gc, signed-caches -System configuration file: /etc/nix/nix.conf -User configuration files: /Users/nobody/.config/nix/nix.conf:/etc/xdg/nix/nix.conf -Store directory: /nix/store -State directory: /nix/var/nix -Data directory: /nix/store/12asl5a17ffj78njcy2fj31v59rdmanx-lix-2.90-beta.1/share -` - - info, err := parseVersionInfo([]byte(raw)) - if err != nil { - t.Error("got parse error:", err) - } - if got, want := info.Name, "nix"; got != want { - t.Errorf("got Name = %q, want %q", got, want) - } - if got, want := info.Version, "2.90.0-beta.1"; got != want { - t.Errorf("got Version = %q, want %q", got, want) - } - if got, want := info.System, "aarch64-darwin"; got != want { - t.Errorf("got System = %q, want %q", got, want) - } - if got, want := info.ExtraSystems, []string{"x86_64-darwin"}; !slices.Equal(got, want) { - t.Errorf("got ExtraSystems = %q, want %q", got, want) - } - if got, want := info.Features, []string{"gc", "signed-caches"}; !slices.Equal(got, want) { - t.Errorf("got Features = %q, want %q", got, want) - } - if got, want := info.SystemConfig, "/etc/nix/nix.conf"; got != want { - t.Errorf("got SystemConfig = %q, want %q", got, want) - } - if got, want := info.UserConfigs, []string{"/Users/nobody/.config/nix/nix.conf", "/etc/xdg/nix/nix.conf"}; !slices.Equal(got, want) { - t.Errorf("got UserConfigs = %q, want %q", got, want) - } - if got, want := info.StoreDir, "/nix/store"; got != want { - t.Errorf("got StoreDir = %q, want %q", got, want) - } - if got, want := info.StateDir, "/nix/var/nix"; got != want { - t.Errorf("got StateDir = %q, want %q", got, want) - } - if got, want := info.DataDir, "/nix/store/12asl5a17ffj78njcy2fj31v59rdmanx-lix-2.90-beta.1/share"; got != want { - t.Errorf("got DataDir = %q, want %q", got, want) - } -} - -func TestParseVersionInfoShort(t *testing.T) { - cases := []struct { - in string - name string - version string - }{ - {"nix (Nix) 2.21.2", "nix", "2.21.2"}, - {"nix (Nix) 2.23.0pre20240526_7de033d6", "nix", "2.23.0pre20240526_7de033d6"}, - {"command (Nix) name (Nix) 2.21.2", "command (Nix) name", "2.21.2"}, - {"nix (Lix, like Nix) 2.90.0-beta.1", "nix", "2.90.0-beta.1"}, - } - - for _, tt := range cases { - t.Run(tt.in, func(t *testing.T) { - got, err := parseVersionInfo([]byte(tt.in)) - if err != nil { - t.Error("got parse error:", err) - } - if got.Name != tt.name { - t.Errorf("got Name = %q, want %q", got.Name, tt.name) - } - if got.Version != tt.version { - t.Errorf("got Version = %q, want %q", got.Version, tt.version) - } - }) - } -} - -func TestParseVersionInfoError(t *testing.T) { - t.Run("NilOutput", func(t *testing.T) { - _, err := parseVersionInfo(nil) - if err == nil { - t.Error("want non-nil error") - } - }) - t.Run("EmptyOutput", func(t *testing.T) { - _, err := parseVersionInfo([]byte{}) - if err == nil { - t.Error("want non-nil error") - } - }) - t.Run("MissingVersionOutput", func(t *testing.T) { - _, err := parseVersionInfo([]byte("nix output without a version")) - if err == nil { - t.Error("want non-nil error") - } - }) -} - -func TestVersionInfoAtLeast(t *testing.T) { - info := VersionInfo{} - if info.AtLeast(Version2_12) { - t.Errorf("got empty current version >= %s", Version2_12) - } - - info.Version = Version2_13 - if !info.AtLeast(Version2_12) { - t.Errorf("got %s < %s", info.Version, Version2_12) - } - if !info.AtLeast(Version2_13) { - t.Errorf("got %s < %s", info.Version, Version2_13) - } - if info.AtLeast(Version2_14) { - t.Errorf("got %s >= %s", info.Version, Version2_14) - } - - // https://github.com/jetify-com/devbox/issues/2128 - info.Version = "2.23.0pre20240526_7de033d6" - if !info.AtLeast(Version2_12) { - t.Errorf("got %s < %s", info.Version, Version2_12) - } - if info.AtLeast("2.23.0") { - t.Errorf("got %s > %s", info.Version, "2.23.0") - } - if info.AtLeast("2.24.0") { - t.Errorf("got %s > %s", info.Version, "2.24.0") - } - if info.AtLeast("2.23.0-pre.99999999") { - t.Errorf("got %s > %s", info.Version, "2.23.0-pre.99999999") - } - if !info.AtLeast("2.23.0-pre.1") { - t.Errorf("got %s < %s", info.Version, "2.23.0-pre.1") - } - - t.Run("ArgEmptyPanic", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("want panic for empty version") - } - }() - info.AtLeast("") - }) - t.Run("ArgInvalidPanic", func(t *testing.T) { - v := "notasemver" - defer func() { - if r := recover(); r == nil { - t.Errorf("want panic for invalid version %q", v) - } - }() - info.AtLeast(v) - }) -} diff --git a/internal/nix/search.go b/internal/nix/search.go index 70a0ea6e7d2..523a394c79b 100644 --- a/internal/nix/search.go +++ b/internal/nix/search.go @@ -19,7 +19,7 @@ var ( ErrPackageNotInstalled = errors.New("package not installed") ) -type Info struct { +type PkgInfo struct { // attribute key is different in flakes vs legacy so we should only use it // if we know exactly which version we are using AttributeKey string `json:"attribute"` @@ -28,28 +28,28 @@ type Info struct { Version string `json:"version"` } -func (i *Info) String() string { +func (i *PkgInfo) String() string { return fmt.Sprintf("%s-%s", i.PName, i.Version) } -func Search(url string) (map[string]*Info, error) { +func Search(url string) (map[string]*PkgInfo, error) { if strings.HasPrefix(url, "runx:") { // TODO implement runx search. Also, move this check outside this function: nix package // should not be handling runx logic. - return map[string]*Info{}, nil + return map[string]*PkgInfo{}, nil } return searchSystem(url, "" /* system */) } -func parseSearchResults(data []byte) map[string]*Info { +func parseSearchResults(data []byte) map[string]*PkgInfo { var results map[string]map[string]any err := json.Unmarshal(data, &results) if err != nil { panic(err) } - infos := map[string]*Info{} + infos := map[string]*PkgInfo{} for key, result := range results { - infos[key] = &Info{ + infos[key] = &PkgInfo{ AttributeKey: key, PName: result["pname"].(string), Version: result["version"].(string), @@ -85,7 +85,7 @@ func PkgExistsForAnySystem(pkg string) bool { return false } -func searchSystem(url, system string) (map[string]*Info, error) { +func searchSystem(url, system string) (map[string]*PkgInfo, error) { // Eventually we may pass a writer here, but for now it is safe to use stderr writer := os.Stderr // Search will download nixpkgs if it's not already downloaded. Adding this @@ -125,7 +125,7 @@ var allowableQuery = regexp.MustCompile("^github:NixOS/nixpkgs/[0-9a-f]{40}#[^#] // queries of the form `nixpkgs/#attribute`, we can know for sure that // once `nix search` returns a valid result, it will always be the very same result. // Hence we can cache it locally and answer future queries fast, by not calling `nix search`. -func SearchNixpkgsAttribute(query string) (map[string]*Info, error) { +func SearchNixpkgsAttribute(query string) (map[string]*PkgInfo, error) { if !allowableQuery.MatchString(query) { return nil, errors.Errorf("invalid query: %s, must match regex: %s", query, allowableQuery) } @@ -135,7 +135,7 @@ func SearchNixpkgsAttribute(query string) (map[string]*Info, error) { // Check if the query was already cached, and return the result if so cache := filecache.New( "devbox/nix", - filecache.WithCacheDir[map[string]*Info](xdg.CacheSubpath("")), + filecache.WithCacheDir[map[string]*PkgInfo](xdg.CacheSubpath("")), ) if results, err := cache.Get(key); err == nil { diff --git a/internal/nix/search_test.go b/internal/nix/search_test.go index a6ea52c9479..6fc1bf52a3f 100644 --- a/internal/nix/search_test.go +++ b/internal/nix/search_test.go @@ -66,7 +66,7 @@ func TestParseSearchResults(t *testing.T) { testCases := []struct { name string input []byte - expectedResult map[string]*Info + expectedResult map[string]*PkgInfo }{ { name: "Valid JSON input", @@ -80,7 +80,7 @@ func TestParseSearchResults(t *testing.T) { "version": "3.9.16" } }`), - expectedResult: map[string]*Info{ + expectedResult: map[string]*PkgInfo{ "go": { AttributeKey: "go", PName: "go", @@ -96,7 +96,7 @@ func TestParseSearchResults(t *testing.T) { { name: "Empty JSON input", input: []byte(`{}`), - expectedResult: map[string]*Info{}, + expectedResult: map[string]*PkgInfo{}, }, } diff --git a/internal/nix/shim.go b/internal/nix/shim.go new file mode 100644 index 00000000000..80592f217ea --- /dev/null +++ b/internal/nix/shim.go @@ -0,0 +1,38 @@ +package nix + +import "go.jetpack.io/devbox/nix" + +// The types and functions in this file act a shim for the non-internal version +// of this package (go.jetpack.io/devbox/nix). That way callers don't need to +// import two nix packages and alias one of them. + +const ( + Version2_12 = nix.Version2_12 + Version2_13 = nix.Version2_13 + Version2_14 = nix.Version2_14 + Version2_15 = nix.Version2_15 + Version2_16 = nix.Version2_16 + Version2_17 = nix.Version2_17 + Version2_18 = nix.Version2_18 + Version2_19 = nix.Version2_19 + Version2_20 = nix.Version2_20 + Version2_21 = nix.Version2_21 + Version2_22 = nix.Version2_22 + Version2_23 = nix.Version2_23 + Version2_24 = nix.Version2_24 + Version2_25 = nix.Version2_25 + + MinVersion = nix.Version2_12 +) + +type ( + Nix = nix.Nix + Info = nix.Info +) + +var Default = nix.Default + +func AtLeast(version string) bool { return nix.AtLeast(version) } +func SourceProfile() (sourced bool, err error) { return nix.SourceProfile() } +func System() string { return nix.System() } +func Version() string { return nix.Version() } diff --git a/internal/nix/source.go b/internal/nix/source.go deleted file mode 100644 index 0c2f06cc64c..00000000000 --- a/internal/nix/source.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2024 Jetify Inc. and contributors. All rights reserved. -// Use of this source code is governed by the license in the LICENSE file. - -package nix - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/MakeNowJust/heredoc/v2" - "github.com/hashicorp/go-envparse" - "github.com/pkg/errors" - - "go.jetpack.io/devbox/internal/boxcli/usererr" - "go.jetpack.io/devbox/internal/envir" - "go.jetpack.io/devbox/internal/xdg" -) - -func nixLinks() []string { - return []string{ - "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh", - filepath.Join(os.Getenv(envir.Home), ".nix-profile/etc/profile.d/nix.sh"), - // logic introduced in https://github.com/NixOS/nix/pull/5588/files - xdg.StateSubpath("nix/profile/etc/profile.d/nix.sh"), - xdg.StateSubpath("nix/profiles/profile/etc/profile.d/nix.sh"), - } -} - -func SourceNixEnv() error { - // if command is not in path, the source the nix startup files and hopefully - // the command will be found. (we should still check that nix is actually - // installed before we get here) - srcFile := "" - for _, f := range nixLinks() { - if _, err := os.Stat(f); err == nil { - srcFile = f - break - } - } - - if srcFile == "" { - return usererr.New( - "Unable to find nix startup file. If /nix directory exists it's " + - "possible the installation did not complete successfully. Follow " + - "instructions at https://nixos.org/download.html for manual install.", - ) - } - - // Source the nix script that sets the environment, and print the environment - // variables that it sets, in a way we can parse. - - // NOTE: Only use shell built-ins in this script so that we don't introduce - // any dependencies on an external binary. - script := heredoc.Docf(` - . %s; - echo PATH=$PATH; - echo NIX_PROFILES=$NIX_PROFILES; - echo NIX_SSL_CERT_FILE=$NIX_SSL_CERT_FILE; - echo MANPATH=$MANPATH; - `, srcFile) - cmd := exec.Command( - "/bin/sh", - "-c", - script, - ) - - bs, err := cmd.CombinedOutput() - if err != nil { - // When there's an error, the output is usually an error message that - // was printed to stderr and that we want in the error for debugging. - return errors.Wrap(err, string(bs)) - } - - envvars, err := envparse.Parse(bytes.NewReader(bs)) - if err != nil { - return errors.Wrap(err, "failed to parse nix env vars") - } - - for k, v := range envvars { - if len(strings.TrimSpace(v)) > 0 { - os.Setenv(k, v) - } - } - - return nil -} diff --git a/internal/nix/store.go b/internal/nix/store.go index 481579b4616..8b3dbd47e21 100644 --- a/internal/nix/store.go +++ b/internal/nix/store.go @@ -12,6 +12,7 @@ import ( "go.jetpack.io/devbox/internal/debug" "go.jetpack.io/devbox/internal/redact" + "go.jetpack.io/devbox/nix" "golang.org/x/exp/maps" ) @@ -128,17 +129,13 @@ func (e *DaemonError) Redact() string { // DaemonVersion returns the version of the currently running Nix daemon. func DaemonVersion(ctx context.Context) (string, error) { - // We only need the version to decide which CLI flags to use. We can - // ignore the error because an empty version assumes nix.MinVersion. - cliVersion, _ := Version() - storeCmd := "ping" - if cliVersion.AtLeast(Version2_19) { + if nix.AtLeast(nix.Version2_19) { // "nix store ping" is deprecated as of 2.19 in favor of // "nix store info". storeCmd = "info" } - canJSON := cliVersion.AtLeast(Version2_14) + canJSON := nix.AtLeast(nix.Version2_14) cmd := command("store", storeCmd, "--store", "daemon") if canJSON { diff --git a/internal/nix/upgrade.go b/internal/nix/upgrade.go index 54348dbdb38..ee6050eab0d 100644 --- a/internal/nix/upgrade.go +++ b/internal/nix/upgrade.go @@ -8,6 +8,7 @@ import ( "os" "go.jetpack.io/devbox/internal/ux" + "go.jetpack.io/devbox/nix" ) func ProfileUpgrade(ProfileDir, indexOrName string) error { @@ -19,13 +20,9 @@ func ProfileUpgrade(ProfileDir, indexOrName string) error { } func FlakeUpdate(ProfileDir string) error { - version, err := Version() - if err != nil { - return err - } ux.Finfof(os.Stderr, "Running \"nix flake update\"\n") cmd := command("flake", "update") - if version.AtLeast(Version2_19) { + if nix.AtLeast(Version2_19) { cmd.Args = append(cmd.Args, "--flake") } cmd.Args = append(cmd.Args, ProfileDir) diff --git a/internal/telemetry/segment.go b/internal/telemetry/segment.go index b8fbf29a26c..8c88f9b5571 100644 --- a/internal/telemetry/segment.go +++ b/internal/telemetry/segment.go @@ -4,6 +4,7 @@ package telemetry import ( + "cmp" "io" "log" "os" @@ -12,7 +13,7 @@ import ( "github.com/samber/lo" segment "github.com/segmentio/analytics-go" - "go.jetpack.io/devbox/internal/nix" + "go.jetpack.io/devbox/nix" "go.jetpack.io/devbox/internal/build" "go.jetpack.io/devbox/internal/envir" @@ -34,10 +35,7 @@ func initSegmentClient() bool { } func newTrackMessage(name string, meta Metadata) *segment.Track { - nixVersion := "unknown" - if v, err := nix.Version(); err == nil { - nixVersion = v.Version - } + nixVersion := cmp.Or(nix.Version(), "unknown") dur := time.Since(procStartTime) if !meta.EventStart.IsZero() { diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 5720aa8d51b..2fdab419cd8 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -4,6 +4,7 @@ package telemetry import ( + "cmp" "crypto/rand" "encoding/hex" "encoding/json" @@ -26,7 +27,7 @@ import ( segment "github.com/segmentio/analytics-go" "go.jetpack.io/devbox/internal/boxcli/usererr" "go.jetpack.io/devbox/internal/devbox/providers/identity" - "go.jetpack.io/devbox/internal/nix" + "go.jetpack.io/devbox/nix" "go.jetpack.io/devbox/internal/build" "go.jetpack.io/devbox/internal/envir" @@ -144,10 +145,7 @@ func Error(err error, meta Metadata) { return } - nixVersion := "unknown" - if v, err := nix.Version(); err == nil { - nixVersion = v.Version - } + nixVersion := cmp.Or(nix.Version(), "unknown") event := &sentry.Event{ EventID: sentry.EventID(ExecutionID), diff --git a/nix/nix.go b/nix/nix.go new file mode 100644 index 00000000000..97fb8248d53 --- /dev/null +++ b/nix/nix.go @@ -0,0 +1,439 @@ +package nix + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "go.jetpack.io/devbox/internal/redact" + "golang.org/x/mod/semver" +) + +// Default is the default Nix installation. +var Default = &Nix{} + +// System calls [Nix.System] on the default Nix installation. +func System() string { + return Default.System() +} + +// Version calls [Nix.Version] on the default Nix installation. +func Version() string { + return Default.Version() +} + +// AtLeast reports if the default Nix installation's version is equal to or +// newer than the given version. It returns false if it cannot determine the +// Nix version. +func AtLeast(version string) bool { + info, _ := Default.Info() + return info.AtLeast(version) +} + +// Nix provides an interface for interacting with Nix. The zero-value is valid +// and uses the first Nix executable found in $PATH. +type Nix struct { + // Path is the absolute path to the nix executable. If it is empty, + // nix commands use the executable found in $PATH. + Path string + lookPath atomic.Pointer[string] + + info Info + infoErr error + infoOnce sync.Once +} + +// resolvePath resolves the path to the Nix executable. It returns n.Path if it +// is non-empty and a valid executable. Otherwise it searches for a nix +// executable in $PATH and common installation directories. +func (n *Nix) resolvePath() (string, error) { + if n.Path != "" { + return exec.LookPath(n.Path) // verify it's an executable. + } + + // Re-use the cached path if we've already found Nix before. + cached := n.lookPath.Load() + if cached != nil && *cached != "" { + return *cached, nil + } + + _, _ = SourceProfile() + path, pathErr := exec.LookPath("nix") + if pathErr == nil { + n.lookPath.Store(&path) + return path, nil + } + + try := []string{ + "/nix/var/nix/profiles/default/bin/nix", + "/run/current-system/sw/bin", + } + for _, path := range try { + 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 + } + } + } + return "", pathErr +} + +// command runs an arbitrary Nix command. +func (n *Nix) command(ctx context.Context, args ...string) (*exec.Cmd, error) { + path, err := n.resolvePath() + if err != nil { + return nil, err + } + return exec.CommandContext(ctx, path, args...), nil +} + +// System returns the system from [Nix.Info] or an empty string if there was an +// error. +func (n *Nix) System() string { + info, _ := n.Info() + return info.System +} + +// Version returns the version from [Nix.Info] or an empty string if there was +// an error. +func (n *Nix) Version() string { + info, _ := n.Info() + return info.Version +} + +// Info returns Nix version information. It caches the result after the first +// call, which means it won't reflect any configuration changes to Nix. Create a +// new Nix instance to retrieve uncached information. +func (n *Nix) Info() (Info, error) { + // Create the command first, which will catch any errors finding the Nix + // executable outside of the once. This allows us to retry after + // installing Nix. + cmd, err := n.command(context.Background(), "--version", "--debug") + if err != nil { + return Info{}, err + } + + n.infoOnce.Do(func() { + out, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 { + n.infoErr = redact.Errorf("nix command: %s: %q: %v", redact.Safe(cmd), exitErr.Stderr, err) + return + } + n.infoErr = redact.Errorf("nix command: %s: %v", redact.Safe(cmd), err) + return + } + n.info, n.infoErr = parseInfo(out) + }) + return n.info, n.infoErr +} + +// All major Nix versions supported by Devbox. +const ( + Version2_12 = "2.12.0" + Version2_13 = "2.13.0" + Version2_14 = "2.14.0" + Version2_15 = "2.15.0" + Version2_16 = "2.16.0" + Version2_17 = "2.17.0" + Version2_18 = "2.18.0" + Version2_19 = "2.19.0" + Version2_20 = "2.20.0" + Version2_21 = "2.21.0" + Version2_22 = "2.22.0" + Version2_23 = "2.23.0" + Version2_24 = "2.24.0" + Version2_25 = "2.25.0" + + MinVersion = Version2_12 +) + +// versionRegexp matches the first line of "nix --version" output. +// +// The semantic component is sourced from . +// It's been modified to tolerate Nix prerelease versions, which don't have a +// hyphen before the prerelease component and contain underscores. +var versionRegexp = regexp.MustCompile(`^(.+) \(.+\) ((?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:(?:-|pre)(?P(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$`) + +// preReleaseRegexp matches Nix prerelease version strings, which are not valid +// semvers. +var preReleaseRegexp = regexp.MustCompile(`pre(?P[0-9]+)_(?P[a-f0-9]{4,40})$`) + +// Info contains information about a Nix installation. +type Info struct { + // Name identifies the Nix implementation. It is usually "nix" but may + // also be a fork like "lix". + Name string + + // Version is the semantic Nix version string. + Version string + + // System is the Nix system tuple. It follows the pattern - + // and does not use the same values as GOOS or GOARCH. Note that the Nix + // system is configurable and may not represent the actual operating + // system or architecture. + System string + + // ExtraSystems are other systems that the current machine supports. + // Usually set by the extra-platforms setting in nix.conf. + ExtraSystems []string + + // Features are the capabilities that the Nix binary was compiled with. + Features []string + + // SystemConfig is the path to the Nix system configuration file, + // usually /etc/nix/nix.conf. + SystemConfig string + + // UserConfigs is a list of paths to the user's Nix configuration files. + UserConfigs []string + + // StoreDir is the path to the Nix store directory, usually /nix/store. + StoreDir string + + // StateDir is the path to the Nix state directory, usually + // /nix/var/nix. + StateDir string + + // DataDir is the path to the Nix data directory, usually somewhere + // within the Nix store. This field is empty for Nix versions <= 2.12. + DataDir string +} + +func parseInfo(data []byte) (Info, error) { + // Example nix --version --debug output from Nix versions 2.12 to 2.21. + // Version 2.12 omits the data directory, but they're otherwise + // identical. + // + // See https://github.com/NixOS/nix/blob/5b9cb8b3722b85191ee8cce8f0993170e0fc234c/src/libmain/shared.cc#L284-L305 + // + // nix (Nix) 2.21.2 + // System type: aarch64-darwin + // Additional system types: x86_64-darwin + // Features: gc, signed-caches + // System configuration file: /etc/nix/nix.conf + // User configuration files: /Users/nobody/.config/nix/nix.conf:/etc/xdg/nix/nix.conf + // Store directory: /nix/store + // State directory: /nix/var/nix + // Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share + + info := Info{} + if len(data) == 0 { + return info, redact.Errorf("empty nix --version output") + } + + lines := strings.Split(string(data), "\n") + matches := versionRegexp.FindStringSubmatch(lines[0]) + if len(matches) < 3 { + return info, redact.Errorf("parse nix version: %s", redact.Safe(lines[0])) + } + info.Name = matches[1] + info.Version = matches[2] + for _, line := range lines { + name, value, found := strings.Cut(line, ": ") + if !found { + continue + } + + switch name { + case "System type": + info.System = value + case "Additional system types": + info.ExtraSystems = strings.Split(value, ", ") + case "Features": + info.Features = strings.Split(value, ", ") + case "System configuration file": + info.SystemConfig = value + case "User configuration files": + info.UserConfigs = strings.Split(value, ":") + case "Store directory": + info.StoreDir = value + case "State directory": + info.StateDir = value + case "Data directory": + info.DataDir = value + } + } + return info, nil +} + +// AtLeast returns true if i.Version is >= version per semantic versioning. It +// always returns false if i.Version is empty or invalid, such as when the +// current Nix version cannot be parsed. It panics if version is an invalid +// semver. +func (i Info) AtLeast(version string) bool { + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if !semver.IsValid(version) { + panic(fmt.Sprintf("nix.atLeast: invalid version %q", version[1:])) + } + if semver.IsValid("v" + i.Version) { + return semver.Compare("v"+i.Version, version) >= 0 + } + + // If the version isn't a valid semver, check to see if it's a + // prerelease (e.g., 2.23.0pre20240526_7de033d6) and coerce it to a + // valid version (2.23.0-pre.20240526+7de033d6) so we can compare it. + prerelease := preReleaseRegexp.ReplaceAllString(i.Version, "-pre.$date+$commit") + return semver.Compare("v"+prerelease, version) >= 0 +} + +// sourceProfileMutex guards against multiple goroutines attempting to source +// the Nix profile scripts concurrently. +var sourceProfileMutex sync.Mutex + +// SourceProfile adds environment variables from the Nix profile shell scripts +// to the current process's environment. This ensures that PATH contains the nix +// bin directory and that NIX_PROFILES and NIX_SSL_CERT_FILE are set. +// +// For properly configured Nix installations, the user's login shell handles +// sourcing the profile and SourceProfile has no effect. +func SourceProfile() (sourced bool, err error) { + if profileSourced() { + return false, nil + } + sourceProfileMutex.Lock() + defer sourceProfileMutex.Unlock() + + if profileSourced() { + return false, nil + } + + shell := os.Getenv("SHELL") + if shell == "" { + shell = "sh" + } + shell, _ = exec.LookPath("sh") + if shell == "" { + shell = "/bin/sh" + } + + trySource := func(path string) error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + wantEnv := map[string]bool{ + "NIX_PROFILES": true, + "NIX_SSL_CERT_FILE": true, + "PATH": true, + "XDG_DATA_DIRS": true, + "__ETC_PROFILE_NIX_SOURCED": true, + } + script := fmt.Sprintf(". \"%s\"\n", path) + for name := range wantEnv { + script += fmt.Sprintf("echo %s=\"$%[1]s\"\n", name) + } + + cmd := exec.CommandContext(ctx, shell, "-e", "-c", script) + stdout, err := cmd.Output() + if err != nil { + return err + } + for _, line := range strings.Split(string(stdout), "\n") { + name, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + if wantEnv[name] { + err = os.Setenv(name, value) + if err != nil { + return err + } + delete(wantEnv, name) + } + } + return nil + } + + for _, path := range profilePaths() { + err = trySource(path) + if err == nil { + return true, nil + } + } + return false, fmt.Errorf("unable to source Nix profile") +} + +// profileSourced checks if the Nix profile shell scripts (such as +// /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh) have already been +// successfully sourced. +func profileSourced() bool { + // Check if we're already in a Nix environment. Use NIX_PROFILES instead + // of __ETC_PROFILE_NIX_SOURCED because it's set for single-user + // installs and on NixOS (whereas __ETC_PROFILE_NIX_SOURCED is not). + _, ok := os.LookupEnv("NIX_PROFILES") + return ok +} + +// profilePaths returns the paths where the Nix profile shell scripts might be. +// None of the paths are guaranteed to be readable or even exist. +func profilePaths() []string { + // os.UserHomeDir only checks $HOME, user.Current reads /etc/passwd or + // uses libc. This can help when running in an isolated environment + // where $HOME isn't set. + home, _ := os.UserHomeDir() + if home == "" { + if u, err := user.Current(); err == nil { + home = u.HomeDir + } + } + if home == "" { + // Might as well check the root home directory if we've got + // nothing else. + home = "/root" + } + xdgState := os.Getenv("XDG_STATE_HOME") + if xdgState == "" { + xdgState = filepath.Join(home, ".local/state") + } + + dirs := make([]string, 0, 5) + if nixExe, err := exec.LookPath("nix"); err == nil { + dirs = append(dirs, filepath.Clean(nixExe+"/../../etc/profile.d")) + } + if !slices.Contains(dirs, "/nix/var/nix/profiles/default/etc/profile.d") { + dirs = append(dirs, "/nix/var/nix/profiles/default/etc/profile.d") + } + dirs = append(dirs, + filepath.Join(home, ".nix-profile/etc/profile.d"), + filepath.Join(xdgState, "nix/profile/etc/profile.d"), + filepath.Join(xdgState, "nix/profiles/profile/etc/profile.d"), + ) + + // Try sourcing scripts in the following order: + // + // 1. nix-daemon.sh: because nix.sh is a no-op when $USER isn't set + // (this happens in containers). + // 2. nix-daemon.fish: same, but for fish users. + // 3. nix.sh, nix.fish: for old single-user installs. + files := make([]string, 0, len(dirs)*4) + for _, dir := range dirs { + files = append(files, filepath.Join(dir, "nix-daemon.sh")) + } + for _, dir := range dirs { + files = append(files, filepath.Join(dir, "nix-daemon.fish")) + } + for _, dir := range dirs { + files = append(files, filepath.Join(dir, "nix.sh")) + } + for _, dir := range dirs { + files = append(files, filepath.Join(dir, "nix.fish")) + } + return files +} diff --git a/nix/nix_test.go b/nix/nix_test.go new file mode 100644 index 00000000000..1604edf39a6 --- /dev/null +++ b/nix/nix_test.go @@ -0,0 +1,206 @@ +//nolint:dupl +package nix + +import ( + "slices" + "testing" +) + +func TestParseVersionInfo(t *testing.T) { + raw := `nix (Nix) 2.21.2 +System type: aarch64-darwin +Additional system types: x86_64-darwin +Features: gc, signed-caches +System configuration file: /etc/nix/nix.conf +User configuration files: /Users/nobody/.config/nix/nix.conf:/etc/xdg/nix/nix.conf +Store directory: /nix/store +State directory: /nix/var/nix +Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share +` + + info, err := parseInfo([]byte(raw)) + if err != nil { + t.Error("got parse error:", err) + } + if got, want := info.Name, "nix"; got != want { + t.Errorf("got Name = %q, want %q", got, want) + } + if got, want := info.Version, "2.21.2"; got != want { + t.Errorf("got Version = %q, want %q", got, want) + } + if got, want := info.System, "aarch64-darwin"; got != want { + t.Errorf("got System = %q, want %q", got, want) + } + if got, want := info.ExtraSystems, []string{"x86_64-darwin"}; !slices.Equal(got, want) { + t.Errorf("got ExtraSystems = %q, want %q", got, want) + } + if got, want := info.Features, []string{"gc", "signed-caches"}; !slices.Equal(got, want) { + t.Errorf("got Features = %q, want %q", got, want) + } + if got, want := info.SystemConfig, "/etc/nix/nix.conf"; got != want { + t.Errorf("got SystemConfig = %q, want %q", got, want) + } + if got, want := info.UserConfigs, []string{"/Users/nobody/.config/nix/nix.conf", "/etc/xdg/nix/nix.conf"}; !slices.Equal(got, want) { + t.Errorf("got UserConfigs = %q, want %q", got, want) + } + if got, want := info.StoreDir, "/nix/store"; got != want { + t.Errorf("got StoreDir = %q, want %q", got, want) + } + if got, want := info.StateDir, "/nix/var/nix"; got != want { + t.Errorf("got StateDir = %q, want %q", got, want) + } + if got, want := info.DataDir, "/nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share"; got != want { + t.Errorf("got DataDir = %q, want %q", got, want) + } +} + +func TestParseLixVersionInfo(t *testing.T) { + raw := `nix (Lix, like Nix) 2.90.0-beta.1 +System type: aarch64-darwin +Additional system types: x86_64-darwin +Features: gc, signed-caches +System configuration file: /etc/nix/nix.conf +User configuration files: /Users/nobody/.config/nix/nix.conf:/etc/xdg/nix/nix.conf +Store directory: /nix/store +State directory: /nix/var/nix +Data directory: /nix/store/12asl5a17ffj78njcy2fj31v59rdmanx-lix-2.90-beta.1/share +` + + info, err := parseInfo([]byte(raw)) + if err != nil { + t.Error("got parse error:", err) + } + if got, want := info.Name, "nix"; got != want { + t.Errorf("got Name = %q, want %q", got, want) + } + if got, want := info.Version, "2.90.0-beta.1"; got != want { + t.Errorf("got Version = %q, want %q", got, want) + } + if got, want := info.System, "aarch64-darwin"; got != want { + t.Errorf("got System = %q, want %q", got, want) + } + if got, want := info.ExtraSystems, []string{"x86_64-darwin"}; !slices.Equal(got, want) { + t.Errorf("got ExtraSystems = %q, want %q", got, want) + } + if got, want := info.Features, []string{"gc", "signed-caches"}; !slices.Equal(got, want) { + t.Errorf("got Features = %q, want %q", got, want) + } + if got, want := info.SystemConfig, "/etc/nix/nix.conf"; got != want { + t.Errorf("got SystemConfig = %q, want %q", got, want) + } + if got, want := info.UserConfigs, []string{"/Users/nobody/.config/nix/nix.conf", "/etc/xdg/nix/nix.conf"}; !slices.Equal(got, want) { + t.Errorf("got UserConfigs = %q, want %q", got, want) + } + if got, want := info.StoreDir, "/nix/store"; got != want { + t.Errorf("got StoreDir = %q, want %q", got, want) + } + if got, want := info.StateDir, "/nix/var/nix"; got != want { + t.Errorf("got StateDir = %q, want %q", got, want) + } + if got, want := info.DataDir, "/nix/store/12asl5a17ffj78njcy2fj31v59rdmanx-lix-2.90-beta.1/share"; got != want { + t.Errorf("got DataDir = %q, want %q", got, want) + } +} + +func TestParseVersionInfoShort(t *testing.T) { + cases := []struct { + in string + name string + version string + }{ + {"nix (Nix) 2.21.2", "nix", "2.21.2"}, + {"nix (Nix) 2.23.0pre20240526_7de033d6", "nix", "2.23.0pre20240526_7de033d6"}, + {"command (Nix) name (Nix) 2.21.2", "command (Nix) name", "2.21.2"}, + {"nix (Lix, like Nix) 2.90.0-beta.1", "nix", "2.90.0-beta.1"}, + } + + for _, tt := range cases { + t.Run(tt.in, func(t *testing.T) { + got, err := parseInfo([]byte(tt.in)) + if err != nil { + t.Error("got parse error:", err) + } + if got.Name != tt.name { + t.Errorf("got Name = %q, want %q", got.Name, tt.name) + } + if got.Version != tt.version { + t.Errorf("got Version = %q, want %q", got.Version, tt.version) + } + }) + } +} + +func TestParseVersionInfoError(t *testing.T) { + t.Run("NilOutput", func(t *testing.T) { + _, err := parseInfo(nil) + if err == nil { + t.Error("want non-nil error") + } + }) + t.Run("EmptyOutput", func(t *testing.T) { + _, err := parseInfo([]byte{}) + if err == nil { + t.Error("want non-nil error") + } + }) + t.Run("MissingVersionOutput", func(t *testing.T) { + _, err := parseInfo([]byte("nix output without a version")) + if err == nil { + t.Error("want non-nil error") + } + }) +} + +func TestVersionInfoAtLeast(t *testing.T) { + info := Info{} + if info.AtLeast(Version2_12) { + t.Errorf("got empty current version >= %s", Version2_12) + } + + info.Version = Version2_13 + if !info.AtLeast(Version2_12) { + t.Errorf("got %s < %s", info.Version, Version2_12) + } + if !info.AtLeast(Version2_13) { + t.Errorf("got %s < %s", info.Version, Version2_13) + } + if info.AtLeast(Version2_14) { + t.Errorf("got %s >= %s", info.Version, Version2_14) + } + + // https://github.com/jetify-com/devbox/issues/2128 + info.Version = "2.23.0pre20240526_7de033d6" + if !info.AtLeast(Version2_12) { + t.Errorf("got %s < %s", info.Version, Version2_12) + } + if info.AtLeast("2.23.0") { + t.Errorf("got %s > %s", info.Version, "2.23.0") + } + if info.AtLeast("2.24.0") { + t.Errorf("got %s > %s", info.Version, "2.24.0") + } + if info.AtLeast("2.23.0-pre.99999999") { + t.Errorf("got %s > %s", info.Version, "2.23.0-pre.99999999") + } + if !info.AtLeast("2.23.0-pre.1") { + t.Errorf("got %s < %s", info.Version, "2.23.0-pre.1") + } + + t.Run("ArgEmptyPanic", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("want panic for empty version") + } + }() + info.AtLeast("") + }) + t.Run("ArgInvalidPanic", func(t *testing.T) { + v := "notasemver" + defer func() { + if r := recover(); r == nil { + t.Errorf("want panic for invalid version %q", v) + } + }() + info.AtLeast(v) + }) +}