diff --git a/cli/common/go.mod b/cli/common/go.mod index 58d9563f1c..47c3bc2e7a 100644 --- a/cli/common/go.mod +++ b/cli/common/go.mod @@ -4,4 +4,4 @@ go 1.26 require github.com/Microsoft/go-winio v0.6.2 -require golang.org/x/sys v0.10.0 // indirect +require golang.org/x/sys v0.10.0 diff --git a/cli/common/unityprocess/process.go b/cli/common/unityprocess/process.go index 6a1d83c4d0..77ba47b0ef 100644 --- a/cli/common/unityprocess/process.go +++ b/cli/common/unityprocess/process.go @@ -1,8 +1,10 @@ package unityprocess import ( + "bytes" "context" "encoding/base64" + "encoding/binary" "fmt" "os/exec" "path/filepath" @@ -17,7 +19,6 @@ const windowsPowerShellCommand = "powershell" var ( macUnityExecutablePattern = regexp.MustCompile(`(?i)Unity\.app/Contents/MacOS/Unity`) windowsUnityExecutablePattern = regexp.MustCompile(`(?i)Unity\.exe`) - macProcessLinePattern = regexp.MustCompile(`^\s*(\d+)\s+(.*)$`) projectPathFlagPattern = regexp.MustCompile(`(?i)-projectpath(?:=|\s+)(.+)$`) nextUnityFlagPattern = regexp.MustCompile(`\s-[A-Za-z][A-Za-z0-9-]*(?:=|\s|$)`) ) @@ -64,16 +65,6 @@ func listUnityProcesses(ctx context.Context) ([]UnityProcess, error) { } } -func listUnityProcessesMac(ctx context.Context) ([]UnityProcess, error) { - commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout) - defer cancel() - output, err := exec.CommandContext(commandContext, "ps", "-axo", "pid=,command=", "-ww").Output() - if err != nil { - return nil, fmt.Errorf("failed to retrieve Unity process list: %w", err) - } - return parseMacUnityProcesses(string(output)), nil -} - func listUnityProcessesWindows(ctx context.Context) ([]UnityProcess, error) { commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout) defer cancel() @@ -103,31 +94,54 @@ func windowsUnityProcessListScript() string { return strings.Join(scriptLines, "\n") } -func parseMacUnityProcesses(output string) []UnityProcess { - processes := []UnityProcess{} - for _, line := range strings.Split(output, "\n") { - matches := macProcessLinePattern.FindStringSubmatch(line) - if len(matches) != 3 { - continue - } +// matchMacUnityProcess checks a single process's (pid, space-joined argv) pair against +// the same Unity-editor-command and project-path rules used for the ps-based command +// text this replaced, so callers built from either a text source or a raw argv slice +// share one matching implementation. +func matchMacUnityProcess(pid int, command string) (UnityProcess, bool) { + if !isUnityEditorCommand(command, macUnityExecutablePattern) { + return UnityProcess{}, false + } + projectPath := extractProjectPath(command) + if projectPath == "" { + return UnityProcess{}, false + } + return UnityProcess{Pid: pid, projectPath: projectPath}, true +} - pid, err := strconv.Atoi(matches[1]) - if err != nil { - continue - } +// parseMacProcArgs2 decodes the kern.procargs2 sysctl buffer layout: a leading int32 +// argc, followed by the exec path (NUL terminated), NUL padding, then argc consecutive +// NUL-terminated argv strings. Darwin's supported architectures (amd64, arm64) are both +// little-endian, so argc is read with binary.LittleEndian. This parser has no +// darwin-specific dependency, only the sysctl call that supplies its input does, so it +// stays in the shared, cross-platform-buildable file for CI coverage. +func parseMacProcArgs2(buf []byte) ([]string, error) { + if len(buf) < 4 { + return nil, fmt.Errorf("procargs2 buffer too short: %d bytes", len(buf)) + } - command := matches[2] - if !isUnityEditorCommand(command, macUnityExecutablePattern) { - continue - } - projectPath := extractProjectPath(command) - if projectPath == "" { - continue - } + argc := int(binary.LittleEndian.Uint32(buf[:4])) + rest := buf[4:] - processes = append(processes, UnityProcess{Pid: pid, projectPath: projectPath}) + execPathEnd := bytes.IndexByte(rest, 0) + if execPathEnd < 0 { + return nil, fmt.Errorf("procargs2 missing exec path terminator") } - return processes + rest = rest[execPathEnd:] + for len(rest) > 0 && rest[0] == 0 { + rest = rest[1:] + } + + args := make([]string, 0, argc) + for len(rest) > 0 && len(args) < argc { + argEnd := bytes.IndexByte(rest, 0) + if argEnd < 0 { + break + } + args = append(args, string(rest[:argEnd])) + rest = rest[argEnd+1:] + } + return args, nil } func parseWindowsUnityProcesses(output string) []UnityProcess { diff --git a/cli/common/unityprocess/process_darwin.go b/cli/common/unityprocess/process_darwin.go new file mode 100644 index 0000000000..74a910643c --- /dev/null +++ b/cli/common/unityprocess/process_darwin.go @@ -0,0 +1,57 @@ +//go:build darwin + +package unityprocess + +import ( + "context" + "fmt" + "strings" + + "golang.org/x/sys/unix" +) + +// listUnityProcessesMac enumerates Unity Editor processes via the kern.proc.all and +// kern.procargs2 sysctl nodes instead of exec'ing /bin/ps, so Unity process discovery +// keeps working inside sandboxes that deny exec of external binaries (ps included) but +// still permit process-info syscalls. +func listUnityProcessesMac(ctx context.Context) ([]UnityProcess, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + kinfoProcs, err := unix.SysctlKinfoProcSlice("kern.proc.all") + if err != nil { + return nil, fmt.Errorf("failed to retrieve Unity process list: %w", err) + } + + processes := []UnityProcess{} + for _, kinfoProc := range kinfoProcs { + pid := int(kinfoProc.Proc.P_pid) + if pid <= 0 { + continue + } + + args, err := macProcessArgs(pid) + if err != nil || len(args) == 0 { + // Reading another user's process args fails (EPERM). Non-root ps could + // not read those processes' arguments either, so they never matched + // before; skipping them preserves prior behavior. + continue + } + + process, matched := matchMacUnityProcess(pid, strings.Join(args, " ")) + if matched { + processes = append(processes, process) + } + } + return processes, nil +} + +// macProcessArgs reads a process's argv via the kern.procargs2 sysctl node. +func macProcessArgs(pid int) ([]string, error) { + buf, err := unix.SysctlRaw("kern.procargs2", pid) + if err != nil { + return nil, err + } + return parseMacProcArgs2(buf) +} diff --git a/cli/common/unityprocess/process_other.go b/cli/common/unityprocess/process_other.go new file mode 100644 index 0000000000..d0ad296c53 --- /dev/null +++ b/cli/common/unityprocess/process_other.go @@ -0,0 +1,16 @@ +//go:build !darwin + +package unityprocess + +import ( + "context" + "errors" +) + +// listUnityProcessesMac exists so the shared dispatcher in process.go compiles on +// every target OS; the real sysctl-based implementation lives in process_darwin.go +// and this stub is never reached at runtime because listUnityProcesses only calls it +// when runtime.GOOS == "darwin". +func listUnityProcessesMac(_ context.Context) ([]UnityProcess, error) { + return nil, errors.New("listUnityProcessesMac is unsupported on this platform") +} diff --git a/cli/common/unityprocess/process_test.go b/cli/common/unityprocess/process_test.go index 007db34c84..26e2b70ee3 100644 --- a/cli/common/unityprocess/process_test.go +++ b/cli/common/unityprocess/process_test.go @@ -2,30 +2,100 @@ package unityprocess import ( "encoding/base64" + "encoding/binary" "path/filepath" "runtime" "strings" "testing" ) -// Verifies macOS Unity process parsing extracts project paths and skips batchmode workers. -func TestParseMacUnityProcessesExtractsProjectPath(t *testing.T) { - output := `123 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath "/Users//My Project" -useHub -hubIPC -456 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -batchmode -projectPath "/Users//Batch" -789 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath /Users//Other -logFile - -` +// Verifies macOS Unity process matching extracts project paths and skips batchmode workers, +// using the same (pid, command) shape produced by joining a process's sysctl-read argv. +func TestMatchMacUnityProcessExtractsProjectPath(t *testing.T) { + editorProcess, matched := matchMacUnityProcess( + 123, + `/Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath "/Users//My Project" -useHub -hubIPC`) + if !matched { + t.Fatal("expected the Unity editor process to match") + } + if editorProcess.Pid != 123 || editorProcess.projectPath != "/Users//My Project" { + t.Fatalf("editor process mismatch: %#v", editorProcess) + } - processes := parseMacUnityProcesses(output) + _, batchmodeMatched := matchMacUnityProcess( + 456, + `/Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -batchmode -projectPath "/Users//Batch"`) + if batchmodeMatched { + t.Fatal("expected a -batchmode process to be skipped") + } - if len(processes) != 2 { - t.Fatalf("process count mismatch: %#v", processes) + unquotedProcess, unquotedMatched := matchMacUnityProcess( + 789, + `/Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath /Users//Other -logFile -`) + if !unquotedMatched { + t.Fatal("expected an unquoted project path process to match") } - if processes[0].Pid != 123 || processes[0].projectPath != "/Users//My Project" { - t.Fatalf("first process mismatch: %#v", processes[0]) + if unquotedProcess.Pid != 789 || unquotedProcess.projectPath != "/Users//Other" { + t.Fatalf("unquoted process mismatch: %#v", unquotedProcess) } - if processes[1].Pid != 789 || processes[1].projectPath != "/Users//Other" { - t.Fatalf("second process mismatch: %#v", processes[1]) +} + +// Verifies the kern.procargs2 buffer parser decodes argc, skips the exec path and NUL +// padding, and stops after argc argv entries. +func TestParseMacProcArgs2DecodesArgv(t *testing.T) { + buf := buildProcArgs2Fixture(t, "/usr/bin/execpath", []string{"/usr/bin/execpath", "-projectPath", "/tmp/proj"}) + + args, err := parseMacProcArgs2(buf) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + expected := []string{"/usr/bin/execpath", "-projectPath", "/tmp/proj"} + if len(args) != len(expected) { + t.Fatalf("argv length mismatch: %#v", args) + } + for i, want := range expected { + if args[i] != want { + t.Fatalf("argv[%d] mismatch: got %q, want %q", i, args[i], want) + } + } +} + +// Verifies a buffer shorter than the leading argc field is rejected instead of panicking. +func TestParseMacProcArgs2RejectsShortBuffer(t *testing.T) { + _, err := parseMacProcArgs2([]byte{1, 2}) + + if err == nil { + t.Fatal("expected an error for a too-short buffer") + } +} + +// Verifies a buffer missing the NUL-terminated exec path is rejected instead of scanning past the end. +func TestParseMacProcArgs2RejectsMissingExecPathTerminator(t *testing.T) { + buf := make([]byte, 4) + binary.LittleEndian.PutUint32(buf, 1) + buf = append(buf, []byte("/usr/bin/execpath-with-no-terminator")...) + + _, err := parseMacProcArgs2(buf) + + if err == nil { + t.Fatal("expected an error when the exec path has no NUL terminator") + } +} + +// buildProcArgs2Fixture assembles a kern.procargs2-shaped buffer: argc, then the exec +// path NUL terminated, one padding NUL, then each argv entry NUL terminated. +func buildProcArgs2Fixture(t *testing.T, execPath string, argv []string) []byte { + t.Helper() + + buf := make([]byte, 4) + binary.LittleEndian.PutUint32(buf, uint32(len(argv))) + buf = append(buf, []byte(execPath)...) + buf = append(buf, 0) + for _, arg := range argv { + buf = append(buf, []byte(arg)...) + buf = append(buf, 0) } + return buf } // Verifies Windows Unity process parsing decodes Base64 command lines, extracts project paths, and skips batchmode workers. diff --git a/cli/common/unityprocess/timeouts.go b/cli/common/unityprocess/timeouts.go index c536c4561a..5a7ed9e532 100644 --- a/cli/common/unityprocess/timeouts.go +++ b/cli/common/unityprocess/timeouts.go @@ -6,7 +6,8 @@ import ( ) const ( - // ProcessListCommandTimeout bounds ps and PowerShell process-enumeration calls that can hang on WMI stalls. + // ProcessListCommandTimeout bounds the Windows PowerShell process-enumeration call that can hang on WMI stalls. + // macOS enumeration reads process info via sysctl instead of exec'ing an external command, so it does not use this. ProcessListCommandTimeout = 10 * time.Second // FocusCommandTimeout bounds osascript and PowerShell focus calls that can hang on permission dialogs. FocusCommandTimeout = 10 * time.Second diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 121363ffd8..5a6029efd3 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "9eed1894ac125f75cdbb2f5145977ecfa92095bf" + "sharedInputsHash": "ffe6879910a39cf2bfd41750d1004a74009bc166" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index cbc2dd113d..a5ea643d1d 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "5e974fe74190b43814743342f870ff27ded3b83c" + "sharedInputsHash": "365d004e1dd5b0ffd4da57aafb814466595cbf26" }