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
}
+1
View File
@@ -4,5 +4,6 @@ go 1.20
require (
gioui.org v0.6.0
golang.org/x/sys v0.5.0
softbox.local/core v0.0.0
)
+1
View File
@@ -7,4 +7,5 @@ golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+UL
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
@@ -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 EditionLegacy Edition = "Legacy"
// 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 EditionLegacy
}
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(), EditionLegacy)
}
}
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 EditionLegacy
}
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
}
+26 -2
View File
@@ -195,9 +195,13 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
SHA256: file.SHA256,
})
}
record.Entrypoint = expectation.App.Entrypoint
record.WorkingDirectory = extracted.WorkingDir
record.MinOS = expectation.App.MinOS
record.RequiresAdmin = expectation.App.RequiresAdmin
var recordWriteErr error
switcher := installer.NewSwitcher(func(currentPath string) error {
switcher := installer.NewSwitcherWithPreSwitchCheck(func(currentPath string) error {
if err := service.health(currentPath); err != nil {
return err
}
@@ -206,7 +210,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
}
return nil
})
}, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint))
if err := switcher.Switch(appRoot); err != nil {
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
}
@@ -218,6 +222,26 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
}, nil
}
func (service *InstallService) preSwitchCheck(
appRoot string,
appID string,
entrypoint string,
) installer.PreSwitchCheck {
return func() error {
running, err := service.targetState.IsRunning(
appID,
filepath.Join(appRoot, "current", entrypoint),
)
if err != nil {
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
}
if running {
return ErrTargetRunning
}
return nil
}
}
func (service *InstallService) preExtractCheck(
appRoot string,
appID string,
+99
View File
@@ -45,6 +45,10 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.
if record.Version != "1.2.3" || len(record.Files) != 2 {
t.Fatalf("record = %#v", record)
}
if record.Entrypoint != "bin/App.exe" || record.WorkingDirectory != "." ||
record.MinOS != "windows-10" || record.RequiresAdmin {
t.Fatalf("launch metadata = %#v", record)
}
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
t.Fatalf("record first file = %#v", record.Files[0])
}
@@ -184,6 +188,101 @@ func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
}
}
func TestInstallServiceRechecksTargetImmediatelyBeforeUpdateSwitch(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
initial := newInstallService(t, store, func(string) error { return nil })
if _, err := initial.Install(InstallRequest{
Entry: installEntry(oldPackage, "1.0.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: oldArchive,
}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
appRoot := filepath.Join(appsRoot, "test-app")
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
targetErr := errors.New("target probe failed")
tests := []struct {
name string
probe func(int) (bool, error)
wantErr error
wantCode FailureCode
}{
{
name: "target starts during extraction",
probe: func(call int) (bool, error) {
return call == 2, nil
},
wantErr: ErrTargetRunning,
wantCode: FailureCodeAppRunning,
},
{
name: "target state fails at switch",
probe: func(call int) (bool, error) {
if call == 2 {
return false, targetErr
}
return false, nil
},
wantErr: targetErr,
wantCode: FailureCodeTargetStateUnavailable,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
calls := 0
service := newInstallServiceWithCheckers(
t,
store,
func(string) error { return nil },
diskSpaceCheckerFunc(func(string) (int64, error) {
return StagingDiskReserveBytes + 64*1024, nil
}),
targetStateCheckerFunc(func(appID, entrypoint string) (bool, error) {
calls++
if appID != "test-app" || entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
t.Fatalf("target check = (%q, %q)", appID, entrypoint)
}
return test.probe(calls)
}),
)
_, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.1.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
}
if stage := installErrorStage(t, err); stage != InstallStageSwitch {
t.Fatalf("stage = %q, want %q", stage, InstallStageSwitch)
}
if code := installErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if calls != 2 {
t.Fatalf("target check calls = %d, want 2", calls)
}
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
t.Fatalf("current after failed update = %q", got)
}
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
t.Fatal("installed-app record changed after failed update")
}
for _, path := range []string{"staging", "backup", "install-transaction.json"} {
if _, statErr := os.Stat(filepath.Join(appRoot, path)); !os.IsNotExist(statErr) {
t.Fatalf("failed update left %s, stat error = %v", path, statErr)
}
}
})
}
}
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
extractor, err := installer.NewExtractor(installTestLimits())
if err != nil {
+319
View File
@@ -0,0 +1,319 @@
// Package launch contains the core-only, fail-closed application startup use
// case. Platform process, compatibility and process-creation capabilities are
// supplied by the caller.
package launch
import (
"errors"
"fmt"
"os"
"regexp"
"softbox.local/core/internal/safepath"
"softbox.local/core/storage"
)
var (
ErrLaunchConfig = errors.New("invalid launch service configuration")
ErrLaunchRequest = errors.New("invalid launch request")
ErrAppNotInstalled = errors.New("app is not installed")
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
ErrCompatibilityCheck = errors.New("system compatibility check failed")
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
ErrAuthorizationCheck = errors.New("launch authorization check failed")
ErrLaunchUnauthorized = errors.New("launch is not authorized")
ErrTargetStateCheck = errors.New("launch target state check failed")
ErrAppRunning = errors.New("installed app is already running")
ErrProcessStart = errors.New("start installed app")
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
)
// FailureCode is the stable, non-localized result of a launch attempt.
type FailureCode string
const (
FailureCodeNotInstalled FailureCode = "not_installed"
FailureCodeLaunchMetadataInvalid FailureCode = "launch_metadata_invalid"
FailureCodeLaunchTargetUnsafe FailureCode = "launch_target_unsafe"
FailureCodeEntrypointMissing FailureCode = "entrypoint_missing"
FailureCodeCompatibilityUnavailable FailureCode = "compatibility_unavailable"
FailureCodeAppIncompatible FailureCode = "app_incompatible"
FailureCodeAuthorizationFailed FailureCode = "authorization_unavailable"
FailureCodeLaunchUnauthorized FailureCode = "not_authorized"
FailureCodeAppRunning FailureCode = "app_running"
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
FailureCodeLaunchFailed FailureCode = "launch_failed"
)
// Error preserves a stable launch code and the diagnostic cause.
type Error struct {
Code FailureCode
Err error
}
func (err *Error) Error() string {
return fmt.Sprintf("launch (%s): %v", err.Code, err.Err)
}
func (err *Error) Unwrap() error {
return err.Err
}
// Request names the installed app to start. The caller cannot supply a path,
// arguments or working directory.
type Request struct {
AppID string
}
// Command contains only paths resolved from verified installed metadata.
type Command struct {
Entrypoint string
WorkingDirectory string
RequiresAdmin bool
}
// Result is returned only after the platform accepted the process start.
type Result struct {
AppID string
PID int
}
// InstalledAppResolver returns an installed record paired with a validated
// real current directory.
type InstalledAppResolver interface {
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
}
// CompatibilityChecker reports whether a recorded min_os may run here.
type CompatibilityChecker interface {
IsCompatible(minOS string) (bool, error)
}
// AuthorizationChecker decides whether the user may launch one app. It is a
// required boundary; license policy is implemented by the later licensing task.
type AuthorizationChecker interface {
IsAuthorized(appID string) (bool, error)
}
// TargetStateChecker reports whether this precise entrypoint is running.
type TargetStateChecker interface {
IsRunning(appID string, entrypointPath string) (bool, error)
}
// ProcessLauncher starts one verified command without accepting shell input.
type ProcessLauncher interface {
Start(command Command) (int, error)
}
// ServiceConfig makes every external launch dependency explicit.
type ServiceConfig struct {
Records InstalledAppResolver
Compatibility CompatibilityChecker
Authorization AuthorizationChecker
TargetState TargetStateChecker
Launcher ProcessLauncher
}
// Service executes the safe local launch flow.
type Service struct {
records InstalledAppResolver
compatibility CompatibilityChecker
authorization AuthorizationChecker
targetState TargetStateChecker
launcher ProcessLauncher
}
func NewService(config ServiceConfig) (*Service, error) {
if config.Records == nil {
return nil, fmt.Errorf("%w: installed app resolver is required", ErrLaunchConfig)
}
if config.Compatibility == nil {
return nil, fmt.Errorf("%w: compatibility checker is required", ErrLaunchConfig)
}
if config.Authorization == nil {
return nil, fmt.Errorf("%w: authorization checker is required", ErrLaunchConfig)
}
if config.TargetState == nil {
return nil, fmt.Errorf("%w: target state checker is required", ErrLaunchConfig)
}
if config.Launcher == nil {
return nil, fmt.Errorf("%w: process launcher is required", ErrLaunchConfig)
}
return &Service{
records: config.Records,
compatibility: config.Compatibility,
authorization: config.Authorization,
targetState: config.TargetState,
launcher: config.Launcher,
}, nil
}
// Start validates local metadata and filesystem identity before invoking the
// platform process launcher.
func (service *Service) Start(request Request) (Result, error) {
if !launchAppIDPattern.MatchString(request.AppID) {
return Result{}, launchError(ErrLaunchRequest)
}
record, current, err := service.records.ResolveCurrent(request.AppID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Result{}, launchError(ErrAppNotInstalled)
}
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
return Result{}, launchError(fmt.Errorf("%w: %w", ErrLaunchTargetUnsafe, err))
}
return Result{}, launchError(fmt.Errorf("resolve installed app: %w", err))
}
command, err := launchCommand(record, current)
if err != nil {
return Result{}, launchError(err)
}
compatible, err := service.compatibility.IsCompatible(record.MinOS)
if err != nil {
return Result{}, launchError(fmt.Errorf("%w: %w", ErrCompatibilityCheck, err))
}
if !compatible {
return Result{}, launchError(ErrAppIncompatible)
}
authorized, err := service.authorization.IsAuthorized(record.ID)
if err != nil {
return Result{}, launchError(fmt.Errorf("%w: %w", ErrAuthorizationCheck, err))
}
if !authorized {
return Result{}, launchError(ErrLaunchUnauthorized)
}
running, err := service.targetState.IsRunning(record.ID, command.Entrypoint)
if err != nil {
return Result{}, launchError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
}
if running {
return Result{}, launchError(ErrAppRunning)
}
pid, err := service.launcher.Start(command)
if err != nil {
return Result{}, launchError(fmt.Errorf("%w: %w", ErrProcessStart, err))
}
if pid <= 0 {
return Result{}, launchError(fmt.Errorf("%w: invalid process ID", ErrProcessStart))
}
return Result{AppID: record.ID, PID: pid}, nil
}
func launchCommand(record storage.InstalledApp, current string) (Command, error) {
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
return Command{}, ErrLaunchMetadata
}
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchMetadata, err)
}
if record.WorkingDirectory != "." {
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchMetadata, err)
}
}
if !validMinOS(record.MinOS) {
return Command{}, ErrLaunchMetadata
}
if !containsEntrypoint(record.Files, record.Entrypoint) {
return Command{}, fmt.Errorf("%w: entrypoint is not in installed files", ErrLaunchMetadata)
}
if err := requireRealDirectory(current); err != nil {
return Command{}, err
}
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
if err != nil {
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchTargetUnsafe, err)
}
workingDirectory := current
if record.WorkingDirectory != "." {
workingDirectory, err = safepath.JoinUnder(current, record.WorkingDirectory)
if err != nil {
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchTargetUnsafe, err)
}
}
if err := requireRealDirectory(workingDirectory); err != nil {
return Command{}, err
}
if err := requireRegularFile(entrypoint); err != nil {
return Command{}, err
}
return Command{
Entrypoint: entrypoint,
WorkingDirectory: workingDirectory,
RequiresAdmin: record.RequiresAdmin,
}, nil
}
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
for _, file := range files {
if file.Path == entrypoint {
return true
}
}
return false
}
func validMinOS(minOS string) bool {
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
}
func requireRealDirectory(path string) error {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return fmt.Errorf("%w: %s", ErrLaunchTargetUnsafe, path)
}
if err != nil {
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("%w: %s is not a real directory", ErrLaunchTargetUnsafe, path)
}
return nil
}
func requireRegularFile(path string) error {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return fmt.Errorf("%w: %s", ErrEntrypointMissing, path)
}
if err != nil {
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%w: %s is not a regular file", ErrLaunchTargetUnsafe, path)
}
return nil
}
func launchError(err error) error {
return &Error{Code: failureCodeFor(err), Err: err}
}
func failureCodeFor(err error) FailureCode {
switch {
case errors.Is(err, ErrAppNotInstalled):
return FailureCodeNotInstalled
case errors.Is(err, ErrLaunchMetadata):
return FailureCodeLaunchMetadataInvalid
case errors.Is(err, ErrLaunchTargetUnsafe):
return FailureCodeLaunchTargetUnsafe
case errors.Is(err, ErrEntrypointMissing):
return FailureCodeEntrypointMissing
case errors.Is(err, ErrCompatibilityCheck):
return FailureCodeCompatibilityUnavailable
case errors.Is(err, ErrAppIncompatible):
return FailureCodeAppIncompatible
case errors.Is(err, ErrAuthorizationCheck):
return FailureCodeAuthorizationFailed
case errors.Is(err, ErrLaunchUnauthorized):
return FailureCodeLaunchUnauthorized
case errors.Is(err, ErrAppRunning):
return FailureCodeAppRunning
case errors.Is(err, ErrTargetStateCheck):
return FailureCodeTargetStateUnavailable
default:
return FailureCodeLaunchFailed
}
}
+288
View File
@@ -0,0 +1,288 @@
package launch
import (
"errors"
"os"
"path/filepath"
"testing"
"softbox.local/core/storage"
)
func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
store, appRoot := seedInstalledApp(t)
launcher := &recordingLauncher{pid: 42}
service := newService(t, store, launcher)
result, err := service.Start(Request{AppID: "test-app"})
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if result != (Result{AppID: "test-app", PID: 42}) {
t.Fatalf("result = %#v", result)
}
wantEntrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
if launcher.command.Entrypoint != wantEntrypoint ||
launcher.command.WorkingDirectory != filepath.Join(appRoot, "current", "bin") ||
!launcher.command.RequiresAdmin {
t.Fatalf("command = %#v", launcher.command)
}
}
func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
errCompatibility := errors.New("compatibility unavailable")
errAuthorization := errors.New("authorization unavailable")
errTargetState := errors.New("target state unavailable")
errStart := errors.New("start failed")
tests := []struct {
name string
mutate func(*storage.InstalledApp, string)
service func(*Service)
wantErr error
wantCode FailureCode
}{
{
name: "missing legacy metadata",
mutate: func(record *storage.InstalledApp, _ string) {
record.Entrypoint = ""
},
wantErr: ErrLaunchMetadata,
wantCode: FailureCodeLaunchMetadataInvalid,
},
{
name: "entrypoint is absent",
mutate: func(_ *storage.InstalledApp, appRoot string) {
if err := os.Remove(filepath.Join(appRoot, "current", "bin", "App.exe")); err != nil {
t.Fatalf("remove entrypoint: %v", err)
}
},
wantErr: ErrEntrypointMissing,
wantCode: FailureCodeEntrypointMissing,
},
{
name: "unsafe current layout",
service: func(service *Service) {
service.records = resolverFunc(func(string) (storage.InstalledApp, string, error) {
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
})
},
wantErr: ErrLaunchTargetUnsafe,
wantCode: FailureCodeLaunchTargetUnsafe,
},
{
name: "incompatible system",
service: func(service *Service) {
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, nil })
},
wantErr: ErrAppIncompatible,
wantCode: FailureCodeAppIncompatible,
},
{
name: "compatibility failure",
service: func(service *Service) {
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, errCompatibility })
},
wantErr: errCompatibility,
wantCode: FailureCodeCompatibilityUnavailable,
},
{
name: "unauthorized",
service: func(service *Service) {
service.authorization = authorizationFunc(func(string) (bool, error) { return false, nil })
},
wantErr: ErrLaunchUnauthorized,
wantCode: FailureCodeLaunchUnauthorized,
},
{
name: "authorization failure",
service: func(service *Service) {
service.authorization = authorizationFunc(func(string) (bool, error) { return false, errAuthorization })
},
wantErr: errAuthorization,
wantCode: FailureCodeAuthorizationFailed,
},
{
name: "already running",
service: func(service *Service) {
service.targetState = targetStateFunc(func(string, string) (bool, error) { return true, nil })
},
wantErr: ErrAppRunning,
wantCode: FailureCodeAppRunning,
},
{
name: "target state failure",
service: func(service *Service) {
service.targetState = targetStateFunc(func(string, string) (bool, error) { return false, errTargetState })
},
wantErr: errTargetState,
wantCode: FailureCodeTargetStateUnavailable,
},
{
name: "launcher failure",
service: func(service *Service) {
service.launcher = &recordingLauncher{err: errStart}
},
wantErr: errStart,
wantCode: FailureCodeLaunchFailed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store, appRoot := seedInstalledApp(t)
record, found, err := store.Read("test-app")
if err != nil || !found {
t.Fatalf("Read() found=%t err=%v", found, err)
}
if test.mutate != nil {
test.mutate(&record, appRoot)
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
}
launcher := &recordingLauncher{pid: 42}
service := newService(t, store, launcher)
if test.service != nil {
test.service(service)
}
_, err = service.Start(Request{AppID: "test-app"})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Start() error = %v, want %v", err, test.wantErr)
}
if code := launchErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if launcher.calls != 0 {
t.Fatalf("launcher calls = %d, want 0", launcher.calls)
}
})
}
}
func TestNewServiceRequiresEveryDependency(t *testing.T) {
store, _ := seedInstalledApp(t)
config := ServiceConfig{
Records: store,
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
Launcher: &recordingLauncher{pid: 1},
}
for _, test := range []struct {
name string
mutate func(*ServiceConfig)
}{
{"records", func(config *ServiceConfig) { config.Records = nil }},
{"compatibility", func(config *ServiceConfig) { config.Compatibility = nil }},
{"authorization", func(config *ServiceConfig) { config.Authorization = nil }},
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
{"launcher", func(config *ServiceConfig) { config.Launcher = nil }},
} {
t.Run(test.name, func(t *testing.T) {
candidate := config
test.mutate(&candidate)
if _, err := NewService(candidate); !errors.Is(err, ErrLaunchConfig) {
t.Fatalf("NewService() error = %v, want %v", err, ErrLaunchConfig)
}
})
}
}
func seedInstalledApp(t *testing.T) (*storage.InstalledAppStore, string) {
t.Helper()
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
record := storage.InstalledApp{
SchemaVersion: 1,
ID: "test-app",
Version: "1.2.3",
Architecture: "amd64",
Channel: "stable",
Entrypoint: "bin/App.exe",
WorkingDirectory: "bin",
MinOS: "windows-10",
RequiresAdmin: true,
Files: []storage.InstalledFile{{
Path: "bin/App.exe",
Size: 1,
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
}},
}
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
appRoot := filepath.Join(appsRoot, record.ID)
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(entrypoint, []byte("x"), 0o700); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
return store, appRoot
}
func newService(t *testing.T, records InstalledAppResolver, launcher ProcessLauncher) *Service {
t.Helper()
service, err := NewService(ServiceConfig{
Records: records,
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
Launcher: launcher,
})
if err != nil {
t.Fatalf("NewService() error = %v", err)
}
return service
}
func launchErrorCode(t *testing.T, err error) FailureCode {
t.Helper()
var launchErr *Error
if !errors.As(err, &launchErr) {
t.Fatalf("error = %v, want launch Error", err)
}
return launchErr.Code
}
type compatibilityFunc func(string) (bool, error)
type resolverFunc func(string) (storage.InstalledApp, string, error)
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
return resolver(appID)
}
func (checker compatibilityFunc) IsCompatible(minOS string) (bool, error) {
return checker(minOS)
}
type authorizationFunc func(string) (bool, error)
func (checker authorizationFunc) IsAuthorized(appID string) (bool, error) {
return checker(appID)
}
type targetStateFunc func(string, string) (bool, error)
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
return checker(appID, entrypoint)
}
type recordingLauncher struct {
command Command
pid int
err error
calls int
}
func (launcher *recordingLauncher) Start(command Command) (int, error) {
launcher.calls++
launcher.command = command
if launcher.err != nil {
return 0, launcher.err
}
return launcher.pid, nil
}
+1
View File
@@ -48,6 +48,7 @@ type ExtractResult struct {
Files int
Bytes int64
EntrypointPath string
WorkingDir string
PayloadFiles []ExtractedFile
}
+31 -4
View File
@@ -14,6 +14,11 @@ var (
type HealthCheck func(currentPath string) error
// PreSwitchCheck runs immediately before replacing an existing current
// directory. It is intentionally core-only so callers can inject process
// state without bringing platform APIs into the transaction.
type PreSwitchCheck func() error
type switchStep string
const (
@@ -42,13 +47,27 @@ func (err *RollbackError) Unwrap() []error {
// Switcher activates a verified staging directory and runs an injected check.
type Switcher struct {
health HealthCheck
afterStep func(switchStep) error
durability durabilityFence
health HealthCheck
preSwitchCheck PreSwitchCheck
afterStep func(switchStep) error
durability durabilityFence
}
func NewSwitcher(health HealthCheck) *Switcher {
return &Switcher{health: health, durability: defaultDurability()}
return NewSwitcherWithPreSwitchCheck(health, nil)
}
// NewSwitcherWithPreSwitchCheck creates a switcher that performs the optional
// check only for updates with an existing current directory.
func NewSwitcherWithPreSwitchCheck(
health HealthCheck,
preSwitchCheck PreSwitchCheck,
) *Switcher {
return &Switcher{
health: health,
preSwitchCheck: preSwitchCheck,
durability: defaultDurability(),
}
}
func (switcher *Switcher) Switch(root string) error {
@@ -75,6 +94,14 @@ func (switcher *Switcher) Switch(root string) error {
if state.backup {
return ErrBackupExists
}
if state.current && switcher.preSwitchCheck != nil {
if err := switcher.preSwitchCheck(); err != nil {
if cleanupErr := removeManagedDirectoryWithFence(layout, layout.staging, fence); cleanupErr != nil {
return errors.Join(err, cleanupErr)
}
return err
}
}
record := newTransaction(phasePrepared, state.current)
if err := writeTransactionWithFence(layout, record, fence); err != nil {
+35
View File
@@ -57,6 +57,41 @@ func TestSwitcherRemovesFailedInitialInstall(t *testing.T) {
assertMissing(t, filepath.Join(root, transactionFileName))
}
func TestSwitcherPreSwitchCheckPreventsUpdateAndCleansStaging(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
checkErr := errors.New("target is running")
switcher := NewSwitcherWithPreSwitchCheck(
func(string) error { return nil },
func() error {
assertVersion(t, filepath.Join(root, "current"), "old")
assertVersion(t, filepath.Join(root, "staging"), "new")
return checkErr
},
)
err := switcher.Switch(root)
if !errors.Is(err, checkErr) {
t.Fatalf("Switch() error = %v, want %v", err, checkErr)
}
assertVersion(t, filepath.Join(root, "current"), "old")
assertMissing(t, filepath.Join(root, "staging"))
assertMissing(t, filepath.Join(root, "backup"))
assertMissing(t, filepath.Join(root, transactionFileName))
}
func TestSwitcherSkipsPreSwitchCheckForInitialInstall(t *testing.T) {
root := makeInstallRoot(t, "", "new")
switcher := NewSwitcherWithPreSwitchCheck(
func(string) error { return nil },
func() error { return errors.New("must not run") },
)
if err := switcher.Switch(root); err != nil {
t.Fatalf("Switch() error = %v", err)
}
assertVersion(t, filepath.Join(root, "current"), "new")
}
func TestRecoverInterruptedSwitch(t *testing.T) {
tests := []struct {
name string
+1
View File
@@ -197,6 +197,7 @@ func (extractor Extractor) ExtractVerifiedFileWithCheck(
if err != nil {
return ExtractResult{}, packageError(PackageStageExtract, err)
}
result.WorkingDir = manifest.WorkingDir
return result, nil
}
+55 -6
View File
@@ -39,12 +39,16 @@ type InstalledFile struct {
// InstalledApp is the local installed-app.json v1 protocol.
type InstalledApp struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Version string `json:"version"`
Architecture string `json:"architecture"`
Channel string `json:"channel"`
Files []InstalledFile `json:"files"`
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Version string `json:"version"`
Architecture string `json:"architecture"`
Channel string `json:"channel"`
Entrypoint string `json:"entrypoint,omitempty"`
WorkingDirectory string `json:"working_directory,omitempty"`
MinOS string `json:"min_os,omitempty"`
RequiresAdmin bool `json:"requires_admin"`
Files []InstalledFile `json:"files"`
}
// InstallationSnapshot contains disk facts without deriving UI status.
@@ -107,6 +111,37 @@ func (store *InstalledAppStore) Read(appID string) (record InstalledApp, found b
return store.readLocked(appID)
}
// ResolveCurrent returns a validated record and the real current directory
// below its app root. It never creates directories and rejects unsafe layouts.
func (store *InstalledAppStore) ResolveCurrent(appID string) (InstalledApp, string, error) {
store.mu.Lock()
defer store.mu.Unlock()
record, found, err := store.readLocked(appID)
if err != nil {
return InstalledApp{}, "", err
}
if !found {
return InstalledApp{}, "", os.ErrNotExist
}
appRoot, exists, err := store.inspectAppRoot(appID)
if err != nil {
return InstalledApp{}, "", err
}
if !exists {
return InstalledApp{}, "", os.ErrNotExist
}
current := filepath.Join(appRoot, "current")
if err := requireRealDirectory(current); err != nil {
if errors.Is(err, os.ErrNotExist) {
return InstalledApp{}, "", fmt.Errorf("%w: current directory is missing: %w", ErrStorageLayoutUnsafe, err)
}
return InstalledApp{}, "", err
}
record.Files = append([]InstalledFile(nil), record.Files...)
return record, current, nil
}
// Inspect reads the installed record and detects an unfinished install journal.
func (store *InstalledAppStore) Inspect(appID string) (InstallationSnapshot, error) {
store.mu.Lock()
@@ -283,6 +318,20 @@ func (record InstalledApp) validate() error {
if record.Channel != "stable" {
return fmt.Errorf("%w: channel=%q", ErrInstalledAppInvalid, record.Channel)
}
if record.Entrypoint != "" {
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
return fmt.Errorf("%w: entrypoint: %v", ErrInstalledAppInvalid, err)
}
}
if record.WorkingDirectory != "" && record.WorkingDirectory != "." {
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
return fmt.Errorf("%w: working_directory: %v", ErrInstalledAppInvalid, err)
}
}
if record.MinOS != "" && record.MinOS != "windows-7-sp1" &&
record.MinOS != "windows-10" && record.MinOS != "windows-11" {
return fmt.Errorf("%w: min_os=%q", ErrInstalledAppInvalid, record.MinOS)
}
if record.Files == nil {
return fmt.Errorf("%w: files must be an array", ErrInstalledAppInvalid)
}
+60
View File
@@ -239,6 +239,66 @@ func TestInstalledAppStoreMissingRecord(t *testing.T) {
}
}
func TestInstalledAppStoreResolveCurrentRequiresRealCurrentDirectory(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := NewInstalledAppStore(appsRoot)
record := validInstalledApp()
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
if _, _, err := store.ResolveCurrent(record.ID); !errors.Is(err, ErrStorageLayoutUnsafe) {
t.Fatalf("ResolveCurrent(missing) error = %v, want %v", err, ErrStorageLayoutUnsafe)
}
current := filepath.Join(appsRoot, record.ID, "current")
if err := os.MkdirAll(current, 0o700); err != nil {
t.Fatalf("MkdirAll(current) error = %v", err)
}
loaded, resolvedCurrent, err := store.ResolveCurrent(record.ID)
if err != nil {
t.Fatalf("ResolveCurrent() error = %v", err)
}
if loaded.ID != record.ID || resolvedCurrent != current {
t.Fatalf("ResolveCurrent() = (%#v, %q), want (%#v, %q)", loaded, resolvedCurrent, record, current)
}
}
func TestInstalledAppStoreValidatesOptionalLaunchMetadata(t *testing.T) {
tests := []struct {
name string
mutate func(*InstalledApp)
}{
{
name: "unsafe entrypoint",
mutate: func(record *InstalledApp) {
record.Entrypoint = "../App.exe"
},
},
{
name: "unsafe working directory",
mutate: func(record *InstalledApp) {
record.WorkingDirectory = "bin/.. "
},
},
{
name: "unsupported minimum OS",
mutate: func(record *InstalledApp) {
record.MinOS = "windows-12"
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
record := validInstalledApp()
test.mutate(&record)
err := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")).Write(record)
if !errors.Is(err, ErrInstalledAppInvalid) {
t.Fatalf("Write() error = %v, want %v", err, ErrInstalledAppInvalid)
}
})
}
}
func validInstalledApp() InstalledApp {
return InstalledApp{
SchemaVersion: 1,
+3 -3
View File
@@ -144,7 +144,7 @@ soft_quay/
T-202 将本地识别拆为两层:
- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。
- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。T-401 新记录同时保存已验证的 `entrypoint`、`working_directory`、`min_os` 与 `requires_admin`;旧 v1 记录仍可读取,但缺少完整元数据不能启动。
- `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。
磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。
@@ -173,13 +173,13 @@ T-301 下载队列:
→ 以同一普通完成文件核对实际长度 = Catalog size → 以同句柄校验 SHA-256
→ 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 有界严格读取根 app.json
→ 比对 ID/版本/通道/系统/架构/入口/管理员标记 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging 并记录实际文件 hash → 校验 entry_exe
→ 恢复旧 transaction(如有)→ current 改名 backup → staging 原子切换为 current
→ 恢复旧 transaction(如有)→ 更新时在 current 改名 backup 紧邻前复查精确 entrypoint 未运行 → current 改名 backup → staging 原子切换为 current
→ 必需 health check → 原子写 installed-app.json → 成功延迟清理 backup / 任一失败恢复 backup
```
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。core 不包含 Windows API、进程枚举、等待、强杀或启动,具体 Toolhelp 适配与进程退出协议由 T-401 在 `platform/windows`/命令装配时实现。
Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。T-401 在 `Switcher` 的 current→backup 临界区重复精确运行状态检查,仅明确运行中映射 `app_running` 并清理 staging,checker 故障映射 `target_state_unavailable`;没有旧 current 的首次安装跳过该 hook。`core/application/launch` 保持纯 core 接口边界,按受控 current/记录、兼容、授权、运行状态、平台启动器的顺序 fail closed;Toolhelp、`RtlGetVersion` 和无参数进程创建/固定 `runas` elevation 仅在两端 `platform/windows`,并有非 Windows 不支持 stub。当前 cmd 没有可信 Catalog、许可证或下载完成文件的生产装配来源,所以没有注入 allow-all 授权或伪装端到端启动按钮;Gio Layout 仍只处理内存事件。
Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。
+24 -2
View File
@@ -164,7 +164,7 @@ T-102 Phase 1 原型进一步固定:
### 2.4 安装记录 installed-app.json(本地)
记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
记录实际安装的软件 ID、版本、架构、channel、已验证的启动元数据和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
```json
{
@@ -173,6 +173,10 @@ T-102 Phase 1 原型进一步固定:
"version": "1.4.2",
"architecture": "amd64",
"channel": "stable",
"entrypoint": "JsonParser.exe",
"working_directory": ".",
"min_os": "windows-7-sp1",
"requires_admin": false,
"files": [
{
"path": "JsonParser.exe",
@@ -187,6 +191,7 @@ T-102 Phase 1 原型进一步固定:
- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、共享 Windows 安全相对路径以外的文件名、大小写折叠重复路径和非法 SHA-256 均拒绝。
- `files` 必须是数组;v1 可以为空。T-302 从实际已验证、CRC/长度检查后写入 staging 的每个 payload 文件计算路径、size 和 SHA-256 并写入完整清单;可选 `files.json` 不是新的信任根,仍保留给后续修复功能。
- T-401 新安装必须写入 `entrypoint`、`working_directory`、`min_os`、`requires_admin`,其值来自已与可信 Catalog 精确比对的 `app.json`。前 3 个字段沿用共享安全相对路径/已知系统版本规则(working directory 额外允许 `.`);entrypoint 必须也是 `files` 内的普通文件。为保持 v1 可读,历史记录可缺少这些可选字段,但启动用例不得猜测默认 EXE、工作目录或最低系统,必须 fail closed。
- 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。
- SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。
@@ -227,7 +232,7 @@ T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再
在同句柄 size/SHA、ZIP 预扫描和严格 `app.json` 身份比对均通过后,提取器会在创建 `staging/` 前提供已规划 payload 的准确展开字节数、普通文件数和安全 entrypoint。安装 use case 必须以 `payload_bytes + 64 MiB` 查询 app root 所在卷的可用空间;可用空间不足时不创建 staging。预检不能替代写入、同步、切换或回滚阶段的 fail-closed I/O 错误处理。
安装 use case 同时在上述位置检查当前 `current/<entrypoint>` 是否正在运行。容量、storage failure classifier 与运行状态均通过 core 接口注入,且均为必需依赖;classifier 只对已带 `ErrStagingOutput` 的原始 I/O 链识别磁盘满,core 不导入 Windows API。检查失败按不可安全继续处理。v1 不强杀、不启动、不等待进程退出。Windows Toolhelp 枚举、正常退出等待与启动协议属于后续 T-401,不能进入 core。
安装 use case 同时在上述位置检查当前 `current/<entrypoint>` 是否正在运行。容量、storage failure classifier 与运行状态均通过 core 接口注入,且均为必需依赖;classifier 只对已带 `ErrStagingOutput` 的原始 I/O 链识别磁盘满,core 不导入 Windows API。检查失败按不可安全继续处理。T-401 又在存在旧 `current` 的更新中、`current → backup` rename 紧邻前复查同一目标;明确运行中清理本次 staging 并返回 `app_running`,检测错误返回 `target_state_unavailable`,其他 switch/rename 错误仍为原有安装失败归因。首次安装不做第二次检查。v1 不强杀、不等待进程退出。
安装结果面向调用方的错误码为下表的稳定英文枚举;UI 负责本地化,原始错误只保留给 `errors.Is`、日志和诊断,不得进入 UI payload。
@@ -245,6 +250,23 @@ T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再
上述任一预检或验证失败都发生在 switch 前;已有版本的 `current` 和 `installed-app.json` 必须保持可用,首次安装不得留下 executable `current`。
### 2.6.1 受控启动与失败码
`core/application/launch` 的请求仅接受 app ID。它从本地记录取得启动元数据,确认 `current` 与工作目录是真实受控目录、entrypoint 是 `current` 内列入 `files` 的普通非 symlink 文件,再依次检查最低系统、授权和该精确 entrypoint 的运行状态,最后调用平台启动器。请求或 UI 不得提供 EXE、路径、参数、URL 或 shell command;授权 checker 是必需注入边界,许可证策略仍由 T-501~T-503 实现。
Windows 平台用 Toolhelp32 快照枚举,并以 `QueryFullProcessImageName` 的规范绝对路径作最终身份匹配;同名 EXE 只可作为查询优化,不能成为运行结论。快照/枚举/候选路径查询失败必须返回错误,不能当作“不在运行”。启动器只接收已验证的绝对 entrypoint 和工作目录:普通启动不经命令 shell 或 PATH 搜索;`requires_admin` 使用固定 `runas` Windows 动词且不传参数。非 Windows stub 对兼容、进程检测和启动均返回明确不支持错误。
| code | 含义 |
| --- | --- |
| `not_installed` | 没有可读取的安装记录 |
| `launch_metadata_invalid` | 历史记录缺少完整启动元数据,或元数据不安全/不一致 |
| `launch_target_unsafe` | `current`、工作目录或 entrypoint 布局不是受控真实对象 |
| `entrypoint_missing` | 记录中的 entrypoint 文件不存在 |
| `compatibility_unavailable` / `app_incompatible` | 无法可靠判定系统,或系统不满足最低版本 |
| `authorization_unavailable` / `not_authorized` | 授权边界不可用,或当前 app 未获授权 |
| `app_running` / `target_state_unavailable` | 精确 entrypoint 已运行,或不能可靠判断其状态 |
| `launch_failed` | 平台拒绝或未能创建受控进程 |
### 2.7 下载任务元数据 download-task.json(本地)
每个任务以稳定 `request_id` 为主键,元数据位于 `downloads/tasks/<request_id>.json`,字节文件位于 `downloads/files/<request_id>.part|.download`。本地路径只由客户端从 request_id 派生,不接受 URL 或 Content-Disposition 提供的文件名。
+7 -7
View File
@@ -13,22 +13,22 @@
## 当前快照
- 日期:2026-07-19
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 已完成;T-401 已正式落成,当前执行进程检测、受控启动与切换临界区复查
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查已完成
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 写入 installed-app;health 或记录写失败经 Switcher 回滚),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:T-401 正在定义并实现 Toolhelp 运行状态、启动元数据/受控启动、授权注入及 `TargetStateChecker` 切换临界区复查;下载 completed 文件的生产消费、许可证策略与完整端到端编排仍不在本任务。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调,但不阻止 T-401。物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入
- 当前 blocker:可领取 T-402。下载 completed 文件的生产消费、许可证策略与完整端到端编排仍未装配;T-401 因此没有向 cmd 注入 allow-all 授权或伪装启动闭环。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615 已完成;T-401 已正式落成,正在执行进程检测与软件启动任务 |
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615 与 T-401 已完成;下一项为 T-402 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
@@ -40,9 +40,9 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`。
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`。
- 正在进行:无。
- 正在准备领取:T-401(其依赖 T-303、T-615 均已完成);完成并提交后才可正式落成 T-402。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
- 正在准备领取:T-402(依赖 T-401 已完成);应先正式落成并提交该任务,再开始子软件更新流程。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
## 当前可运行内容
+8 -3
View File
@@ -3,12 +3,12 @@ id: T-401
title: 进程检测、受控启动与切换临界区复查
phase: 4
deps: [T-303, T-615]
status: TODO
status: DONE
created: 2026-07-19
issue: null
context_ref: null
context_ref: d0cf3333946af1eaace7b9c7f1edc58c38891f4d
claim_branch: null
work_branch: null
work_branch: agent/codex/T-401
write_paths:
- docs/tasks/T-401.md
- core/application/install/
@@ -17,6 +17,9 @@ write_paths:
- core/storage/
- app-modern/platform/windows/
- app-win7/platform/windows/
- app-modern/go.mod
- app-win7/go.mod
- app-win7/go.work.sum
- app-modern/cmd/softbox/
- app-win7/cmd/softbox/
- docs/api.md
@@ -65,3 +68,5 @@ T-303 已定义 `TargetStateChecker` 并在 staging 前拒绝运行中的旧版
## 执行记录
- 2026-07-19:正式落成。依据 Phase 3 交叉复核冻结 Toolhelp 身份匹配、启动元数据、受控启动、授权注入和 switch 临界区复查;明确 O3、退出协议、许可证策略与 T-601 真机验证的边界。
- 2026-07-19:领取任务,基于 `d0cf3333946af1eaace7b9c7f1edc58c38891f4d` 在 `agent/codex/T-401` 执行;先重新记录基线,再按任务规格实现。
- 2026-07-19:完成。`installed-app.json` 新安装写入受验证启动元数据;`application/launch` 只接受 app ID,按受控 current/普通 entrypoint、兼容、授权、精确运行状态和平台启动器顺序 fail closed。双端 Windows 平台以 Toolhelp32 + 完整映像绝对路径匹配、`RtlGetVersion` 兼容判断和无参数受控启动(管理员包固定 `runas`)实现,非 Windows stub 明确返回不支持。更新在 current→backup 紧邻前复查并清理拒绝的 staging,旧 current/记录不变;首次安装跳过复查。当前 cmd 没有可信 Catalog、许可证或 download 消费源,因此未注入 allow-all 授权、未伪装端到端启动按钮;Gio Layout 仍无 I/O。验证通过:`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./application/install ./application/launch ./installer`、两端 Windows amd64 构建、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。
+18
View File
@@ -30,6 +30,24 @@
"channel": {
"const": "stable"
},
"entrypoint": {
"type": "string",
"minLength": 1,
"$comment": "Optional for pre-T-401 v1 records; new installations write a runtime-validated safe relative payload path.",
"pattern": "^(?!/)(?! )(?!.*(/ ))(?!.*\\\\)(?!.*[:<>\"|?*])(?!\\.\\.?(/|$))(?!.*(/\\.\\.?)(/|$))(?!.*//)(?!.*[ .](/|$)).+$"
},
"working_directory": {
"type": "string",
"minLength": 1,
"$comment": "Optional for pre-T-401 v1 records; new installations write . or a runtime-validated safe relative payload directory.",
"pattern": "^(\\.|(?!/)(?! )(?!.*(/ ))(?!.*\\\\)(?!.*[:<>\"|?*])(?!\\.\\.?(/|$))(?!.*(/\\.\\.?)(/|$))(?!.*//)(?!.*[ .](/|$)).+)$"
},
"min_os": {
"enum": ["windows-7-sp1", "windows-10", "windows-11"]
},
"requires_admin": {
"type": "boolean"
},
"files": {
"type": "array",
"items": {