diff --git a/src/core/autostart_windows.go b/src/core/autostart_windows.go index 484b084..dbf3633 100644 --- a/src/core/autostart_windows.go +++ b/src/core/autostart_windows.go @@ -113,7 +113,14 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath } func readShortcut(shortcutPath string) (string, string, error) { - script := `$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)` + // Force UTF-8 before writing the path. PowerShell defaults to the system + // OEM code page (e.g. CP866 on Russian Windows). Without this override, + // [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as + // OEM bytes; Go then reads them as UTF-8 and gets a different string from + // os.Executable, causing AutostartStatus to report "shortcut points to + // another executable" for any install path that contains non-ASCII chars. + // New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM. + script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)` command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script) command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath) configureHiddenWindow(command) @@ -178,7 +185,33 @@ func parseRegistryRunValue(output string) (string, bool) { } func sameWindowsPath(left string, right string) bool { - left = filepath.Clean(strings.Trim(left, `"`)) - right = filepath.Clean(strings.Trim(right, `"`)) - return strings.EqualFold(left, right) + left = normalizeWindowsPath(left) + right = normalizeWindowsPath(right) + if strings.EqualFold(left, right) { + return true + } + // If the string comparison fails, compare by filesystem object identity. + // os.SameFile uses the volume serial number and file index on Windows, so + // it correctly handles cases where one path uses an NTFS 8.3 short name + // while the other uses the long name. Windows generates 8.3 names for + // directory entries that contain spaces; when the process is launched via + // a Startup-folder shortcut the OS may resolve the PIDL to the short-name + // form, so os.Executable can return a different string than WScript reads + // back from TargetPath even though both point to the same file. The same + // fallback also covers directory junction points. + leftInfo, leftErr := os.Lstat(left) + rightInfo, rightErr := os.Lstat(right) + if leftErr == nil && rightErr == nil { + return os.SameFile(leftInfo, rightInfo) + } + return false +} + +func normalizeWindowsPath(p string) string { + p = strings.Trim(p, `"`) + // filepath.Clean preserves the \\?\ extended-length device path prefix that + // Windows adds for paths exceeding MAX_PATH. Strip it so the cleaned result + // compares equal to the same path without the prefix. + p = strings.TrimPrefix(p, `\\?\`) + return filepath.Clean(p) } diff --git a/src/core/autostart_windows_test.go b/src/core/autostart_windows_test.go index 782b24b..99bd0fa 100644 --- a/src/core/autostart_windows_test.go +++ b/src/core/autostart_windows_test.go @@ -5,6 +5,7 @@ package core import ( "os" "path/filepath" + "syscall" "testing" ) @@ -34,6 +35,46 @@ func TestSameWindowsPathHandlesSpaces(t *testing.T) { } } +func TestSameWindowsPathStripsExtendedLengthPrefix(t *testing.T) { + if !sameWindowsPath(`\\?\D:\Apps\GoSentry\gosentry.exe`, `D:\Apps\GoSentry\gosentry.exe`) { + t.Fatal("expected \\\\?\\-prefixed path to match plain path") + } +} + +func TestSameWindowsPathMatchesShortNameViaFilesystem(t *testing.T) { + // Create a file inside a directory whose name contains a space. On NTFS + // systems that have 8.3 name generation enabled, Windows also assigns a + // short name to the directory (e.g. "Local~1"). WScript.Shell may return + // the long form while os.Executable returns the short form (or vice versa). + // Verify that sameWindowsPath treats both representations as equal. + tempDir := t.TempDir() + dirWithSpace := filepath.Join(tempDir, "Local Git") + if err := os.MkdirAll(dirWithSpace, 0755); err != nil { + t.Fatalf("create dir: %v", err) + } + longPath := filepath.Join(dirWithSpace, "gosentry.exe") + if err := os.WriteFile(longPath, []byte("test"), 0644); err != nil { + t.Fatalf("create file: %v", err) + } + + // GetShortPathName converts the long path to its 8.3 equivalent when 8.3 + // names are available; it returns the unchanged path otherwise. + p16, err := syscall.UTF16PtrFromString(longPath) + if err != nil { + t.Fatalf("UTF16PtrFromString: %v", err) + } + buf := make([]uint16, syscall.MAX_PATH) + n, err := syscall.GetShortPathName(p16, &buf[0], uint32(len(buf))) + if err != nil { + t.Skipf("GetShortPathName: %v", err) + } + shortPath := syscall.UTF16ToString(buf[:n]) + + if !sameWindowsPath(longPath, shortPath) { + t.Fatalf("sameWindowsPath(%q, %q) = false; want true", longPath, shortPath) + } +} + func TestStartupShortcutPathUsesUserStartupFolder(t *testing.T) { t.Setenv("APPDATA", `C:\Users\mixem\AppData\Roaming`) @@ -48,6 +89,33 @@ func TestStartupShortcutPathUsesUserStartupFolder(t *testing.T) { } } +func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) { + tempDir := t.TempDir() + shortcutPath := filepath.Join(tempDir, "GoSentry.lnk") + targetPath := filepath.Join(tempDir, "Программы и драйвера", "GoSentry", "gosentry.exe") + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + t.Fatalf("create target directory: %v", err) + } + if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil { + t.Fatalf("create target file: %v", err) + } + + if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil { + t.Fatalf("create shortcut: %v", err) + } + + actual, arguments, err := readShortcut(shortcutPath) + if err != nil { + t.Fatalf("read shortcut: %v", err) + } + if !sameWindowsPath(actual, targetPath) { + t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath) + } + if arguments != StartInTrayArgument { + t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument) + } +} + func TestCreateStartupShortcutHandlesSpaces(t *testing.T) { tempDir := t.TempDir() shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")