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) }