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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type ExtractResult struct {
|
||||
Files int
|
||||
Bytes int64
|
||||
EntrypointPath string
|
||||
WorkingDir string
|
||||
PayloadFiles []ExtractedFile
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,6 +197,7 @@ func (extractor Extractor) ExtractVerifiedFileWithCheck(
|
||||
if err != nil {
|
||||
return ExtractResult{}, packageError(PackageStageExtract, err)
|
||||
}
|
||||
result.WorkingDir = manifest.WorkingDir
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 提供的文件名。
|
||||
|
||||
@@ -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
@@ -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`。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user