From 1caf9f7bcb2ec9148a46bdf2534bff2fe213fa7d Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Sat, 17 Oct 2026 13:48:00 -0400 Subject: [PATCH 1/2] fix(install): stage runtime downloads under the dtvem root Installs downloaded and extracted into os.TempDir() and then used os.Rename to move the result into the versions directory. When the dtvem root lives on a different drive than the OS temp directory (e.g. DTVEM_ROOT on H: with Windows on C:), the rename fails with "The system cannot move the file to a different disk drive". The same happens on Unix when /tmp is a separate filesystem such as tmpfs. Stage installs in /tmp/- instead, so the final move is always a same-volume rename. Leftovers from an interrupted install are cleared before reuse since the OS no longer cleans them. --- src/internal/config/paths.go | 27 +++++++++ src/internal/config/paths_test.go | 90 ++++++++++++++++++++++++++++ src/runtimes/node/provider_full.go | 4 +- src/runtimes/python/provider_full.go | 4 +- src/runtimes/ruby/provider_full.go | 4 +- 5 files changed, 123 insertions(+), 6 deletions(-) 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/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) } From 3849f93332e437ec46f79486f70388aa96c92bae Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Sat, 17 Oct 2026 13:49:59 -0400 Subject: [PATCH 2/2] fix(shim): stop a shim from launching itself as its PATH fallback When a shim cannot resolve a version it falls back to the next match on PATH, skipping only ShimsDir(). ShimsDir() is derived from DTVEM_ROOT or the home directory, so when a process runs with a different home (a sandbox redirecting USERPROFILE, or no USERPROFILE at all) the real shims directory was not skipped. The shim found itself and launched itself, one process per hop, until the machine ran out of memory. The fallback lookup now also skips the directory of the running shim and any candidate that is the same file, whatever the environment says the home is. Directory comparison also matches different spellings of the same directory (trailing separator, 8.3 short names, symlinks) via os.SameFile. As a backstop, a shim records the path it hands off to in DTVEM_SHIM_FALLBACK. A shim that starts and finds itself named there was launched by another shim's fallback and exits with an explanation instead of looping. The marker names a file rather than a shim name so a real executable reached this way can still start the same command again. --- src/cmd/shim/fallback_test.go | 269 +++++++++++++++++++++++++++++++++ src/cmd/shim/main.go | 62 +++++++- src/internal/path/path.go | 85 +++++++++-- src/internal/path/path_test.go | 157 +++++++++++++++++++ 4 files changed, 557 insertions(+), 16 deletions(-) create mode 100644 src/cmd/shim/fallback_test.go 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/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