Implement controlled app launch (T-401)
This commit is contained in:
@@ -4,5 +4,6 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
gioui.org v0.10.1
|
gioui.org v0.10.1
|
||||||
|
golang.org/x/sys v0.39.0
|
||||||
softbox.local/core v0.0.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
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
)
|
||||||
|
|
||||||
// Edition identifies the application build channel shown by the UI.
|
// Edition identifies the application build channel shown by the UI.
|
||||||
type Edition string
|
type Edition string
|
||||||
|
|
||||||
const EditionModern Edition = "Modern"
|
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.
|
// Platform is the minimal boundary for target-specific capabilities.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
OS() string
|
OS() string
|
||||||
Edition() Edition
|
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.
|
// New returns the platform implementation selected by build tags.
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
package windows
|
package windows
|
||||||
|
|
||||||
import "runtime"
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
)
|
||||||
|
|
||||||
type platformStub struct{}
|
type platformStub struct{}
|
||||||
|
|
||||||
@@ -17,3 +21,15 @@ func (platformStub) OS() string {
|
|||||||
func (platformStub) Edition() Edition {
|
func (platformStub) Edition() Edition {
|
||||||
return EditionModern
|
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
|
package windows
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestPlatformStubContract(t *testing.T) {
|
func TestPlatformStubContract(t *testing.T) {
|
||||||
platform := New()
|
platform := New()
|
||||||
@@ -11,3 +15,86 @@ func TestPlatformStubContract(t *testing.T) {
|
|||||||
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionModern)
|
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
|
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{}
|
type platform struct{}
|
||||||
|
|
||||||
func newPlatform() Platform {
|
func newPlatform() Platform {
|
||||||
@@ -15,3 +34,179 @@ func (platform) OS() string {
|
|||||||
func (platform) Edition() Edition {
|
func (platform) Edition() Edition {
|
||||||
return EditionModern
|
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 (
|
require (
|
||||||
gioui.org v0.6.0
|
gioui.org v0.6.0
|
||||||
|
golang.org/x/sys v0.5.0
|
||||||
softbox.local/core v0.0.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/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/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 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=
|
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
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
)
|
||||||
|
|
||||||
// Edition identifies the application build channel shown by the UI.
|
// Edition identifies the application build channel shown by the UI.
|
||||||
type Edition string
|
type Edition string
|
||||||
|
|
||||||
const EditionLegacy Edition = "Legacy"
|
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.
|
// Platform is the minimal boundary for target-specific capabilities.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
OS() string
|
OS() string
|
||||||
Edition() Edition
|
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.
|
// New returns the platform implementation selected by build tags.
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
package windows
|
package windows
|
||||||
|
|
||||||
import "runtime"
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
)
|
||||||
|
|
||||||
type platformStub struct{}
|
type platformStub struct{}
|
||||||
|
|
||||||
@@ -17,3 +21,15 @@ func (platformStub) OS() string {
|
|||||||
func (platformStub) Edition() Edition {
|
func (platformStub) Edition() Edition {
|
||||||
return EditionLegacy
|
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
|
package windows
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestPlatformStubContract(t *testing.T) {
|
func TestPlatformStubContract(t *testing.T) {
|
||||||
platform := New()
|
platform := New()
|
||||||
@@ -11,3 +15,86 @@ func TestPlatformStubContract(t *testing.T) {
|
|||||||
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionLegacy)
|
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
|
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{}
|
type platform struct{}
|
||||||
|
|
||||||
func newPlatform() Platform {
|
func newPlatform() Platform {
|
||||||
@@ -15,3 +34,179 @@ func (platform) OS() string {
|
|||||||
func (platform) Edition() Edition {
|
func (platform) Edition() Edition {
|
||||||
return EditionLegacy
|
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,
|
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
|
var recordWriteErr error
|
||||||
switcher := installer.NewSwitcher(func(currentPath string) error {
|
switcher := installer.NewSwitcherWithPreSwitchCheck(func(currentPath string) error {
|
||||||
if err := service.health(currentPath); err != nil {
|
if err := service.health(currentPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -206,7 +210,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
|
|||||||
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
|
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint))
|
||||||
if err := switcher.Switch(appRoot); err != nil {
|
if err := switcher.Switch(appRoot); err != nil {
|
||||||
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
|
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
|
||||||
}
|
}
|
||||||
@@ -218,6 +222,26 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
|
|||||||
}, nil
|
}, 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(
|
func (service *InstallService) preExtractCheck(
|
||||||
appRoot string,
|
appRoot string,
|
||||||
appID string,
|
appID string,
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.
|
|||||||
if record.Version != "1.2.3" || len(record.Files) != 2 {
|
if record.Version != "1.2.3" || len(record.Files) != 2 {
|
||||||
t.Fatalf("record = %#v", record)
|
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")) {
|
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])
|
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) {
|
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
|
||||||
extractor, err := installer.NewExtractor(installTestLimits())
|
extractor, err := installer.NewExtractor(installTestLimits())
|
||||||
if err != nil {
|
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
|
Files int
|
||||||
Bytes int64
|
Bytes int64
|
||||||
EntrypointPath string
|
EntrypointPath string
|
||||||
|
WorkingDir string
|
||||||
PayloadFiles []ExtractedFile
|
PayloadFiles []ExtractedFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ var (
|
|||||||
|
|
||||||
type HealthCheck func(currentPath string) error
|
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
|
type switchStep string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -42,13 +47,27 @@ func (err *RollbackError) Unwrap() []error {
|
|||||||
|
|
||||||
// Switcher activates a verified staging directory and runs an injected check.
|
// Switcher activates a verified staging directory and runs an injected check.
|
||||||
type Switcher struct {
|
type Switcher struct {
|
||||||
health HealthCheck
|
health HealthCheck
|
||||||
afterStep func(switchStep) error
|
preSwitchCheck PreSwitchCheck
|
||||||
durability durabilityFence
|
afterStep func(switchStep) error
|
||||||
|
durability durabilityFence
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSwitcher(health HealthCheck) *Switcher {
|
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 {
|
func (switcher *Switcher) Switch(root string) error {
|
||||||
@@ -75,6 +94,14 @@ func (switcher *Switcher) Switch(root string) error {
|
|||||||
if state.backup {
|
if state.backup {
|
||||||
return ErrBackupExists
|
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)
|
record := newTransaction(phasePrepared, state.current)
|
||||||
if err := writeTransactionWithFence(layout, record, fence); err != nil {
|
if err := writeTransactionWithFence(layout, record, fence); err != nil {
|
||||||
|
|||||||
@@ -57,6 +57,41 @@ func TestSwitcherRemovesFailedInitialInstall(t *testing.T) {
|
|||||||
assertMissing(t, filepath.Join(root, transactionFileName))
|
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) {
|
func TestRecoverInterruptedSwitch(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ func (extractor Extractor) ExtractVerifiedFileWithCheck(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ExtractResult{}, packageError(PackageStageExtract, err)
|
return ExtractResult{}, packageError(PackageStageExtract, err)
|
||||||
}
|
}
|
||||||
|
result.WorkingDir = manifest.WorkingDir
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ type InstalledFile struct {
|
|||||||
|
|
||||||
// InstalledApp is the local installed-app.json v1 protocol.
|
// InstalledApp is the local installed-app.json v1 protocol.
|
||||||
type InstalledApp struct {
|
type InstalledApp struct {
|
||||||
SchemaVersion int `json:"schema_version"`
|
SchemaVersion int `json:"schema_version"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Architecture string `json:"architecture"`
|
Architecture string `json:"architecture"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
Files []InstalledFile `json:"files"`
|
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.
|
// 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)
|
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.
|
// Inspect reads the installed record and detects an unfinished install journal.
|
||||||
func (store *InstalledAppStore) Inspect(appID string) (InstallationSnapshot, error) {
|
func (store *InstalledAppStore) Inspect(appID string) (InstallationSnapshot, error) {
|
||||||
store.mu.Lock()
|
store.mu.Lock()
|
||||||
@@ -283,6 +318,20 @@ func (record InstalledApp) validate() error {
|
|||||||
if record.Channel != "stable" {
|
if record.Channel != "stable" {
|
||||||
return fmt.Errorf("%w: channel=%q", ErrInstalledAppInvalid, record.Channel)
|
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 {
|
if record.Files == nil {
|
||||||
return fmt.Errorf("%w: files must be an array", ErrInstalledAppInvalid)
|
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 {
|
func validInstalledApp() InstalledApp {
|
||||||
return InstalledApp{
|
return InstalledApp{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ soft_quay/
|
|||||||
|
|
||||||
T-202 将本地识别拆为两层:
|
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`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。
|
- `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。
|
||||||
|
|
||||||
磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。
|
磁盘扫描结果必须在进入 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
|
→ 以同一普通完成文件核对实际长度 = Catalog size → 以同句柄校验 SHA-256
|
||||||
→ 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 有界严格读取根 app.json
|
→ 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 有界严格读取根 app.json
|
||||||
→ 比对 ID/版本/通道/系统/架构/入口/管理员标记 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging 并记录实际文件 hash → 校验 entry_exe
|
→ 比对 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
|
→ 必需 health check → 原子写 installed-app.json → 成功延迟清理 backup / 任一失败恢复 backup
|
||||||
```
|
```
|
||||||
|
|
||||||
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
|
必须防止:绝对路径、`../` 与 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)。
|
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(本地)
|
### 2.4 安装记录 installed-app.json(本地)
|
||||||
|
|
||||||
记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
|
记录实际安装的软件 ID、版本、架构、channel、已验证的启动元数据和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -173,6 +173,10 @@ T-102 Phase 1 原型进一步固定:
|
|||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"architecture": "amd64",
|
"architecture": "amd64",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
"entrypoint": "JsonParser.exe",
|
||||||
|
"working_directory": ".",
|
||||||
|
"min_os": "windows-7-sp1",
|
||||||
|
"requires_admin": false,
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "JsonParser.exe",
|
"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 均拒绝。
|
- 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` 不是新的信任根,仍保留给后续修复功能。
|
- `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,但所有读取都重新严格校验。
|
- 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。
|
||||||
- SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。
|
- 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 错误处理。
|
在同句柄 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。
|
安装结果面向调用方的错误码为下表的稳定英文枚举;UI 负责本地化,原始错误只保留给 `errors.Is`、日志和诊断,不得进入 UI payload。
|
||||||
|
|
||||||
@@ -245,6 +250,23 @@ T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再
|
|||||||
|
|
||||||
上述任一预检或验证失败都发生在 switch 前;已有版本的 `current` 和 `installed-app.json` 必须保持可用,首次安装不得留下 executable `current`。
|
上述任一预检或验证失败都发生在 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(本地)
|
### 2.7 下载任务元数据 download-task.json(本地)
|
||||||
|
|
||||||
每个任务以稳定 `request_id` 为主键,元数据位于 `downloads/tasks/<request_id>.json`,字节文件位于 `downloads/files/<request_id>.part|.download`。本地路径只由客户端从 request_id 派生,不接受 URL 或 Content-Disposition 提供的文件名。
|
每个任务以稳定 `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
|
- 日期: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 不交叉解析
|
- 技术栈:根 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/本地状态/存储、共享 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/记录写失败回滚、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 静态 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/` 记录运行时生成的攻击/传输矩阵
|
- 数据:`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 闸门、打印双目标构建命令)
|
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
- 版本管理: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/` | 已有 | 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 双平台验证入口 |
|
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||||
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
|
| `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 职责文件 |
|
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
|
|
||||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
任务状态以 `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: 进程检测、受控启动与切换临界区复查
|
title: 进程检测、受控启动与切换临界区复查
|
||||||
phase: 4
|
phase: 4
|
||||||
deps: [T-303, T-615]
|
deps: [T-303, T-615]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-19
|
created: 2026-07-19
|
||||||
issue: null
|
issue: null
|
||||||
context_ref: null
|
context_ref: d0cf3333946af1eaace7b9c7f1edc58c38891f4d
|
||||||
claim_branch: null
|
claim_branch: null
|
||||||
work_branch: null
|
work_branch: agent/codex/T-401
|
||||||
write_paths:
|
write_paths:
|
||||||
- docs/tasks/T-401.md
|
- docs/tasks/T-401.md
|
||||||
- core/application/install/
|
- core/application/install/
|
||||||
@@ -17,6 +17,9 @@ write_paths:
|
|||||||
- core/storage/
|
- core/storage/
|
||||||
- app-modern/platform/windows/
|
- app-modern/platform/windows/
|
||||||
- app-win7/platform/windows/
|
- app-win7/platform/windows/
|
||||||
|
- app-modern/go.mod
|
||||||
|
- app-win7/go.mod
|
||||||
|
- app-win7/go.work.sum
|
||||||
- app-modern/cmd/softbox/
|
- app-modern/cmd/softbox/
|
||||||
- app-win7/cmd/softbox/
|
- app-win7/cmd/softbox/
|
||||||
- docs/api.md
|
- docs/api.md
|
||||||
@@ -65,3 +68,5 @@ T-303 已定义 `TargetStateChecker` 并在 staging 前拒绝运行中的旧版
|
|||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 2026-07-19:正式落成。依据 Phase 3 交叉复核冻结 Toolhelp 身份匹配、启动元数据、受控启动、授权注入和 switch 临界区复查;明确 O3、退出协议、许可证策略与 T-601 真机验证的边界。
|
- 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": {
|
"channel": {
|
||||||
"const": "stable"
|
"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": {
|
"files": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
Reference in New Issue
Block a user