diff --git a/config/config.go b/config/config.go index 8177723..59ff0d0 100644 --- a/config/config.go +++ b/config/config.go @@ -9,7 +9,7 @@ func Prepare(key, value *string) (skip bool, deferredFn func(), err error) { case "root": deferredFn, err = processRoot(value) - if err != nil && strings.Contains(err.Error(), "blocked") { + if err != nil && (strings.Contains(err.Error(), "blocked") || strings.Contains(err.Error(), "NVM4313")) { skip = true } diff --git a/config/go.mod b/config/go.mod index d0c7cfd..886594a 100644 --- a/config/go.mod +++ b/config/go.mod @@ -2,3 +2,24 @@ module common/config go 1.26.2 +replace common/fs v1.0.0 => ../fs + +replace common/settings v1.0.0 => ../settings + +replace common/preferences v1.0.0 => ../preferences + +replace common/registry v1.0.0 => ../registry + +replace common/urlguard v1.0.0 => ../urlguard + +require ( + common/fs v1.0.0 + common/settings v1.0.0 +) + +require ( + common/preferences v1.0.0 // indirect + common/registry v1.0.0 // indirect + common/urlguard v1.0.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/config/go.sum b/config/go.sum new file mode 100644 index 0000000..cdc9b1c --- /dev/null +++ b/config/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/config/root.go b/config/root.go index df249ae..c20b535 100644 --- a/config/root.go +++ b/config/root.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "common/fs" "common/settings" ) @@ -21,6 +22,10 @@ func processRoot(value *string) (deferredFn func(), err error) { sourceRoot := filepath.Clean(s.Root) targetRoot := filepath.Clean(settings.Expand(*value)) + if err = fs.CheckInstallRootAllowed(targetRoot); err != nil { + return + } + if strings.EqualFold(sourceRoot, targetRoot) { err = fmt.Errorf("root value is already %s", *value) return @@ -44,7 +49,7 @@ func processRoot(value *string) (deferredFn func(), err error) { return } - fmt.Println("\nIf you wish to use the same Node.js versions in the new root, please move the following directories\n") + fmt.Println("\nIf you wish to use the same Node.js versions in the new root, please move the following directories") for _, v := range versions { fmt.Printf(" %s\n", v) } diff --git a/fs/install_root.go b/fs/install_root.go new file mode 100644 index 0000000..acc2977 --- /dev/null +++ b/fs/install_root.go @@ -0,0 +1,24 @@ +package fs + +// InstallRootDeniedEvent is the stable event code for a blocked Node.js storage path. +const InstallRootDeniedEvent = "NVM4313" + +// InstallRootDeniedMessage explains why a Node.js storage path is refused. +func InstallRootDeniedMessage() string { + return "This path is not a safe Node.js storage location.\n" + + "\n" + + "Node.js versions must live in a dedicated directory. Uninstall removes that folder. " + + "Storing versions in Windows, Program Files, your user profile, or other system folders can hide or delete other software.\n" + + "\n" + + "Disallowed locations include:\n" + + "- Windows, System32, WinSxS, Program Files, Program Files (x86), ProgramData, WindowsApps, Recycle Bin\n" + + "- Drive roots (C:\\)\n" + + "- Your user profile, AppData, Desktop, Documents, Downloads, Pictures, Videos, Music\n" + + "- Start Menu, Startup, LocalAppData\\Programs, LocalAppData\\Microsoft\n" + + "- The NVM program folder, .shim, .nodejs, and utils\n" + + "- Any parent of those folders (for example C:\\Users)\n" + + "\n" + + "Use a dedicated directory. Default: %LOCALAPPDATA%\\Author Software\\nvm\\installs\n" + + "\n" + + "Event code: " + InstallRootDeniedEvent +} diff --git a/fs/install_root_other.go b/fs/install_root_other.go new file mode 100644 index 0000000..091ab7d --- /dev/null +++ b/fs/install_root_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package fs + +import "strings" + +// CheckInstallRootAllowed is a no-op off Windows. +func CheckInstallRootAllowed(path string) error { return nil } + +// IsSafeToDeleteInstallRoot reports whether uninstall may DelTree the storage path. +func IsSafeToDeleteInstallRoot(path string) bool { + return strings.TrimSpace(path) != "" +} diff --git a/fs/install_root_windows.go b/fs/install_root_windows.go new file mode 100644 index 0000000..99f08ab --- /dev/null +++ b/fs/install_root_windows.go @@ -0,0 +1,295 @@ +//go:build windows + +package fs + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +const kfDontVerify uint32 = 0x00004000 + +type installRootProtect struct { + subtree []string + exact []string +} + +// CheckInstallRootAllowed refuses Node.js storage in OS, profile, or NVM program folders. +func CheckInstallRootAllowed(path string) error { + abs, err := normalizeInstallRootPath(path) + if err != nil { + return fmt.Errorf("%s\n\nRequested path: %s", InstallRootDeniedMessage(), strings.TrimSpace(path)) + } + if installRootDenied(abs, defaultInstallRootProtect()) { + return fmt.Errorf("%s\n\nRequested path: %s", InstallRootDeniedMessage(), abs) + } + return nil +} + +// IsSafeToDeleteInstallRoot reports whether uninstall may DelTree the storage path. +func IsSafeToDeleteInstallRoot(path string) bool { + return CheckInstallRootAllowed(path) == nil +} + +func normalizeInstallRootPath(path string) (string, error) { + path = strings.TrimSpace(path) + path = strings.Trim(path, `"'`) + if path == "" { + return "", fmt.Errorf("empty path") + } + path = os.ExpandEnv(path) + path = strings.TrimSpace(path) + if path == "" { + return "", fmt.Errorf("empty path") + } + path = stripExtendedPathPrefix(path) + if isUNCPath(path) { + return "", fmt.Errorf("unc path") + } + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func stripExtendedPathPrefix(path string) string { + if len(path) >= 4 && path[:4] == `\\?\` { + rest := path[4:] + if len(rest) >= 4 && strings.EqualFold(rest[:4], `UNC\`) { + return `\\` + rest[4:] + } + return rest + } + return path +} + +func isUNCPath(path string) bool { + if len(path) < 2 || path[0] != '\\' || path[1] != '\\' { + return false + } + if len(path) >= 4 && path[0] == '\\' && path[1] == '\\' && path[2] == '?' && path[3] == '\\' { + return false + } + return true +} + +func defaultInstallRootProtect() installRootProtect { + var p installRootProtect + + addSubtree := func(paths ...string) { + for _, path := range paths { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + continue + } + p.subtree = append(p.subtree, path) + } + } + addExact := func(paths ...string) { + for _, path := range paths { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + continue + } + p.exact = append(p.exact, path) + } + } + + win := knownFolder(windows.FOLDERID_Windows, "SystemRoot", "windir") + sys := knownFolder(windows.FOLDERID_System) + sysx86 := knownFolder(windows.FOLDERID_SystemX86) + pf := knownFolder(windows.FOLDERID_ProgramFiles, "ProgramFiles", "ProgramW6432") + pf86 := knownFolder(windows.FOLDERID_ProgramFilesX86, "ProgramFiles(x86)") + pfCommon := knownFolder(windows.FOLDERID_ProgramFilesCommon) + pfCommon86 := knownFolder(windows.FOLDERID_ProgramFilesCommonX86) + programData := knownFolder(windows.FOLDERID_ProgramData, "ProgramData", "ALLUSERSPROFILE") + + addSubtree(win, sys, sysx86, pf, pf86, pfCommon, pfCommon86, programData) + if win != "" { + addSubtree(filepath.Join(win, "WinSxS")) + } + if pf != "" { + addSubtree(filepath.Join(pf, "WindowsApps")) + } + if pf86 != "" { + addSubtree(filepath.Join(pf86, "WindowsApps")) + } + + addExact(driveRoots()...) + for _, root := range driveRoots() { + addExact(filepath.Join(root, "$Recycle.Bin")) + } + + profile := knownFolder(windows.FOLDERID_Profile, "USERPROFILE") + local := knownFolder(windows.FOLDERID_LocalAppData, "LOCALAPPDATA") + roaming := knownFolder(windows.FOLDERID_RoamingAppData, "APPDATA") + localLow := knownFolder(windows.FOLDERID_LocalAppDataLow) + publicDir := knownFolder(windows.FOLDERID_Public, "PUBLIC") + userPrograms := knownFolder(windows.FOLDERID_UserProgramFiles) + + addExact( + profile, + local, + roaming, + localLow, + publicDir, + knownFolder(windows.FOLDERID_Desktop), + knownFolder(windows.FOLDERID_Documents), + knownFolder(windows.FOLDERID_Downloads), + knownFolder(windows.FOLDERID_Pictures), + knownFolder(windows.FOLDERID_Videos), + knownFolder(windows.FOLDERID_Music), + knownFolder(windows.FOLDERID_PublicDesktop), + knownFolder(windows.FOLDERID_PublicDocuments), + knownFolder(windows.FOLDERID_PublicDownloads), + knownFolder(windows.FOLDERID_PublicPictures), + knownFolder(windows.FOLDERID_PublicVideos), + knownFolder(windows.FOLDERID_PublicMusic), + knownFolder(windows.FOLDERID_StartMenu), + knownFolder(windows.FOLDERID_CommonStartMenu), + knownFolder(windows.FOLDERID_Startup), + knownFolder(windows.FOLDERID_CommonStartup), + userPrograms, + ) + if local != "" { + addExact(filepath.Join(local, "Microsoft")) + if userPrograms == "" { + addExact(filepath.Join(local, "Programs")) + } + } + if profile != "" { + addExact( + filepath.Join(profile, "Downloads"), + filepath.Join(profile, "Desktop"), + filepath.Join(profile, "Documents"), + filepath.Join(profile, "Pictures"), + filepath.Join(profile, "Videos"), + filepath.Join(profile, "Music"), + ) + } + + for _, root := range nvmProgramRoots(local, pf, pf86) { + addExact(root) + addExact( + filepath.Join(root, ".shim"), + filepath.Join(root, ".nodejs"), + filepath.Join(root, "utils"), + ) + } + + return p +} + +func nvmProgramRoots(local, pf, pf86 string) []string { + roots := make([]string, 0, 4) + add := func(path string) { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + return + } + for _, existing := range roots { + if equalFoldPath(existing, path) { + return + } + } + roots = append(roots, path) + } + if local != "" { + add(filepath.Join(local, "Author Software", "nvm")) + } + if pf != "" { + add(filepath.Join(pf, "Author Software", "nvm")) + } + if pf86 != "" { + add(filepath.Join(pf86, "Author Software", "nvm")) + } + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + if strings.Contains(strings.ToLower(dir), `\author software\nvm`) { + add(dir) + } + } + return roots +} + +func installRootDenied(abs string, protect installRootProtect) bool { + for _, root := range protect.subtree { + if pathUnderOrEqual(abs, root) { + return true + } + } + for _, root := range protect.exact { + if equalFoldPath(abs, root) { + return true + } + } + protected := make([]string, 0, len(protect.subtree)+len(protect.exact)) + protected = append(protected, protect.subtree...) + protected = append(protected, protect.exact...) + for _, child := range protected { + if pathIsStrictParent(abs, child) { + return true + } + } + return false +} + +func pathUnderOrEqual(child, parent string) bool { + child = filepath.Clean(child) + parent = filepath.Clean(parent) + if parent == "" || parent == "." || child == "" || child == "." { + return false + } + if equalFoldPath(child, parent) { + return true + } + c := strings.ToLower(child) + p := strings.ToLower(parent) + sep := string(os.PathSeparator) + if !strings.HasSuffix(p, sep) { + p += sep + } + return strings.HasPrefix(c, p) +} + +func pathIsStrictParent(parent, child string) bool { + return pathUnderOrEqual(child, parent) && !equalFoldPath(parent, child) +} + +func knownFolder(id *windows.KNOWNFOLDERID, envNames ...string) string { + if id != nil { + p, err := windows.KnownFolderPath(id, kfDontVerify) + if err == nil && strings.TrimSpace(p) != "" { + return filepath.Clean(p) + } + } + for _, name := range envNames { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return filepath.Clean(v) + } + } + return "" +} + +func driveRoots() []string { + kernel32 := windows.NewLazySystemDLL("kernel32.dll") + proc := kernel32.NewProc("GetLogicalDrives") + r1, _, _ := proc.Call() + mask := uint32(r1) + if mask == 0 { + return nil + } + out := make([]string, 0, 4) + for i := 0; i < 26; i++ { + if mask&(1<." hidden:"true"` ProxyAuthType string `cfg:"proxy_auth_type" reg:"ProxyAuthType" help:"Preferred proxy authentication type for the configured proxy." hidden:"true"` diff --git a/settings/settings.go b/settings/settings.go index f29e892..780eb45 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -844,6 +844,9 @@ func Validate(name string, value interface{}) error { return fmt.Errorf("root must be a non-empty path") } cleaned := filepath.Clean(Expand(strVal)) + if err := fs.CheckInstallRootAllowed(cleaned); err != nil { + return err + } fs.WarnRiskyRootLayout(cleaned) fs.WarnSharedWritableRoot(cleaned) diff --git a/settings/settings_test.go b/settings/settings_test.go index 126c109..3c46519 100644 --- a/settings/settings_test.go +++ b/settings/settings_test.go @@ -499,6 +499,25 @@ func TestGet_UnknownSetting(t *testing.T) { } } +func TestRoot_RejectsProgramFiles(t *testing.T) { + pf := os.Getenv("ProgramFiles") + if pf == "" { + t.Skip("ProgramFiles not set") + } + err := settings.Put("root", filepath.Join(pf, "nodejs")) + if err == nil { + t.Fatal("Put(root, Program Files\\nodejs) expected NVM4313, got nil") + } + if !strings.Contains(err.Error(), "NVM4313") { + t.Fatalf("Put(root) error = %v, want NVM4313", err) + } +} + +func TestRoot_AllowsDedicatedChild(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nvm-versions") + mustPut(t, "root", dir) +} + func TestDefaultShimBunByEdition(t *testing.T) { orig := settings.CertifiedBuild t.Cleanup(func() { settings.CertifiedBuild = orig })