Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/common/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
78 changes: 46 additions & 32 deletions cli/common/unityprocess/process.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package unityprocess

import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"fmt"
"os/exec"
"path/filepath"
Expand All @@ -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|$)`)
)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Comment on lines +118 to 145

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound argc before using it as slice capacity.

argc is read directly from the raw sysctl buffer and used unbounded as make([]string, 0, argc). A corrupted/unexpected buffer with a very large leading value (e.g. near 0xFFFFFFFF) would attempt a huge allocation and could panic, defeating the malformed-buffer hardening this function is otherwise designed and tested for (short-buffer and missing-terminator cases are already rejected).

🛡️ Proposed fix to cap the pre-allocation
+const maxProcArgs2Argv = 4096 // sane upper bound; real argv counts are far smaller
+
 func parseMacProcArgs2(buf []byte) ([]string, error) {
 	if len(buf) < 4 {
 		return nil, fmt.Errorf("procargs2 buffer too short: %d bytes", len(buf))
 	}
 
 	argc := int(binary.LittleEndian.Uint32(buf[:4]))
+	if argc < 0 || argc > maxProcArgs2Argv {
+		return nil, fmt.Errorf("procargs2 argc out of range: %d", argc)
+	}
 	rest := buf[4:]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
const maxProcArgs2Argv = 4096 // sane upper bound; real argv counts are far smaller
func parseMacProcArgs2(buf []byte) ([]string, error) {
if len(buf) < 4 {
return nil, fmt.Errorf("procargs2 buffer too short: %d bytes", len(buf))
}
argc := int(binary.LittleEndian.Uint32(buf[:4]))
if argc < 0 || argc > maxProcArgs2Argv {
return nil, fmt.Errorf("procargs2 argc out of range: %d", argc)
}
rest := buf[4:]
execPathEnd := bytes.IndexByte(rest, 0)
if execPathEnd < 0 {
return nil, fmt.Errorf("procargs2 missing exec path terminator")
}
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
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/common/unityprocess/process.go` around lines 118 - 145, Cap or otherwise
validate the raw argc value in parseMacProcArgs2 before passing it as the
capacity to make([]string, 0, argc). Ensure malformed values cannot trigger an
enormous allocation or panic, while preserving parsing of valid argument counts
and the existing short-buffer and missing-terminator errors.


func parseWindowsUnityProcesses(output string) []UnityProcess {
Expand Down
57 changes: 57 additions & 0 deletions cli/common/unityprocess/process_darwin.go
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 16 additions & 0 deletions cli/common/unityprocess/process_other.go
Original file line number Diff line number Diff line change
@@ -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")
}
96 changes: 83 additions & 13 deletions cli/common/unityprocess/process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<USER_NAME>/My Project" -useHub -hubIPC
456 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -batchmode -projectPath "/Users/<USER_NAME>/Batch"
789 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath /Users/<USER_NAME>/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/<USER_NAME>/My Project" -useHub -hubIPC`)
if !matched {
t.Fatal("expected the Unity editor process to match")
}
if editorProcess.Pid != 123 || editorProcess.projectPath != "/Users/<USER_NAME>/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/<USER_NAME>/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/<USER_NAME>/Other -logFile -`)
if !unquotedMatched {
t.Fatal("expected an unquoted project path process to match")
}
if processes[0].Pid != 123 || processes[0].projectPath != "/Users/<USER_NAME>/My Project" {
t.Fatalf("first process mismatch: %#v", processes[0])
if unquotedProcess.Pid != 789 || unquotedProcess.projectPath != "/Users/<USER_NAME>/Other" {
t.Fatalf("unquoted process mismatch: %#v", unquotedProcess)
}
if processes[1].Pid != 789 || processes[1].projectPath != "/Users/<USER_NAME>/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.
Expand Down
3 changes: 2 additions & 1 deletion cli/common/unityprocess/timeouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/dispatcher/shared-inputs-stamp.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaVersion": 1,
"sharedInputsHash": "9eed1894ac125f75cdbb2f5145977ecfa92095bf"
"sharedInputsHash": "ffe6879910a39cf2bfd41750d1004a74009bc166"
}
2 changes: 1 addition & 1 deletion cli/project-runner/shared-inputs-stamp.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaVersion": 1,
"sharedInputsHash": "5e974fe74190b43814743342f870ff27ded3b83c"
"sharedInputsHash": "365d004e1dd5b0ffd4da57aafb814466595cbf26"
}