From 1caf9f7bcb2ec9148a46bdf2534bff2fe213fa7d Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Sat, 17 Oct 2026 13:48:00 -0400 Subject: [PATCH] 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) }