diff --git a/cli/common/unityprocess/process.go b/cli/common/unityprocess/process.go index dc023eacd8..6a1d83c4d0 100644 --- a/cli/common/unityprocess/process.go +++ b/cli/common/unityprocess/process.go @@ -2,6 +2,7 @@ package unityprocess import ( "context" + "encoding/base64" "fmt" "os/exec" "path/filepath" @@ -74,21 +75,32 @@ func listUnityProcessesMac(ctx context.Context) ([]UnityProcess, error) { } func listUnityProcessesWindows(ctx context.Context) ([]UnityProcess, error) { + commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout) + defer cancel() + output, err := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", windowsUnityProcessListScript()).Output() + if err != nil { + return nil, fmt.Errorf("failed to retrieve Unity process list on Windows: %w", err) + } + return parseWindowsUnityProcesses(string(output)), nil +} + +// windowsUnityProcessListScript builds the PowerShell script that lists Unity +// processes as "pid|base64(UTF-8 command line)" lines. +// why: Windows PowerShell 5.1 encodes redirected stdout with the OEM code page +// (e.g. CP932 on Japanese Windows), so non-ASCII project paths in the command +// line get corrupted when Go reads the output as UTF-8. Base64 over UTF-8 +// bytes keeps the stream ASCII-only regardless of the console code page. +func windowsUnityProcessListScript() string { scriptLines := []string{ "$ErrorActionPreference = 'Stop'", "$processes = Get-CimInstance Win32_Process -Filter \"Name = 'Unity.exe'\" | Where-Object { $_.CommandLine }", "foreach ($process in $processes) {", " $commandLine = $process.CommandLine -replace \"`r\", ' ' -replace \"`n\", ' '", - " Write-Output (\"{0}|{1}\" -f $process.ProcessId, $commandLine)", + " $encodedCommandLine = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($commandLine))", + " Write-Output (\"{0}|{1}\" -f $process.ProcessId, $encodedCommandLine)", "}", } - commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout) - defer cancel() - output, err := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", strings.Join(scriptLines, "\n")).Output() - if err != nil { - return nil, fmt.Errorf("failed to retrieve Unity process list on Windows: %w", err) - } - return parseWindowsUnityProcesses(string(output)), nil + return strings.Join(scriptLines, "\n") } func parseMacUnityProcesses(output string) []UnityProcess { @@ -136,7 +148,12 @@ func parseWindowsUnityProcesses(output string) []UnityProcess { continue } - command := strings.TrimSpace(trimmed[delimiterIndex+1:]) + decodedCommand, err := base64.StdEncoding.DecodeString(strings.TrimSpace(trimmed[delimiterIndex+1:])) + if err != nil { + continue + } + + command := strings.TrimSpace(string(decodedCommand)) if !isUnityEditorCommand(command, windowsUnityExecutablePattern) { continue } diff --git a/cli/common/unityprocess/process_test.go b/cli/common/unityprocess/process_test.go index f29976f524..c0fb37b271 100644 --- a/cli/common/unityprocess/process_test.go +++ b/cli/common/unityprocess/process_test.go @@ -1,6 +1,7 @@ package unityprocess import ( + "encoding/base64" "path/filepath" "runtime" "strings" @@ -27,11 +28,13 @@ func TestParseMacUnityProcessesExtractsProjectPath(t *testing.T) { } } -// Verifies Windows Unity process parsing extracts project paths and skips batchmode workers. +// Verifies Windows Unity process parsing decodes Base64 command lines, extracts project paths, and skips batchmode workers. func TestParseWindowsUnityProcessesExtractsProjectPath(t *testing.T) { - output := `123|C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -projectPath "C:\Users\\My Project" -useHub -456|C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -batchmode -projectPath "C:\Users\\Batch" -` + encode := func(commandLine string) string { + return base64.StdEncoding.EncodeToString([]byte(commandLine)) + } + output := "123|" + encode(`C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -projectPath "C:\Users\\My Project" -useHub`) + "\r\n" + + "456|" + encode(`C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -batchmode -projectPath "C:\Users\\Batch"`) + "\r\n" processes := parseWindowsUnityProcesses(output) @@ -43,6 +46,50 @@ func TestParseWindowsUnityProcessesExtractsProjectPath(t *testing.T) { } } +// Verifies non-ASCII project paths survive the PowerShell boundary because command lines travel as UTF-8 Base64. +func TestParseWindowsUnityProcessesPreservesNonASCIIProjectPath(t *testing.T) { + projectPath := `C:\Users\\test[1] 検証用\proj` + commandLine := `C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe -projectPath "` + projectPath + `" -useHub` + output := "123|" + base64.StdEncoding.EncodeToString([]byte(commandLine)) + "\r\n" + + processes := parseWindowsUnityProcesses(output) + + if len(processes) != 1 { + t.Fatalf("process count mismatch: %#v", processes) + } + if processes[0].projectPath != projectPath { + t.Fatalf("project path mismatch: %q", processes[0].projectPath) + } +} + +// Verifies command fields that are not valid Base64 (e.g. legacy plain-text or OEM code page bytes) are skipped instead of mis-parsed. +func TestParseWindowsUnityProcessesSkipsNonBase64CommandLines(t *testing.T) { + // 0x8C9F 0x8FD8 0x9770 is the measured CP932 byte sequence for "検証用" + // that Windows PowerShell 5.1 emitted before the Base64 contract. + cp932KenshouYou := string([]byte{0x8C, 0x9F, 0x8F, 0xD8, 0x97, 0x70}) + output := "123|" + `C:\Editor\Unity.exe -projectPath "C:\Users\\` + cp932KenshouYou + `\proj"` + "\r\n" + + processes := parseWindowsUnityProcesses(output) + + if len(processes) != 0 { + t.Fatalf("non-Base64 command lines should be skipped: %#v", processes) + } +} + +// Verifies the Windows process list script transports command lines as UTF-8 Base64 so the OEM console code page cannot corrupt non-ASCII paths. +func TestWindowsUnityProcessListScriptEncodesCommandLineAsUTF8Base64(t *testing.T) { + script := windowsUnityProcessListScript() + + for _, expected := range []string{ + "[System.Text.Encoding]::UTF8.GetBytes($commandLine)", + "[Convert]::ToBase64String(", + } { + if !strings.Contains(script, expected) { + t.Fatalf("script missing %q: %s", expected, script) + } + } +} + // Verifies Unity -projectPath extraction supports quoted, unquoted, and equals forms. func TestExtractProjectPathSupportsEqualsAndSpaces(t *testing.T) { cases := map[string]string{