Skip to content
19 changes: 13 additions & 6 deletions src/bootstrap/activation.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,19 @@ var verifyActivationNode = func(path string) error {

var logActivationBlocked = func(versionDir, nodePath, failureKind, detail string) {
log.ErrorStructured("node.security.activation_blocked", log.StructuredPayload{
"action": "activation_blocked",
"detail": detail,
"failure_kind": failureKind,
"node_path": nodePath,
"source": "link-mode",
"version_path": versionDir,
"action": "activation_blocked",
"detail": detail,
"failure_kind": failureKind,
"node_path": nodePath,
"source": "link-mode",
"version_path": versionDir,
"user": log.Actor(),
"sid": log.ActorSid(),
"hostname": log.Hostname(),
"correlation_id": log.NewCorrelationID(),
"parent_process": log.ParentProcess(),
"project_name": log.ProjectName(),
"project_path": log.ProjectPath(),
}, activationBlockedEventCode)
}

Expand Down
52 changes: 51 additions & 1 deletion src/bootstrap/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,16 @@ func TestEnsureUserProfileInitializedCleansLegacyPayload(t *testing.T) {
createRegistryKey(t, legacyShellRegistrationBase+`\shell\open\command`, map[string]string{
"": `"` + legacyNvmExe + `" "%1"`,
})
nodejsPath := filepath.Join(root, ".nodejs")
createRegistryKey(t, `Environment`, map[string]string{
"NVM_HOME": root,
"NVM_HOME": root,
"NVM_SYMLINK": nodejsPath,
"Path": strings.Join([]string{
`C:\Windows\system32`,
root,
nodejsPath,
`C:\Tools`,
}, ";"),
})

if err := EnsureUserProfileInitialized(); err != nil {
Expand All @@ -386,6 +394,11 @@ func TestEnsureUserProfileInitializedCleansLegacyPayload(t *testing.T) {
assertRegistryKeyMissing(t, legacySyncAppPathKey)
assertRegistryKeyMissing(t, legacyShellRegistrationBase+`\shell\open\command`)
assertRegistryValueMissing(t, `Environment`, "NVM_HOME")
assertRegistryValueMissing(t, `Environment`, "NVM_SYMLINK")
assertUserPathContains(t, `C:\Windows\system32`)
assertUserPathContains(t, nodejsPath)
assertUserPathContains(t, `C:\Tools`)
assertUserPathMissing(t, root)
if len(deletedTasks) != 1 || deletedTasks[0] != "NVM Sync" {
t.Fatalf("deleted tasks = %#v, want [\"NVM Sync\"]", deletedTasks)
}
Expand Down Expand Up @@ -687,6 +700,43 @@ func assertRegistryValueMissing(t *testing.T, keyPath, valueName string) {
}
}

func readUserPath(t *testing.T) string {
t.Helper()
key, err := winreg.OpenKey(winreg.CURRENT_USER, `Environment`, winreg.QUERY_VALUE)
if err != nil {
t.Fatalf("OpenKey(Environment) error = %v", err)
}
defer key.Close()
value, _, err := key.GetStringValue("Path")
if err != nil {
t.Fatalf("GetStringValue(Path) error = %v", err)
}
return value
}

func assertUserPathContains(t *testing.T, segment string) {
t.Helper()
path := readUserPath(t)
normSeg := normalizePathMatch(segment)
for _, part := range strings.Split(path, ";") {
if normalizePathMatch(part) == normSeg {
return
}
}
t.Fatalf("user Path %q missing segment %q", path, segment)
}

func assertUserPathMissing(t *testing.T, segment string) {
t.Helper()
path := readUserPath(t)
normSeg := normalizePathMatch(segment)
for _, part := range strings.Split(path, ";") {
if normalizePathMatch(part) == normSeg {
t.Fatalf("user Path %q still contains segment %q", path, segment)
}
}
}

func createProgramSyncSeed(t *testing.T, relPath string, content []byte) string {
t.Helper()

Expand Down
123 changes: 114 additions & 9 deletions src/bootstrap/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"
"syscall"

"nvm/legacy"

winreg "golang.org/x/sys/windows/registry"
)

Expand Down Expand Up @@ -88,24 +90,127 @@ func removeLegacyCurrentUserEnv(dataRoot string) error {
}
defer key.Close()

nvmHome, _, valueErr := key.GetStringValue("NVM_HOME")
if valueErr != nil {
if valueErr == winreg.ErrNotExist {
return nil
changed := false

nvmHome, _, homeErr := key.GetStringValue("NVM_HOME")
if homeErr != nil && homeErr != winreg.ErrNotExist {
return fmt.Errorf("failed to read current-user NVM_HOME: %w", homeErr)
}
nvmSymlink, _, linkErr := key.GetStringValue("NVM_SYMLINK")
if linkErr != nil && linkErr != winreg.ErrNotExist {
return fmt.Errorf("failed to read current-user NVM_SYMLINK: %w", linkErr)
}

forceRemove := map[string]bool{}
if homeErr == nil && (valueReferencesPath(nvmHome, dataRoot) || looksLikeAuthorNvmHome(nvmHome)) {
if err := key.DeleteValue("NVM_HOME"); err != nil && err != winreg.ErrNotExist {
return fmt.Errorf("failed to delete current-user NVM_HOME: %w", err)
}
return fmt.Errorf("failed to read current-user NVM_HOME: %w", valueErr)
forceRemove[normalizePathMatch(nvmHome)] = true
forceRemove[strings.ToLower("%NVM_HOME%")] = true
changed = true
}
if !valueReferencesPath(nvmHome, dataRoot) {
return nil
if linkErr == nil && (valueReferencesPath(nvmSymlink, dataRoot) || looksLikeLegacyNvmSymlink(nvmSymlink)) {
if err := key.DeleteValue("NVM_SYMLINK"); err != nil && err != winreg.ErrNotExist {
return fmt.Errorf("failed to delete current-user NVM_SYMLINK: %w", err)
}
forceRemove[normalizePathMatch(nvmSymlink)] = true
forceRemove[strings.ToLower("%NVM_SYMLINK%")] = true
changed = true
}

if err := key.DeleteValue("NVM_HOME"); err != nil && err != winreg.ErrNotExist {
return fmt.Errorf("failed to delete current-user NVM_HOME: %w", err)
userPath, _, pathErr := key.GetStringValue("Path")
if pathErr != nil {
if pathErr == winreg.ErrNotExist {
if changed {
legacy.BroadcastEnvironmentChange()
}
return nil
}
return fmt.Errorf("failed to read current-user Path: %w", pathErr)
}

// Also strip community program-root PATH entries (keep .nodejs).
cleaned := filterUserPath(userPath, dataRoot, forceRemove)
if cleaned != userPath {
if err := key.SetExpandStringValue("Path", cleaned); err != nil {
return fmt.Errorf("failed to rewrite current-user Path: %w", err)
}
changed = true
}
if changed {
legacy.BroadcastEnvironmentChange()
}
return nil
}

// RemoveLegacyCurrentUserEnv clears leftover HKCU NVM_HOME/NVM_SYMLINK and community
// program-root user PATH segments while keeping dataRoot\.nodejs. Used by MSI
// impersonated install CA and first-launch bootstrap.
func RemoveLegacyCurrentUserEnv(dataRoot string) error {
return removeLegacyCurrentUserEnv(dataRoot)
}

func looksLikeLegacyNvmSymlink(value string) bool {
norm := normalizePathMatch(value)
if norm == "" {
return false
}
trimmed := strings.TrimSpace(value)
return strings.EqualFold(norm, `c:\nodejs`) ||
strings.EqualFold(trimmed, `%NVM_SYMLINK%`) ||
strings.HasSuffix(norm, `\author software\nvm\.link`) ||
strings.HasSuffix(norm, `\author software\nvm\.nodejs`)
}

func looksLikeAuthorNvmHome(value string) bool {
norm := normalizePathMatch(value)
if norm == "" {
return false
}
return strings.HasSuffix(norm, `\author software\nvm`)
}

// filterUserPath drops legacy NVM segments and the community program root for dataRoot
// while keeping dataRoot\.nodejs.
func filterUserPath(userPath, dataRoot string, forceRemove map[string]bool) string {
if forceRemove == nil {
forceRemove = map[string]bool{}
}
dataNorm := normalizePathMatch(dataRoot)
nodejsNorm := normalizePathMatch(filepath.Join(dataRoot, ".nodejs"))

segments := strings.Split(userPath, ";")
kept := make([]string, 0, len(segments))
for _, seg := range segments {
trimmed := strings.TrimSpace(seg)
if trimmed == "" {
continue
}
norm := normalizePathMatch(trimmed)
expanded := normalizePathMatch(os.ExpandEnv(trimmed))

// Keep .nodejs shim path even when NVM_SYMLINK pointed at it.
if norm == nodejsNorm || expanded == nodejsNorm ||
strings.HasSuffix(norm, `\author software\nvm\.nodejs`) ||
strings.HasSuffix(expanded, `\author software\nvm\.nodejs`) {
kept = append(kept, seg)
continue
}
if forceRemove[norm] || forceRemove[expanded] {
continue
}
// Drop community program root (data root itself).
if (dataNorm != "" && (norm == dataNorm || expanded == dataNorm)) ||
strings.HasSuffix(norm, `\author software\nvm`) ||
strings.HasSuffix(expanded, `\author software\nvm`) {
continue
}
kept = append(kept, seg)
}
return strings.Join(kept, ";")
}

func removeLegacyPath(path string) error {
if _, err := os.Lstat(path); err != nil {
if os.IsNotExist(err) {
Expand Down
58 changes: 58 additions & 0 deletions src/bootstrap/migration_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package bootstrap

import (
"path/filepath"
"strings"
"testing"
)

func TestFilterUserPath_KeepsNodejsDropsProgramRoot(t *testing.T) {
root := `C:\Users\a\AppData\Local\Author Software\nvm`
nodejs := filepath.Join(root, ".nodejs")
in := strings.Join([]string{
`C:\Windows\system32`,
root,
nodejs,
`C:\Tools`,
`%NVM_HOME%`,
}, ";")
force := map[string]bool{
strings.ToLower("%NVM_HOME%"): true,
normalizePathMatch(root): true,
}

got := filterUserPath(in, root, force)

for _, keep := range []string{`C:\Windows\system32`, nodejs, `C:\Tools`} {
if !pathHasSegment(got, keep) {
t.Fatalf("expected keep %q in %q", keep, got)
}
}
for _, drop := range []string{root, `%NVM_HOME%`} {
if pathHasSegment(got, drop) {
t.Fatalf("expected drop %q from %q", drop, got)
}
}
}

func pathHasSegment(path, segment string) bool {
want := normalizePathMatch(segment)
for _, part := range strings.Split(path, ";") {
if normalizePathMatch(part) == want {
return true
}
}
return false
}

func TestLooksLikeLegacyNvmSymlink(t *testing.T) {
if !looksLikeLegacyNvmSymlink(`C:\nodejs`) {
t.Fatal("want classic C:\\nodejs")
}
if !looksLikeLegacyNvmSymlink(`C:\Users\a\AppData\Local\Author Software\nvm\.nodejs`) {
t.Fatal("want Author Software .nodejs")
}
if looksLikeLegacyNvmSymlink(`D:\custom\node-link`) {
t.Fatal("custom link must not match")
}
}
31 changes: 27 additions & 4 deletions src/cmd/layout_warn.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import (
"time"
)

func warnStartupAdvisoriesIfNeeded() {
if license.IsCommunityBuild() {
warnCommunityProgramRootIfNeeded()
return
}
warnCertifiedCommunityFeatureModeIfNeeded()
}

func warnCommunityProgramRootIfNeeded() {
root, err := bootstrap.ProgramRoot()
if err != nil {
Expand All @@ -33,9 +41,24 @@ func warnCommunityProgramRootIfNeeded() {
}
}

func communityEditionWatermark() string {
if license.Edition() != "Community" {
return ""
func warnCertifiedCommunityFeatureModeIfNeeded() {
if !license.InCommunityFeatureMode() {
return
}
msg := license.CommunityFeatureModeWarning()
fmt.Fprintln(os.Stderr, msg)

root, err := bootstrap.ProgramRoot()
if err != nil {
return
}
stamp := filepath.Join(root, ".cache", "community-feature-mode-warn.stamp")
if _, err := eventlog.WriteApplicationWarningThrottled(
uint32(license.FeatureModeWarnEventID),
msg,
stamp,
time.Hour,
); err != nil {
_ = err
}
return "Community (per-user LocalAppData install; see nvm doctor)"
}
Loading
Loading