diff --git a/src/cmd/shim/fallback_test.go b/src/cmd/shim/fallback_test.go new file mode 100644 index 0000000..a4fff85 --- /dev/null +++ b/src/cmd/shim/fallback_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/path" +) + +// The tests in this file run the shim as real processes. TestMain turns the +// test binary into the shim when envRunAsShim is set, and each test copies +// the test binary into place under the name a shim would have. +const ( + envRunAsShim = "DTVEM_TEST_RUN_AS_SHIM" + // envFakeRealExe names the copy that acts as a real runtime executable + // instead of a shim. + envFakeRealExe = "DTVEM_TEST_FAKE_REAL_EXE" + // envSpawnAgain makes the fake real executable run the command again + // through PATH, once. + envSpawnAgain = "DTVEM_TEST_SPAWN_AGAIN" + envHops = "DTVEM_TEST_HOPS" + + // maxHops bounds the process chain, so a regression fails the test + // instead of exhausting the machine. + maxHops = 8 + exitTooManyHops = 99 + + fakeRealReached = "fake real executable reached" +) + +func TestMain(m *testing.M) { + if os.Getenv(envRunAsShim) == "" { + os.Exit(m.Run()) + } + + hops, _ := strconv.Atoi(os.Getenv(envHops)) + if hops >= maxHops { + fmt.Fprintln(os.Stderr, "test guard: process chain too deep") + os.Exit(exitTooManyHops) + } + _ = os.Setenv(envHops, strconv.Itoa(hops+1)) + + if path.SameFile(selfPath(), os.Getenv(envFakeRealExe)) { + os.Exit(runFakeRealExe()) + } + + main() + os.Exit(0) +} + +// runFakeRealExe stands in for a real runtime executable found on PATH and +// returns its exit code. +func runFakeRealExe() int { + fmt.Printf("fallback marker names this executable: %v\n", path.SameFile(os.Getenv(fallbackEnvVar), selfPath())) + + if os.Getenv(envSpawnAgain) != "" { + _ = os.Unsetenv(envSpawnAgain) + cmd := exec.Command(shimNameFromPath(os.Args[0])) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + fmt.Fprintf(os.Stderr, "fake real executable could not run the command again: %v\n", err) + return 1 + } + } + + fmt.Println(fakeRealReached) + return 0 +} + +// installTestBinary copies the running test binary to dir/name, with the +// .exe extension on Windows, and returns its path. +func installTestBinary(t *testing.T, dir, name string) string { + t.Helper() + + self, err := os.Executable() + if err != nil { + t.Fatalf("Failed to locate test binary: %v", err) + } + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create %s: %v", dir, err) + } + if runtime.GOOS == constants.OSWindows { + name += constants.ExtExe + } + dst := filepath.Join(dir, name) + + src, err := os.Open(self) + if err != nil { + t.Fatalf("Failed to open test binary: %v", err) + } + defer func() { _ = src.Close() }() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + t.Fatalf("Failed to create %s: %v", dst, err) + } + if _, err := io.Copy(out, src); err != nil { + _ = out.Close() + t.Fatalf("Failed to copy test binary to %s: %v", dst, err) + } + if err := out.Close(); err != nil { + t.Fatalf("Failed to close %s: %v", dst, err) + } + return dst +} + +// runShimProcess runs shim with PATH set to pathDirs and DTVEM_ROOT set to an +// empty install, so no version resolves and the shim takes its PATH +// fallback. It returns the combined output and the exit code. +func runShimProcess(t *testing.T, shim string, pathDirs []string, extraEnv ...string) (string, int) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, shim) + cmd.Dir = t.TempDir() + cmd.Env = append(shimTestEnv(), + "PATH="+strings.Join(pathDirs, string(os.PathListSeparator)), + "DTVEM_ROOT="+filepath.Join(t.TempDir(), "empty-root"), + envRunAsShim+"=1", + ) + cmd.Env = append(cmd.Env, extraEnv...) + + out, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("shim did not exit within the timeout; output:\n%s", out) + } + if err == nil { + return string(out), 0 + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Failed to run shim: %v", err) + } + return string(out), exitErr.ExitCode() +} + +// shimTestEnv returns the current environment without the variables +// runShimProcess sets, or that would leak state from an earlier hop. +func shimTestEnv() []string { + drop := []string{"PATH", "DTVEM_ROOT", "XDG_DATA_HOME", fallbackEnvVar, envRunAsShim, envFakeRealExe, envSpawnAgain, envHops} + + var env []string + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + keep := true + for _, d := range drop { + if strings.EqualFold(key, d) { + keep = false + break + } + } + if keep { + env = append(env, kv) + } + } + return env +} + +func assertNoRunawayChain(t *testing.T, out string, code int) { + t.Helper() + if code == exitTooManyHops || strings.Contains(out, "test guard") { + t.Fatalf("shim kept launching processes (exit %d); output:\n%s", code, out) + } +} + +func TestShimFallback_SkipsOwnDirectoryWhenHomeDiffers(t *testing.T) { + // The shims directory on PATH is not the one DTVEM_ROOT names, as when a + // sandbox redirects the home directory. Before the fix the shim found + // itself on PATH and launched itself forever. + shimsDir := filepath.Join(t.TempDir(), "install", "shims") + shim := installTestBinary(t, shimsDir, "node") + + out, code := runShimProcess(t, shim, []string{shimsDir}) + + assertNoRunawayChain(t, out, code) + if code != 1 { + t.Errorf("exit code = %d, want 1; output:\n%s", code, out) + } + if !strings.Contains(out, "No system installation found in PATH") { + t.Errorf("output does not report a missing system install:\n%s", out) + } +} + +func TestShimFallback_RunsRealExecutableFurtherDownPath(t *testing.T) { + tempDir := t.TempDir() + shimsDir := filepath.Join(tempDir, "install", "shims") + systemDir := filepath.Join(tempDir, "system") + shim := installTestBinary(t, shimsDir, "node") + realExe := installTestBinary(t, systemDir, "node") + + out, code := runShimProcess(t, shim, []string{shimsDir, systemDir}, envFakeRealExe+"="+realExe) + + assertNoRunawayChain(t, out, code) + if code != 0 { + t.Errorf("exit code = %d, want 0; output:\n%s", code, out) + } + if !strings.Contains(out, fakeRealReached) { + t.Errorf("real executable was not reached; output:\n%s", out) + } + if !strings.Contains(out, "fallback marker names this executable: true") { + t.Errorf("real executable did not receive %s naming it; output:\n%s", fallbackEnvVar, out) + } +} + +func TestShimFallback_StopsWhenFallbackIsAnotherShim(t *testing.T) { + // Two shim directories on PATH, neither the one DTVEM_ROOT names. The + // first shim skips its own directory and finds the second; without the + // guard the two would launch each other forever. + tempDir := t.TempDir() + shimsA := filepath.Join(tempDir, "a", "shims") + shimsB := filepath.Join(tempDir, "b", "shims") + shim := installTestBinary(t, shimsA, "node") + installTestBinary(t, shimsB, "node") + + out, code := runShimProcess(t, shim, []string{shimsA, shimsB}) + + assertNoRunawayChain(t, out, code) + if code != 1 { + t.Errorf("exit code = %d, want 1; output:\n%s", code, out) + } + if !strings.Contains(out, "Refusing to run 'node'") { + t.Errorf("output does not report the fallback loop:\n%s", out) + } +} + +func TestShimFallback_RealExecutableMayRunTheCommandAgain(t *testing.T) { + // A real node reached through the fallback starts `node` again, as a + // script spawning node does. That second shim inherits the fallback + // marker but is not the file it names, so it must run normally. + tempDir := t.TempDir() + shimsDir := filepath.Join(tempDir, "install", "shims") + systemDir := filepath.Join(tempDir, "system") + shim := installTestBinary(t, shimsDir, "node") + realExe := installTestBinary(t, systemDir, "node") + + out, code := runShimProcess(t, shim, []string{shimsDir, systemDir}, + envFakeRealExe+"="+realExe, + envSpawnAgain+"=1", + ) + + assertNoRunawayChain(t, out, code) + if code != 0 { + t.Errorf("exit code = %d, want 0; output:\n%s", code, out) + } + if strings.Contains(out, "Refusing to run") { + t.Errorf("nested launch was mistaken for a fallback loop; output:\n%s", out) + } + if got := strings.Count(out, fakeRealReached); got != 2 { + t.Errorf("real executable reached %d times, want 2; output:\n%s", got, out) + } +} diff --git a/src/cmd/shim/main.go b/src/cmd/shim/main.go index ac4c533..d66efee 100644 --- a/src/cmd/shim/main.go +++ b/src/cmd/shim/main.go @@ -33,12 +33,26 @@ func main() { } } +// fallbackEnvVar names the environment variable a shim sets before handing +// off to an executable found on PATH. Its value is the path being launched, +// which lets a shim tell that it was itself the target of that hand-off. +const fallbackEnvVar = "DTVEM_SHIM_FALLBACK" + func runShim() error { // Get the name of this shim (e.g., "python", "node", "npm") shimName := getShimName() ui.Debug("Shim invoked: %s", shimName) ui.Debug("Arguments: %v", os.Args[1:]) + // Stop if a shim's PATH fallback launched this shim. Running on would + // fail to resolve the same way and fall back again, one process per + // hop, without end. + if target := os.Getenv(fallbackEnvVar); target != "" { + if self := selfPath(); path.SameFile(target, self) { + return fallbackLoopError(shimName, self) + } + } + // Determine which runtime this shim belongs to runtimeName, known := mapShimToRuntime(shimName) if !known { @@ -147,7 +161,7 @@ func runShim() error { // It attempts to fallback to system PATH or prompts for installation func handleNoConfiguredVersion(shimName, runtimeName string, provider runtime.ShimProvider) error { // Try to find the executable deeper in PATH (system installation) - systemPath := path.LookPathExcludingShims(shimName) + systemPath := path.LookPathExcludingSelf(shimName, selfPath()) if systemPath != "" { // Found system installation - use it @@ -158,7 +172,7 @@ func handleNoConfiguredVersion(shimName, runtimeName string, provider runtime.Sh fmt.Fprintln(os.Stderr) // Empty line for spacing // Execute the system version (no provider env needed for system installations) - if err := executeCommand(systemPath, os.Args[1:], nil); err != nil { + if err := executeFallback(systemPath); err != nil { return fmt.Errorf("failed to execute system %s: %w", shimName, err) } return nil @@ -266,7 +280,7 @@ func mapShimToRuntime(shimName string) (string, bool) { // piping, and it names the fix so the state is visible rather than // silently permanent. func handleUnknownShim(shimName string) error { - systemPath := path.LookPathExcludingShims(shimName) + systemPath := path.LookPathExcludingSelf(shimName, selfPath()) if systemPath == "" { ui.Error("'%s' is not managed by dtvem and was not found in PATH", shimName) ui.Info("This is a leftover dtvem shim. Run 'dtvem reshim' to remove it.") @@ -277,12 +291,52 @@ func handleUnknownShim(shimName string) error { ui.Info("Run 'dtvem reshim' to remove it.") fmt.Fprintln(os.Stderr) // Empty line for spacing - if err := executeCommand(systemPath, os.Args[1:], nil); err != nil { + if err := executeFallback(systemPath); err != nil { return fmt.Errorf("failed to execute %s: %w", systemPath, err) } return nil } +// selfPath returns the path of the running shim executable with symlinks +// resolved, or "" if it cannot be determined. +func selfPath() string { + self, err := os.Executable() + if err != nil { + ui.Debug("Could not determine shim path: %v", err) + return "" + } + if resolved, err := filepath.EvalSymlinks(self); err == nil { + return resolved + } + return self +} + +// executeFallback runs an executable found on PATH in place of a dtvem +// version. It records the target in fallbackEnvVar first, so that if the +// target turns out to be a dtvem shim after all, that shim recognizes it +// was launched as its own fallback and stops instead of looping. +// +// The marker names a file rather than a shim name on purpose: a real +// executable reached this way may legitimately start the same command +// again (node running a script that spawns node), and that shim must not +// mistake itself for the loop. +func executeFallback(systemPath string) error { + if err := os.Setenv(fallbackEnvVar, systemPath); err != nil { + return fmt.Errorf("could not set %s: %w", fallbackEnvVar, err) + } + return executeCommand(systemPath, os.Args[1:], nil) +} + +// fallbackLoopError reports that a shim was launched by another shim's PATH +// fallback, and explains the usual cause. +func fallbackLoopError(shimName, self string) error { + ui.Error("Refusing to run '%s': it was launched as a dtvem shim's PATH fallback (%s)", shimName, self) + ui.Info("dtvem could not resolve '%s' in this environment and found another dtvem shim further down PATH.", shimName) + ui.Info("This usually means HOME/USERPROFILE or DTVEM_ROOT points somewhere other than the dtvem install whose shims are on PATH.") + ui.Info("Set DTVEM_ROOT to that install, or put a real '%s' ahead of the dtvem shims on PATH.", shimName) + return fmt.Errorf("%s shim was launched as its own PATH fallback", shimName) +} + // secondaryExecutableError formats a user-facing error explaining that a // secondary executable shim (e.g., uv, pip) exists but the binary cannot // be located in the active runtime version. This is the catch-all when diff --git a/src/internal/config/paths.go b/src/internal/config/paths.go index cdac977..0ed41d6 100644 --- a/src/internal/config/paths.go +++ b/src/internal/config/paths.go @@ -17,6 +17,7 @@ type Paths struct { Versions string // Versions directory (~/.dtvem/versions) Config string // Config directory (~/.dtvem/config) Cache string // Cache directory (~/.dtvem/cache) + Temp string // Staging directory for in-progress installs (~/.dtvem/tmp) } var ( @@ -42,6 +43,7 @@ func initPaths() *Paths { Versions: filepath.Join(root, "versions"), Config: filepath.Join(root, "config"), Cache: filepath.Join(root, "cache"), + Temp: filepath.Join(root, "tmp"), } } @@ -111,6 +113,31 @@ func RuntimeVersionPath(runtimeName, version string) string { return filepath.Join(paths.Versions, runtimeName, version) } +// StagingPath returns the scratch directory used to download and extract a +// runtime version before it is moved into the versions directory. +// +// It lives under the dtvem root rather than the OS temp directory so the final +// move is a same-volume rename: os.Rename cannot move a directory across drives +// on Windows, or across filesystems on Unix (e.g. a tmpfs-backed /tmp). +func StagingPath(runtimeName, version string) string { + paths := DefaultPaths() + return filepath.Join(paths.Temp, runtimeName+"-"+version) +} + +// CreateStagingDir creates an empty staging directory for a runtime version +// and returns its path. Leftovers from an earlier interrupted install are +// removed first, since nothing else cleans up the dtvem root. +func CreateStagingDir(runtimeName, version string) (string, error) { + dir := StagingPath(runtimeName, version) + if err := os.RemoveAll(dir); err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + return dir, nil +} + // GlobalConfigPath returns the path to the global config file func GlobalConfigPath() string { paths := DefaultPaths() diff --git a/src/internal/config/paths_test.go b/src/internal/config/paths_test.go index 3b3086a..fa124ef 100644 --- a/src/internal/config/paths_test.go +++ b/src/internal/config/paths_test.go @@ -48,6 +48,96 @@ func TestGetPaths(t *testing.T) { if !strings.HasPrefix(paths.Config, paths.Root) { t.Errorf("Config path %q should be under Root %q", paths.Config, paths.Root) } + if !strings.HasPrefix(paths.Temp, paths.Root) { + t.Errorf("Temp path %q should be under Root %q", paths.Temp, paths.Root) + } +} + +// useTempRoot points DTVEM_ROOT at a fresh temp directory for the duration of +// the test and returns it. +func useTempRoot(t *testing.T) string { + t.Helper() + root := t.TempDir() + t.Setenv("DTVEM_ROOT", root) + resetPathsForTesting() + t.Cleanup(resetPathsForTesting) + return root +} + +func TestStagingPath(t *testing.T) { + root := useTempRoot(t) + + got := StagingPath("node", "24.22.0") + want := filepath.Join(root, "tmp", "node-24.22.0") + if got != want { + t.Errorf("StagingPath(%q, %q) = %q, want %q", "node", "24.22.0", got, want) + } +} + +func TestStagingPath_SameVolumeAsVersions(t *testing.T) { + // Installs finish with os.Rename from the staging path to the version + // path, which fails across drives on Windows (issue #278). Both must + // resolve under the dtvem root, never the OS temp directory. + root := useTempRoot(t) + + staging := StagingPath("python", "3.12.0") + install := RuntimeVersionPath("python", "3.12.0") + + if filepath.VolumeName(staging) != filepath.VolumeName(install) { + t.Errorf("StagingPath volume %q differs from RuntimeVersionPath volume %q", + filepath.VolumeName(staging), filepath.VolumeName(install)) + } + if !strings.HasPrefix(staging, root) { + t.Errorf("StagingPath() = %q, should be under root %q", staging, root) + } + if strings.HasPrefix(staging, DefaultPaths().Versions) { + t.Errorf("StagingPath() = %q, should not be under the versions directory", staging) + } +} + +func TestCreateStagingDir(t *testing.T) { + useTempRoot(t) + + dir, err := CreateStagingDir("ruby", "3.3.0") + if err != nil { + t.Fatalf("CreateStagingDir() error: %v", err) + } + if want := StagingPath("ruby", "3.3.0"); dir != want { + t.Errorf("CreateStagingDir() = %q, want %q", dir, want) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("staging directory was not created: %v", err) + } + if !info.IsDir() { + t.Errorf("staging path %q is not a directory", dir) + } +} + +func TestCreateStagingDir_RemovesLeftovers(t *testing.T) { + useTempRoot(t) + + stale := filepath.Join(StagingPath("node", "22.0.0"), "extracted", "node.exe") + if err := os.MkdirAll(filepath.Dir(stale), 0755); err != nil { + t.Fatalf("failed to create stale directory: %v", err) + } + if err := os.WriteFile(stale, []byte("stale"), 0644); err != nil { + t.Fatalf("failed to create stale file: %v", err) + } + + dir, err := CreateStagingDir("node", "22.0.0") + if err != nil { + t.Fatalf("CreateStagingDir() error: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("failed to read staging directory: %v", err) + } + if len(entries) != 0 { + t.Errorf("CreateStagingDir() left %d stale entries in %q", len(entries), dir) + } } func TestRuntimeVersionPath(t *testing.T) { diff --git a/src/internal/path/path.go b/src/internal/path/path.go index 210a130..ceabb34 100644 --- a/src/internal/path/path.go +++ b/src/internal/path/path.go @@ -314,35 +314,96 @@ func ShimsDir() string { // This prevents detecting our own shims as "system" installations during migration detection. // Returns the full path to the executable, or empty string if not found. func LookPathExcludingShims(execName string) string { - // Get the shims directory to exclude it from search - shimsDir := ShimsDir() + return lookPathExcluding(execName, "") +} + +// LookPathExcludingSelf searches PATH like LookPathExcludingShims, and also +// skips the directory containing self and any candidate that is the same +// file as self. +// +// A shim uses this for its PATH fallback. ShimsDir is computed from the +// environment (DTVEM_ROOT, otherwise the home directory), so when a process +// runs with a different home than the one the shims on PATH were installed +// under, ShimsDir names some other directory and the shim would find +// itself. Excluding the shim's own location holds whatever the environment +// says. +func LookPathExcludingSelf(execName, self string) string { + return lookPathExcluding(execName, self) +} - // Get PATH environment variable +// lookPathExcluding searches PATH for execName, skipping the shims directory +// and, when self is set, self's directory and any candidate that is self. +func lookPathExcluding(execName, self string) string { pathEnv := os.Getenv("PATH") if pathEnv == "" { return "" } - // Split PATH into directories - pathDirs := filepath.SplitList(pathEnv) + skipDirs := []string{ShimsDir()} + if self != "" { + skipDirs = append(skipDirs, filepath.Dir(self)) + } - // Search each directory - for _, dir := range pathDirs { - // Skip the dtvem shims directory (case-insensitive on Windows) - if strings.EqualFold(dir, shimsDir) { + for _, dir := range filepath.SplitList(pathEnv) { + if isAnyDir(dir, skipDirs) { continue } - // Try to find the executable in this directory candidatePath := findExecutableInDir(dir, execName) - if candidatePath != "" { - return candidatePath + if candidatePath == "" { + continue + } + if self != "" && SameFile(candidatePath, self) { + continue } + return candidatePath } return "" } +// isAnyDir reports whether dir names the same directory as any of dirs. +func isAnyDir(dir string, dirs []string) bool { + for _, d := range dirs { + if isSameDir(dir, d) { + return true + } + } + return false +} + +// isSameDir reports whether a and b name the same directory. Paths that +// differ only in case or cleaning (a trailing separator, "." segments) match +// without touching the disk; otherwise both are stat'ed so that different +// spellings of one directory, such as a Windows 8.3 short name or a +// symlinked path, still match. +func isSameDir(a, b string) bool { + if a == "" || b == "" { + return false + } + if strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) { + return true + } + return SameFile(a, b) +} + +// SameFile reports whether a and b refer to the same file or directory on +// disk. It returns false when either path is empty or cannot be stat'ed. +func SameFile(a, b string) bool { + if a == "" || b == "" { + return false + } + infoA, err := os.Stat(a) + if err != nil { + return false + } + infoB, err := os.Stat(b) + if err != nil { + return false + } + return os.SameFile(infoA, infoB) +} + // findExecutableInDir looks for an executable with the given name in a directory. // On Windows, it tries .exe, .cmd, .bat extensions. // On Unix, it checks if the file exists and has execute permission. diff --git a/src/internal/path/path_test.go b/src/internal/path/path_test.go index 1d50bb8..2fbfc1a 100644 --- a/src/internal/path/path_test.go +++ b/src/internal/path/path_test.go @@ -372,6 +372,163 @@ func TestLookPathExcludingShims_SkipsShimsDir(t *testing.T) { }) } +// writeTestExecutable creates a dummy executable named name in dir, with the +// .exe extension on Windows, and returns its path. +func writeTestExecutable(t *testing.T, dir, name string) string { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create %s: %v", dir, err) + } + if runtime.GOOS == constants.OSWindows { + name += constants.ExtExe + } + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(dir), 0755); err != nil { + t.Fatalf("Failed to create %s: %v", p, err) + } + return p +} + +func TestLookPathExcludingSelf(t *testing.T) { + tempDir := t.TempDir() + // ShimsDir() points at a different install than the one on PATH, as it + // does when a process runs with a redirected home directory. + t.Setenv("DTVEM_ROOT", filepath.Join(tempDir, "other-root")) + + execName := "selftest" + shimsDir := filepath.Join(tempDir, "install", "shims") + systemDir := filepath.Join(tempDir, "system") + self := writeTestExecutable(t, shimsDir, execName) + systemExec := writeTestExecutable(t, systemDir, execName) + + t.Run("skips its own directory and finds the system install", func(t *testing.T) { + t.Setenv("PATH", strings.Join([]string{shimsDir, systemDir}, string(os.PathListSeparator))) + + if got := LookPathExcludingSelf(execName, self); got != systemExec { + t.Errorf("LookPathExcludingSelf(%q, %q) = %q, want %q", execName, self, got, systemExec) + } + }) + + t.Run("returns empty when only its own directory has the executable", func(t *testing.T) { + t.Setenv("PATH", shimsDir) + + if got := LookPathExcludingSelf(execName, self); got != "" { + t.Errorf("LookPathExcludingSelf(%q, %q) = %q, want empty", execName, self, got) + } + }) + + t.Run("skips a hard link to itself in another directory", func(t *testing.T) { + linkDir := filepath.Join(tempDir, "linked") + if err := os.MkdirAll(linkDir, 0755); err != nil { + t.Fatalf("Failed to create link dir: %v", err) + } + link := filepath.Join(linkDir, filepath.Base(self)) + if err := os.Link(self, link); err != nil { + t.Skipf("hard links not supported here: %v", err) + } + t.Setenv("PATH", strings.Join([]string{linkDir, systemDir}, string(os.PathListSeparator))) + + if got := LookPathExcludingSelf(execName, self); got != systemExec { + t.Errorf("LookPathExcludingSelf(%q, %q) = %q, want %q", execName, self, got, systemExec) + } + }) + + t.Run("without a self path the mismatched shims directory is not skipped", func(t *testing.T) { + t.Setenv("PATH", strings.Join([]string{shimsDir, systemDir}, string(os.PathListSeparator))) + + // This is the lookup that made a shim launch itself. + if got := LookPathExcludingSelf(execName, ""); got != self { + t.Errorf("LookPathExcludingSelf(%q, \"\") = %q, want %q", execName, got, self) + } + }) +} + +func TestLookPathExcludingShims_SkipsShimsDirSpelledDifferently(t *testing.T) { + tempDir := t.TempDir() + root := filepath.Join(tempDir, "root") + t.Setenv("DTVEM_ROOT", root) + + execName := "spelltest" + shimsDir := filepath.Join(root, "shims") + systemDir := filepath.Join(tempDir, "system") + writeTestExecutable(t, shimsDir, execName) + systemExec := writeTestExecutable(t, systemDir, execName) + + tests := []struct { + name string + shimsDir string + }{ + {name: "trailing separator", shimsDir: shimsDir + string(os.PathSeparator)}, + {name: "dot segment", shimsDir: filepath.Join(root, ".") + string(os.PathSeparator) + "." + string(os.PathSeparator) + "shims"}, + } + + if runtime.GOOS == constants.OSWindows { + tests = append(tests, struct { + name string + shimsDir string + }{name: "different case", shimsDir: strings.ToUpper(shimsDir)}) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("PATH", strings.Join([]string{tt.shimsDir, systemDir}, string(os.PathListSeparator))) + + if got := LookPathExcludingShims(execName); got != systemExec { + t.Errorf("LookPathExcludingShims(%q) with PATH entry %q = %q, want %q", execName, tt.shimsDir, got, systemExec) + } + }) + } + + t.Run("symlinked directory", func(t *testing.T) { + alias := filepath.Join(tempDir, "alias") + if err := os.Symlink(shimsDir, alias); err != nil { + t.Skipf("symlinks not supported here: %v", err) + } + t.Setenv("PATH", strings.Join([]string{alias, systemDir}, string(os.PathListSeparator))) + + if got := LookPathExcludingShims(execName); got != systemExec { + t.Errorf("LookPathExcludingShims(%q) with PATH entry %q = %q, want %q", execName, alias, got, systemExec) + } + }) +} + +func TestSameFile(t *testing.T) { + tempDir := t.TempDir() + a := writeTestExecutable(t, filepath.Join(tempDir, "a"), "file") + b := writeTestExecutable(t, filepath.Join(tempDir, "b"), "file") + + tests := []struct { + name string + x, y string + want bool + }{ + {name: "same path", x: a, y: a, want: true}, + {name: "same file spelled differently", x: a, y: filepath.Join(tempDir, "b", "..", "a", filepath.Base(a)), want: true}, + {name: "same directory", x: filepath.Join(tempDir, "a"), y: filepath.Join(tempDir, "a") + string(os.PathSeparator), want: true}, + {name: "different files", x: a, y: b, want: false}, + {name: "missing file", x: a, y: filepath.Join(tempDir, "missing"), want: false}, + {name: "empty path", x: a, y: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SameFile(tt.x, tt.y); got != tt.want { + t.Errorf("SameFile(%q, %q) = %v, want %v", tt.x, tt.y, got, tt.want) + } + }) + } + + t.Run("hard link", func(t *testing.T) { + link := filepath.Join(tempDir, "link") + if err := os.Link(a, link); err != nil { + t.Skipf("hard links not supported here: %v", err) + } + if !SameFile(a, link) { + t.Errorf("SameFile(%q, %q) = false, want true", a, link) + } + }) +} + func TestIsDtvemShimsPath(t *testing.T) { // Platform-specific path separator handling: filepath.Join produces // backslashes on Windows and forward slashes on Unix, which matches what diff --git a/src/runtimes/node/provider_full.go b/src/runtimes/node/provider_full.go index a2ad6f5..494c0b3 100644 --- a/src/runtimes/node/provider_full.go +++ b/src/runtimes/node/provider_full.go @@ -43,8 +43,8 @@ func (p *Provider) Install(version string) error { ui.Progress("Downloading from %s", downloadURL) - tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-node-%s", version)) - if err := os.MkdirAll(tempDir, 0755); err != nil { + tempDir, err := config.CreateStagingDir("node", version) + if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer func() { _ = os.RemoveAll(tempDir) }() diff --git a/src/runtimes/python/provider_full.go b/src/runtimes/python/provider_full.go index 5b0bb36..128e0cb 100644 --- a/src/runtimes/python/provider_full.go +++ b/src/runtimes/python/provider_full.go @@ -29,8 +29,8 @@ import ( func (p *Provider) downloadAndExtract(version, downloadURL, archiveName string) (extractDir string, cleanup func(), err error) { ui.Progress("Downloading from %s", downloadURL) - tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-python-%s", version)) - if err := os.MkdirAll(tempDir, 0755); err != nil { + tempDir, err := config.CreateStagingDir("python", version) + if err != nil { return "", nil, fmt.Errorf("failed to create temp directory: %w", err) } diff --git a/src/runtimes/ruby/provider_full.go b/src/runtimes/ruby/provider_full.go index 73e5747..b36160f 100644 --- a/src/runtimes/ruby/provider_full.go +++ b/src/runtimes/ruby/provider_full.go @@ -84,8 +84,8 @@ func (p *Provider) Install(version string) error { func (p *Provider) downloadAndExtract(version, downloadURL, archiveName string) (extractDir string, cleanup func(), err error) { ui.Progress("Downloading from %s", downloadURL) - tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-ruby-%s", version)) - if err := os.MkdirAll(tempDir, 0755); err != nil { + tempDir, err := config.CreateStagingDir("ruby", version) + if err != nil { return "", nil, fmt.Errorf("failed to create temp directory: %w", err) }