Fix autostart status for paths with non-ASCII chars and spaces

readShortcut read the shortcut TargetPath via [Console]::Out.Write, which
uses the system OEM code page by default. On Russian Windows (CP866) this
encoded Cyrillic characters differently from UTF-8, so Go's string(output)
produced a garbled path that never matched os.Executable, causing
AutostartStatus to always report "shortcut points to another executable"
for any install directory that contained non-ASCII characters.

Fix: prepend [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
to the readShortcut PowerShell script so the output is always UTF-8.

Also harden sameWindowsPath against NTFS 8.3 short names: when a directory
name contains spaces Windows assigns a short name (e.g. LOCALG~1 for
"Local Git"), and the OS may use that form when launching from a
Startup-folder shortcut. Add an os.SameFile fallback that compares paths
by volume serial number and file index, which is immune to 8.3 vs long
name differences as well as directory junction points.

Add normalizeWindowsPath helper that strips quotes and the \?\ extended-
length prefix before filepath.Clean so those variants compare equal to
the plain path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-18 07:38:18 +03:00
parent 0c2c9f1f67
commit d24211cab2
2 changed files with 105 additions and 4 deletions
+37 -4
View File
@@ -113,7 +113,14 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
} }
func readShortcut(shortcutPath string) (string, string, error) { 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 := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath) command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
configureHiddenWindow(command) configureHiddenWindow(command)
@@ -178,7 +185,33 @@ func parseRegistryRunValue(output string) (string, bool) {
} }
func sameWindowsPath(left string, right string) bool { func sameWindowsPath(left string, right string) bool {
left = filepath.Clean(strings.Trim(left, `"`)) left = normalizeWindowsPath(left)
right = filepath.Clean(strings.Trim(right, `"`)) right = normalizeWindowsPath(right)
return strings.EqualFold(left, 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)
} }
+68
View File
@@ -5,6 +5,7 @@ package core
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"syscall"
"testing" "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) { func TestStartupShortcutPathUsesUserStartupFolder(t *testing.T) {
t.Setenv("APPDATA", `C:\Users\mixem\AppData\Roaming`) 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) { func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk") shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")