Implement controlled app launch (T-401)

This commit is contained in:
ila
2026-07-19 21:10:59 +08:00
parent d0cf333394
commit 87083c387f
32 changed files with 1802 additions and 31 deletions
+1
View File
@@ -4,5 +4,6 @@ go 1.25.0
require (
gioui.org v0.10.1
golang.org/x/sys v0.39.0
softbox.local/core v0.0.0
)
@@ -0,0 +1,19 @@
package windows
import "fmt"
func versionSupports(minOS string, major, minor, build, servicePack uint32) (bool, error) {
switch minOS {
case "windows-7-sp1":
if major > 6 || (major == 6 && minor > 1) {
return true, nil
}
return major == 6 && minor == 1 && servicePack >= 1, nil
case "windows-10":
return major >= 10, nil
case "windows-11":
return major >= 10 && build >= 22000, nil
default:
return false, fmt.Errorf("unsupported minimum Windows release %q", minOS)
}
}
+14
View File
@@ -1,14 +1,28 @@
package windows
import (
"errors"
"softbox.local/core/application/launch"
)
// Edition identifies the application build channel shown by the UI.
type Edition string
const EditionModern Edition = "Modern"
// ErrUnsupported reports that a Windows-only capability is unavailable on the
// current host. Callers must treat it as an unknown state, never as a stopped
// process or a compatible system.
var ErrUnsupported = errors.New("windows platform capability is unsupported on this host")
// Platform is the minimal boundary for target-specific capabilities.
type Platform interface {
OS() string
Edition() Edition
IsCompatible(minOS string) (bool, error)
IsRunning(appID, entrypoint string) (bool, error)
Start(command launch.Command) (int, error)
}
// New returns the platform implementation selected by build tags.
+17 -1
View File
@@ -2,7 +2,11 @@
package windows
import "runtime"
import (
"runtime"
"softbox.local/core/application/launch"
)
type platformStub struct{}
@@ -17,3 +21,15 @@ func (platformStub) OS() string {
func (platformStub) Edition() Edition {
return EditionModern
}
func (platformStub) IsCompatible(string) (bool, error) {
return false, ErrUnsupported
}
func (platformStub) IsRunning(string, string) (bool, error) {
return false, ErrUnsupported
}
func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported
}
@@ -0,0 +1,23 @@
//go:build !windows
package windows
import (
"errors"
"testing"
"softbox.local/core/application/launch"
)
func TestPlatformStubFailsClosed(t *testing.T) {
platform := New()
if _, err := platform.IsRunning("test-app", "C:/test/App.exe"); !errors.Is(err, ErrUnsupported) {
t.Fatalf("IsRunning() error = %v, want ErrUnsupported", err)
}
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
}
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
}
}
+88 -1
View File
@@ -1,6 +1,10 @@
package windows
import "testing"
import (
"errors"
"path/filepath"
"testing"
)
func TestPlatformStubContract(t *testing.T) {
platform := New()
@@ -11,3 +15,86 @@ func TestPlatformStubContract(t *testing.T) {
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionModern)
}
}
func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "one", "App.exe")
collision := filepath.Join(root, "two", "App.exe")
running, err := entrypointIsRunning(target, func() (processSnapshot, error) {
return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil
})
if err != nil {
t.Fatalf("entrypointIsRunning() error = %v", err)
}
if running {
t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory")
}
running, err = entrypointIsRunning(target, func() (processSnapshot, error) {
return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil
})
if err != nil || !running {
t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err)
}
}
func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) {
expected := errors.New("snapshot failed")
_, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) {
return nil, expected
})
if !errors.Is(err, expected) {
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
}
_, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) {
return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil
})
if !errors.Is(err, expected) {
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
}
}
func TestVersionSupports(t *testing.T) {
cases := []struct {
minOS string
major, minor, build, service uint32
want bool
wantErr bool
}{
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true},
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false},
{minOS: "windows-10", major: 10, want: true},
{minOS: "windows-11", major: 10, build: 19045, want: false},
{minOS: "windows-11", major: 10, build: 22000, want: true},
{minOS: "unknown", wantErr: true},
}
for _, test := range cases {
got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service)
if (err != nil) != test.wantErr || got != test.want {
t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr)
}
}
}
type snapshotItem struct {
path string
err error
}
type scriptedSnapshot struct {
items []snapshotItem
next int
}
func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) {
if snapshot.next == len(snapshot.items) {
return "", false, nil
}
item := snapshot.items[snapshot.next]
snapshot.next++
return item.path, true, item.err
}
func (*scriptedSnapshot) Close() error { return nil }
@@ -2,6 +2,25 @@
package windows
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"softbox.local/core/application/launch"
)
const seeMaskNoCloseProcess = 0x00000040
var (
procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion")
procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW")
)
type platform struct{}
func newPlatform() Platform {
@@ -15,3 +34,179 @@ func (platform) OS() string {
func (platform) Edition() Edition {
return EditionModern
}
func (platform) IsCompatible(minOS string) (bool, error) {
version, err := currentVersion()
if err != nil {
return false, err
}
return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack))
}
func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
return entrypointIsRunning(entrypoint, func() (processSnapshot, error) {
return newToolhelpSnapshot(filepath.Base(entrypoint))
})
}
func (platform) Start(command launch.Command) (int, error) {
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
return 0, fmt.Errorf("launch command must contain absolute paths")
}
if command.RequiresAdmin {
return startElevated(command)
}
cmd := exec.Command(command.Entrypoint)
cmd.Dir = command.WorkingDirectory
if err := cmd.Start(); err != nil {
return 0, err
}
return cmd.Process.Pid, nil
}
type toolhelpSnapshot struct {
handle windows.Handle
targetName string
entry windows.ProcessEntry32
started bool
}
func newToolhelpSnapshot(targetName string) (processSnapshot, error) {
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, err
}
return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil
}
func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) {
for {
var err error
if !snapshot.started {
snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry))
err = windows.Process32First(snapshot.handle, &snapshot.entry)
snapshot.started = true
} else {
err = windows.Process32Next(snapshot.handle, &snapshot.entry)
}
if err != nil {
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return "", false, nil
}
return "", false, err
}
// ExeFile only narrows the expensive query. The identity decision below
// always uses QueryFullProcessImageName's normalized full path.
if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) {
continue
}
path, err := fullProcessImagePath(snapshot.entry.ProcessID)
if err != nil {
return "", false, err
}
return path, true, nil
}
}
func (snapshot *toolhelpSnapshot) Close() error {
return windows.CloseHandle(snapshot.handle)
}
func fullProcessImagePath(pid uint32) (string, error) {
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return "", err
}
defer windows.CloseHandle(process)
for size := uint32(260); size <= 32768; size *= 2 {
buffer := make([]uint16, size)
length := size
err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length)
if err == nil {
return windows.UTF16ToString(buffer[:length]), nil
}
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
return "", err
}
}
return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units")
}
type rtlOSVersionInfoEx struct {
size uint32
major uint32
minor uint32
build uint32
platformID uint32
csdVersion [128]uint16
servicePack uint16
servicePackMinor uint16
suiteMask uint16
productType byte
reserved byte
}
func currentVersion() (rtlOSVersionInfoEx, error) {
version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))}
status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version)))
if status != 0 {
return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status)
}
return version, nil
}
type shellExecuteInfo struct {
size uint32
mask uint32
hwnd uintptr
verb *uint16
file *uint16
parameters *uint16
directory *uint16
show int32
instance uintptr
idList uintptr
class *uint16
keyClass uintptr
hotKey uint32
icon uintptr
process windows.Handle
}
func startElevated(command launch.Command) (int, error) {
verb, err := windows.UTF16PtrFromString("runas")
if err != nil {
return 0, err
}
file, err := windows.UTF16PtrFromString(command.Entrypoint)
if err != nil {
return 0, err
}
directory, err := windows.UTF16PtrFromString(command.WorkingDirectory)
if err != nil {
return 0, err
}
info := shellExecuteInfo{
size: uint32(unsafe.Sizeof(shellExecuteInfo{})),
mask: seeMaskNoCloseProcess,
verb: verb,
file: file,
directory: directory,
show: 1,
}
result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
if result == 0 {
return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr)
}
if info.process == 0 {
return 0, fmt.Errorf("ShellExecuteExW did not return a process handle")
}
defer windows.CloseHandle(info.process)
pid, err := windows.GetProcessId(info.process)
if err != nil {
return 0, err
}
return int(pid), nil
}
+56
View File
@@ -0,0 +1,56 @@
package windows
import (
"fmt"
"path/filepath"
"strings"
)
// processSnapshot is deliberately small so the matching invariant can be
// tested without a Windows host. NextImagePath returns more=false only after a
// successful end-of-snapshot; any enumeration or image-path failure is an
// error, rather than evidence that the target is not running.
type processSnapshot interface {
NextImagePath() (path string, more bool, err error)
Close() error
}
type processSnapshotFactory func() (processSnapshot, error)
func entrypointIsRunning(entrypoint string, newSnapshot processSnapshotFactory) (bool, error) {
target, err := canonicalProcessPath(entrypoint)
if err != nil {
return false, fmt.Errorf("canonicalize target entrypoint: %w", err)
}
snapshot, err := newSnapshot()
if err != nil {
return false, fmt.Errorf("create process snapshot: %w", err)
}
defer snapshot.Close()
for {
path, more, err := snapshot.NextImagePath()
if err != nil {
return false, fmt.Errorf("enumerate process image path: %w", err)
}
if !more {
return false, nil
}
candidate, err := canonicalProcessPath(path)
if err != nil {
return false, fmt.Errorf("canonicalize process image path: %w", err)
}
if strings.EqualFold(target, candidate) {
return true, nil
}
}
}
func canonicalProcessPath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}