Implement controlled app launch (T-401)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user