diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0baea1c..5e62180 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,7 @@ jobs: filters: | nvm: - 'src/**' + - '.github/**' github: - '.github/**' - 'LICENSE' @@ -127,7 +128,7 @@ jobs: - name: Checkout common modules shell: pwsh run: | - $dest = Join-Path (Split-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) -Parent) "common" + $dest = Join-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) "common" if (-not (Test-Path (Join-Path $dest "settings\go.mod"))) { git clone --depth 1 https://github.com/nvm-windows/common.git $dest } diff --git a/src/bootstrap/identity.go b/src/bootstrap/identity.go new file mode 100644 index 0000000..c0672c9 --- /dev/null +++ b/src/bootstrap/identity.go @@ -0,0 +1,264 @@ +//go:build windows + +package bootstrap + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + prefs "common/preferences" + "common/registry" +) + +const ( + CommunityAppxName = "AuthorSoftware.NVMWindows" + CertifiedAppxName = "AuthorSoftware.NVMWindows.Certified" + identityMsixName = "NVMWindows.msix" + retiredValueName = "CommunityIdentityRetired" + registeredValueName = "CertifiedIdentityRegistered" + certifiedRetiredValueName = "CertifiedIdentityRetired" +) + +var runAppxCommand = func(command string) error { + ps := filepath.Join(os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + cmd := exec.Command(ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// RegisterSparseIdentity registers the per-user identity .msix against programRoot. +// Failure is non-fatal for callers; toasts fall back to AUMID-only. +func RegisterSparseIdentity(programRoot string) error { + programRoot = filepath.Clean(strings.TrimSpace(programRoot)) + if programRoot == "" { + return fmt.Errorf("program root is empty") + } + msix := filepath.Join(programRoot, "identity", identityMsixName) + if _, err := os.Stat(msix); err != nil { + return err + } + cmd := fmt.Sprintf( + "Add-AppxPackage -Path %s -ExternalLocation %s -ErrorAction Stop", + psSingleQuote(msix), + psSingleQuote(programRoot), + ) + return runAppxCommand(cmd) +} + +// ProvisionSparseIdentity registers the identity for all users (elevated MSI). +func ProvisionSparseIdentity(programRoot string) error { + programRoot = filepath.Clean(strings.TrimSpace(programRoot)) + if programRoot == "" { + return fmt.Errorf("program root is empty") + } + msix := filepath.Join(programRoot, "identity", identityMsixName) + if _, err := os.Stat(msix); err != nil { + return err + } + cmd := fmt.Sprintf( + "$msix = %s; $root = %s; Add-AppxPackage -Path $msix -ExternalLocation $root -AllUsers -ErrorAction SilentlyContinue; if (-not $?) { Add-AppxPackage -Path $msix -ExternalLocation $root -ErrorAction SilentlyContinue }; try { Add-AppxProvisionedPackage -Online -PackagePath $msix -SkipLicense -ErrorAction SilentlyContinue | Out-Null } catch {}", + psSingleQuote(msix), + psSingleQuote(programRoot), + ) + return runAppxCommand(cmd) +} + +// DeprovisionSparseIdentity drops per-user, all-user, and provisioned packages. +func DeprovisionSparseIdentity(name string) error { + name = strings.TrimSpace(name) + if name == "" { + name = CertifiedAppxName + } + cmd := fmt.Sprintf( + "$n = %s; Get-AppxPackage -AllUsers -Name $n -ErrorAction SilentlyContinue | Remove-AppxPackage -AllUsers -ErrorAction SilentlyContinue; Get-AppxPackage -Name $n -ErrorAction SilentlyContinue | Remove-AppxPackage -ErrorAction SilentlyContinue; Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq $n } | ForEach-Object { Remove-AppxProvisionedPackage -Online -PackageName $_.PackageName -ErrorAction SilentlyContinue }", + psSingleQuote(name), + ) + err := runAppxCommand(cmd) + if strings.EqualFold(name, CertifiedAppxName) { + _ = clearCertifiedIdentityRegistered() + } + return err +} + +// UnregisterSparseIdentity removes a sparse identity package. Missing package is success. +func UnregisterSparseIdentity(name string) error { + name = strings.TrimSpace(name) + if name == "" { + name = CommunityAppxName + } + cmd := fmt.Sprintf( + "Get-AppxPackage -Name %s | Remove-AppxPackage -ErrorAction SilentlyContinue", + psSingleQuote(name), + ) + err := runAppxCommand(cmd) + if strings.EqualFold(name, CertifiedAppxName) { + _ = clearCertifiedIdentityRegistered() + } + return err +} + +// MaybeRemoveStaleCommunityIdentity drops a leftover community Appx after +// community→certified when the MSI impersonated CA ran as SYSTEM. +func MaybeRemoveStaleCommunityIdentity() { + root, err := ProgramRoot() + if err != nil || !isMachineProgramRoot(root) { + return + } + if communityIdentityRetired() { + return + } + _ = UnregisterSparseIdentity(CommunityAppxName) + _ = markCommunityIdentityRetired() +} + +// MaybeRegisterCertifiedIdentity registers the Program Files identity .msix once +// per user when the MSI impersonated CA ran as SYSTEM. +func MaybeRegisterCertifiedIdentity() { + root, err := ProgramRoot() + if err != nil || !isMachineProgramRoot(root) { + return + } + if certifiedIdentityRegistered() { + return + } + if err := RegisterSparseIdentity(root); err != nil { + return + } + _ = markCertifiedIdentityRegistered() + _ = clearCertifiedIdentityRetired() +} + +// MaybeRemoveStaleCertifiedIdentity drops leftover Certified Appx after +// certified→community when MSI deprovision ran as SYSTEM. Leaves Certified +// identity alone if Program Files nvm.exe is still present. +func MaybeRemoveStaleCertifiedIdentity() { + root, err := ProgramRoot() + if err != nil || !shouldRemoveStaleCertifiedIdentity(root, certifiedProgramInstalled()) { + return + } + if certifiedIdentityRetired() { + return + } + _ = UnregisterSparseIdentity(CertifiedAppxName) + _ = markCertifiedIdentityRetired() +} + +func shouldRemoveStaleCertifiedIdentity(programRoot string, certifiedStillInstalled bool) bool { + if isMachineProgramRoot(programRoot) { + return false + } + return !certifiedStillInstalled +} + +func certifiedProgramInstalled() bool { + for _, p := range certifiedNvmExePaths() { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +func certifiedNvmExePaths() []string { + var out []string + if pf := strings.TrimSpace(os.Getenv("ProgramFiles")); pf != "" { + out = append(out, filepath.Join(pf, "Author Software", "nvm", "nvm.exe")) + } + if pf := strings.TrimSpace(os.Getenv("ProgramFiles(x86)")); pf != "" { + out = append(out, filepath.Join(pf, "Author Software", "nvm", "nvm.exe")) + } + return out +} + +func isMachineProgramRoot(root string) bool { + low := strings.ToLower(filepath.Clean(root)) + return strings.Contains(low, `\program files\`) || strings.Contains(low, `\program files (x86)\`) +} + +func communityIdentityRetired() bool { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return false + } + v, exists, err := registry.GetBool(root + "/" + retiredValueName) + if err != nil || !exists { + return false + } + return v +} + +func markCommunityIdentityRetired() error { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return nil + } + return registry.PutBool(true, root+"/"+retiredValueName) +} + +func certifiedIdentityRegistered() bool { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return false + } + v, exists, err := registry.GetBool(root + "/" + registeredValueName) + if err != nil || !exists { + return false + } + return v +} + +func markCertifiedIdentityRegistered() error { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return nil + } + return registry.PutBool(true, root+"/"+registeredValueName) +} + +func clearCertifiedIdentityRegistered() error { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return nil + } + return registry.Del(root + "/" + registeredValueName) +} + +func certifiedIdentityRetired() bool { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return false + } + v, exists, err := registry.GetBool(root + "/" + certifiedRetiredValueName) + if err != nil || !exists { + return false + } + return v +} + +func markCertifiedIdentityRetired() error { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return nil + } + return registry.PutBool(true, root+"/"+certifiedRetiredValueName) +} + +func clearCertifiedIdentityRetired() error { + root := strings.TrimSpace(prefs.ROOT) + if root == "" { + return nil + } + return registry.Del(root + "/" + certifiedRetiredValueName) +} + +func psSingleQuote(path string) string { + return "'" + strings.ReplaceAll(path, "'", "''") + "'" +} diff --git a/src/bootstrap/identity_other.go b/src/bootstrap/identity_other.go new file mode 100644 index 0000000..f30cad5 --- /dev/null +++ b/src/bootstrap/identity_other.go @@ -0,0 +1,22 @@ +//go:build !windows + +package bootstrap + +const ( + CommunityAppxName = "AuthorSoftware.NVMWindows" + CertifiedAppxName = "AuthorSoftware.NVMWindows.Certified" +) + +func RegisterSparseIdentity(string) error { return nil } + +func ProvisionSparseIdentity(string) error { return nil } + +func DeprovisionSparseIdentity(string) error { return nil } + +func UnregisterSparseIdentity(string) error { return nil } + +func MaybeRemoveStaleCommunityIdentity() {} + +func MaybeRegisterCertifiedIdentity() {} + +func MaybeRemoveStaleCertifiedIdentity() {} diff --git a/src/bootstrap/identity_test.go b/src/bootstrap/identity_test.go new file mode 100644 index 0000000..dff1ce2 --- /dev/null +++ b/src/bootstrap/identity_test.go @@ -0,0 +1,96 @@ +//go:build windows + +package bootstrap + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPsSingleQuote(t *testing.T) { + if got := psSingleQuote(`C:\a'b`); got != `'C:\a''b'` { + t.Fatalf("got %q", got) + } +} + +func TestIsMachineProgramRoot(t *testing.T) { + if !isMachineProgramRoot(`C:\Program Files\Author Software\nvm`) { + t.Fatal("Program Files should match") + } + if !isMachineProgramRoot(`C:\Program Files (x86)\Author Software\nvm`) { + t.Fatal("Program Files (x86) should match") + } + if isMachineProgramRoot(`C:\Users\a\AppData\Local\Author Software\nvm`) { + t.Fatal("AppData must not match") + } +} + +func TestUnregisterSparseIdentityCommand(t *testing.T) { + prev := runAppxCommand + t.Cleanup(func() { runAppxCommand = prev }) + var got string + runAppxCommand = func(command string) error { + got = command + return nil + } + if err := UnregisterSparseIdentity(CommunityAppxName); err != nil { + t.Fatal(err) + } + if !strings.Contains(got, CommunityAppxName) || !strings.Contains(got, "Remove-AppxPackage") { + t.Fatalf("command = %q", got) + } +} + +func TestProvisionSparseIdentityCommand(t *testing.T) { + prev := runAppxCommand + t.Cleanup(func() { runAppxCommand = prev }) + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "identity"), 0o755); err != nil { + t.Fatal(err) + } + msix := filepath.Join(root, "identity", identityMsixName) + if err := os.WriteFile(msix, []byte("msix"), 0o644); err != nil { + t.Fatal(err) + } + var got string + runAppxCommand = func(command string) error { + got = command + return nil + } + if err := ProvisionSparseIdentity(root); err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "-AllUsers") || !strings.Contains(got, "Add-AppxProvisionedPackage") { + t.Fatalf("command = %q", got) + } +} + +func TestShouldRemoveStaleCertifiedIdentity(t *testing.T) { + if shouldRemoveStaleCertifiedIdentity(`C:\Program Files\Author Software\nvm`, false) { + t.Fatal("Program Files install must keep Certified identity") + } + if shouldRemoveStaleCertifiedIdentity(`C:\Users\a\AppData\Local\Author Software\nvm`, true) { + t.Fatal("must not strip Certified identity while Program Files nvm.exe exists") + } + if !shouldRemoveStaleCertifiedIdentity(`C:\Users\a\AppData\Local\Author Software\nvm`, false) { + t.Fatal("AppData install after MSI removal should drop leftover Certified identity") + } +} + +func TestDeprovisionSparseIdentityCommand(t *testing.T) { + prev := runAppxCommand + t.Cleanup(func() { runAppxCommand = prev }) + var got string + runAppxCommand = func(command string) error { + got = command + return nil + } + if err := DeprovisionSparseIdentity(CertifiedAppxName); err != nil { + t.Fatal(err) + } + if !strings.Contains(got, CertifiedAppxName) || !strings.Contains(got, "Remove-AppxProvisionedPackage") { + t.Fatalf("command = %q", got) + } +} diff --git a/src/bootstrap/init.go b/src/bootstrap/init.go index 1fb1421..251ff3a 100644 --- a/src/bootstrap/init.go +++ b/src/bootstrap/init.go @@ -91,6 +91,9 @@ func EnsureUserProfileInitialized() error { if err := MaintainShimDirectory(); err != nil { fmt.Fprintf(os.Stderr, "nvm: warning: shim maintenance skipped: %v\n", err) } + MaybeRemoveStaleCommunityIdentity() + MaybeRegisterCertifiedIdentity() + MaybeRemoveStaleCertifiedIdentity() return nil } @@ -154,6 +157,9 @@ func EnsureUserProfileInitialized() error { _ = settings.SeedAnnouncementWatermarksIfEmpty(settings.Put) ensureSyncScheduledTask() + MaybeRemoveStaleCommunityIdentity() + MaybeRegisterCertifiedIdentity() + MaybeRemoveStaleCertifiedIdentity() return nil } diff --git a/src/bootstrap/migration.go b/src/bootstrap/migration.go index 8a17351..6d2212e 100644 --- a/src/bootstrap/migration.go +++ b/src/bootstrap/migration.go @@ -74,6 +74,8 @@ func cleanupLegacyUserPayload(dataRoot string) error { return err } + _ = UnregisterSparseIdentity(CommunityAppxName) + for _, path := range []string{ legacyNvmExe, filepath.Join(dataRoot, "utils"), diff --git a/src/cmd/main.go b/src/cmd/main.go index 609d113..7fc6671 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -107,6 +107,50 @@ func main() { os.Exit(1) } return + case "--register-sparse-identity": + // Inno/MSI: per-user Add-AppxPackage of the identity .msix. Fail-open. + settings.Load() + root, err := bootstrap.ProgramRoot() + if err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(0) + } + if err := bootstrap.RegisterSparseIdentity(root); err != nil { + fmt.Fprintf(os.Stderr, "sparse identity register skipped: %v\n", err) + } + return + case "--unregister-sparse-identity": + settings.Load() + name := bootstrap.CommunityAppxName + if len(os.Args) >= 3 && strings.TrimSpace(os.Args[2]) != "" { + name = strings.TrimSpace(os.Args[2]) + } + if err := bootstrap.UnregisterSparseIdentity(name); err != nil { + fmt.Fprintf(os.Stderr, "sparse identity unregister skipped: %v\n", err) + } + return + case "--provision-sparse-identity": + // Elevated MSI: AllUsers + provisioned. Fail-open. + settings.Load() + root, err := bootstrap.ProgramRoot() + if err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(0) + } + if err := bootstrap.ProvisionSparseIdentity(root); err != nil { + fmt.Fprintf(os.Stderr, "sparse identity provision skipped: %v\n", err) + } + return + case "--deprovision-sparse-identity": + settings.Load() + name := bootstrap.CertifiedAppxName + if len(os.Args) >= 3 && strings.TrimSpace(os.Args[2]) != "" { + name = strings.TrimSpace(os.Args[2]) + } + if err := bootstrap.DeprovisionSparseIdentity(name); err != nil { + fmt.Fprintf(os.Stderr, "sparse identity deprovision skipped: %v\n", err) + } + return case "--retire-community-payload": // Impersonated MSI CA: drop community nvm.exe/utils/.icons and the HKCU // uninstall entry during install, not on first nvm launch. @@ -288,6 +332,7 @@ func main() { newRoot, oldRoot, ) + // Critical setup toast: no collection (reserved nvm.setup). if err := notify.Send(settings.AppId, "NVM storage location updated", msg); err != nil { fmt.Fprint(os.Stderr, err.Error()) os.Exit(1) @@ -305,6 +350,7 @@ func main() { "Uninstall the old Node.js at %s. NVM for Windows left it in place during this upgrade. Leaving it installed causes conflicts.", nodePath, ) + // Critical setup toast: no collection (reserved nvm.setup). if err := notify.Send(settings.AppId, "Old Node.js still installed", officialMsg); err != nil { fmt.Fprint(os.Stderr, err.Error()) os.Exit(1) diff --git a/src/commands/alias/add.go b/src/commands/alias/add.go index 4656f59..718a7a8 100644 --- a/src/commands/alias/add.go +++ b/src/commands/alias/add.go @@ -100,6 +100,7 @@ func (c *Add) Run() error { msg := fmt.Sprintf("%q now refers to v%s", c.Name, version) fmt.Println(msg) if !system.IsAppInForeground() { + // Critical activity toast: no collection (reserved nvm.activity). go notify.Send(settings.AppId, "", msg) } diff --git a/src/commands/alias/remove.go b/src/commands/alias/remove.go index 111446f..b4a1a3e 100644 --- a/src/commands/alias/remove.go +++ b/src/commands/alias/remove.go @@ -59,6 +59,7 @@ func (c *Remove) Run() error { msg := fmt.Sprintf("%d alias%s removed successfully.", count, suffix) fmt.Println(msg) if !system.IsAppInForeground() { + // Critical activity toast: no collection (reserved nvm.activity). go notify.Send(settings.AppId, "", msg) } diff --git a/src/commands/cfg/set.go b/src/commands/cfg/set.go index d4ebd4a..1dec170 100644 --- a/src/commands/cfg/set.go +++ b/src/commands/cfg/set.go @@ -123,6 +123,7 @@ func (s *Set) Run() error { notifyLines = append(notifyLines, fmt.Sprintf("%s set to %s", key, displayValue(key, value))) } if len(notifyLines) > 0 && !system.IsAppInForeground() { + // Critical activity toast: no collection (reserved nvm.activity). go notify.Send(settings.AppId, "", strings.Join(notifyLines, ", ")) } diff --git a/src/commands/firewall/helpers.go b/src/commands/firewall/helpers.go index d7e0bde..4789a26 100644 --- a/src/commands/firewall/helpers.go +++ b/src/commands/firewall/helpers.go @@ -389,6 +389,7 @@ func trustNoAnswerMessage(name, why string) string { func notifyTrustNoAnswer(name, why string) { msg := trustNoAnswerMessage(name, why) fmt.Fprintln(os.Stderr, msg) + // Critical firewall toast: no collection (reserved nvm.firewall). _ = notify.Send(settings.AppId, "NVM Firewall", msg) } @@ -423,6 +424,7 @@ func (n *NotifyChanged) Run() error { return fmt.Errorf("module name required") } msg := fmt.Sprintf("A change to module '%s' was automatically trusted.", name) + // Critical firewall toast: no collection (reserved nvm.firewall). _ = notify.Send(settings.AppId, "NVM Firewall", msg) // NVM4406 audit emitted by proxy at decision site. return nil @@ -480,6 +482,7 @@ func promptTrustToast(name string) error { defer os.Remove(rspPath) msg := fmt.Sprintf("Untrusted module '%s' changed. Do you trust this module?", name) + // Critical firewall toast: no collection (reserved nvm.firewall). _ = notify.Send( settings.AppId, "NVM Firewall", diff --git a/src/commands/malware/malware.go b/src/commands/malware/malware.go index d1eed29..4fbf78d 100644 --- a/src/commands/malware/malware.go +++ b/src/commands/malware/malware.go @@ -91,6 +91,7 @@ func (c *NotifyBlock) Run() error { if license.IsCommunityBuild() && corporatePC() { body += " Certified keeps a record of blocks like this for your security tools." } + // Critical security toast: no collection (reserved nvm.security). return notify.SendWithAudio(settings.AppId, "Malicious package blocked", body, "ms-winsoundevent:Notification.Reminder") } diff --git a/src/commands/pin.go b/src/commands/pin.go index 55d3e87..c395070 100644 --- a/src/commands/pin.go +++ b/src/commands/pin.go @@ -172,6 +172,7 @@ func (c *Pin) Run() error { msg := fmt.Sprintf("Successfully pinned %s Node.js engine to Node.js v%s (with npm v%s)", c.File, node_version, npm_version) fmt.Println(msg) if !system.IsAppInForeground() { + // Critical activity toast: no collection (reserved nvm.activity). go notify.Send(settings.AppId, "", msg) } @@ -183,6 +184,7 @@ func (c *Pin) Run() error { msg := fmt.Sprintf("Successfully pinned %s Node.js version to v%s", c.File, node_version) fmt.Println(msg) if !system.IsAppInForeground() { + // Critical activity toast: no collection (reserved nvm.activity). go notify.Send(settings.AppId, "", msg) } } diff --git a/src/installer/activation.go b/src/installer/activation.go index 7fe1848..2952db7 100644 --- a/src/installer/activation.go +++ b/src/installer/activation.go @@ -114,6 +114,7 @@ func ActivateVersion(version string) error { msg := fmt.Sprintf("Now using Node.js v%s by default.", version) fmt.Fprintln(os.Stdout, msg) if !system.IsAppInForeground() { + // Critical activity toast: no collection (reserved nvm.activity). go notify.Send(settings.AppId, "", msg) } diff --git a/src/installer/installer.go b/src/installer/installer.go index bb27423..353854a 100644 --- a/src/installer/installer.go +++ b/src/installer/installer.go @@ -280,6 +280,7 @@ func install( title = fmt.Sprintf("Node.js v%s Installation Failed", nodeVersion) message = processErr.Error() } + // Critical activity toast: no collection (reserved nvm.activity). go func() { _ = notify.Send(settings.AppId, title, message) }() }() diff --git a/src/installer/uninstaller.go b/src/installer/uninstaller.go index c0dabbe..68338d0 100644 --- a/src/installer/uninstaller.go +++ b/src/installer/uninstaller.go @@ -109,6 +109,7 @@ func Uninstall(cfg UninstallConfig) error { // Apps launches with no console — always toast so the user gets feedback. if len(notifyMsgs) > 0 && (cfg.FromApps || !system.IsAppInForeground()) { + // Critical activity toast: no collection (reserved nvm.activity). go notify.Send(settings.AppId, "", strings.Join(notifyMsgs, "; ")) } diff --git a/src/link/link.go b/src/link/link.go index c339cf2..17c4580 100644 --- a/src/link/link.go +++ b/src/link/link.go @@ -80,6 +80,7 @@ func Link(source, symlink string) error { if no_devmode { // Trigger a notification to open settings to enable developer mode + // Critical setup toast: no collection (reserved nvm.setup). notify.Send(settings.AppId, "Help Enabling Symlinks", "Developer Mode is required to use symlinks.", notify.Action{Label: "Open Developer Settings", URL: "ms-settings:developers"}, )