From 87083c387f94fef2b8ff7e159ee67e42df11b213 Mon Sep 17 00:00:00 2001 From: ila Date: Sun, 19 Jul 2026 21:10:59 +0800 Subject: [PATCH] Implement controlled app launch (T-401) --- app-modern/go.mod | 1 + app-modern/platform/windows/compatibility.go | 19 ++ app-modern/platform/windows/platform.go | 14 + app-modern/platform/windows/platform_stub.go | 18 +- .../platform/windows/platform_stub_test.go | 23 ++ app-modern/platform/windows/platform_test.go | 89 ++++- .../platform/windows/platform_windows.go | 195 +++++++++++ app-modern/platform/windows/process.go | 56 +++ app-win7/go.mod | 1 + app-win7/go.work.sum | 1 + app-win7/platform/windows/compatibility.go | 19 ++ app-win7/platform/windows/platform.go | 14 + app-win7/platform/windows/platform_stub.go | 18 +- .../platform/windows/platform_stub_test.go | 23 ++ app-win7/platform/windows/platform_test.go | 89 ++++- app-win7/platform/windows/platform_windows.go | 195 +++++++++++ app-win7/platform/windows/process.go | 56 +++ core/application/install/service.go | 28 +- core/application/install/service_test.go | 99 ++++++ core/application/launch/service.go | 319 ++++++++++++++++++ core/application/launch/service_test.go | 288 ++++++++++++++++ core/installer/extractor.go | 1 + core/installer/switcher.go | 35 +- core/installer/switcher_test.go | 35 ++ core/installer/verified_package.go | 1 + core/storage/installed_app.go | 61 +++- core/storage/installed_app_test.go | 60 ++++ docs/04-architecture.md | 6 +- docs/api.md | 26 +- docs/current-state.md | 14 +- docs/tasks/T-401.md | 11 +- schemas/installed-app.schema.json | 18 + 32 files changed, 1802 insertions(+), 31 deletions(-) create mode 100644 app-modern/platform/windows/compatibility.go create mode 100644 app-modern/platform/windows/platform_stub_test.go create mode 100644 app-modern/platform/windows/process.go create mode 100644 app-win7/platform/windows/compatibility.go create mode 100644 app-win7/platform/windows/platform_stub_test.go create mode 100644 app-win7/platform/windows/process.go create mode 100644 core/application/launch/service.go create mode 100644 core/application/launch/service_test.go diff --git a/app-modern/go.mod b/app-modern/go.mod index d615bed..7e6ddba 100644 --- a/app-modern/go.mod +++ b/app-modern/go.mod @@ -4,5 +4,6 @@ go 1.25.0 require ( gioui.org v0.10.1 + golang.org/x/sys v0.39.0 softbox.local/core v0.0.0 ) diff --git a/app-modern/platform/windows/compatibility.go b/app-modern/platform/windows/compatibility.go new file mode 100644 index 0000000..14f6c2b --- /dev/null +++ b/app-modern/platform/windows/compatibility.go @@ -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) + } +} diff --git a/app-modern/platform/windows/platform.go b/app-modern/platform/windows/platform.go index 86b7f47..ed6afb5 100644 --- a/app-modern/platform/windows/platform.go +++ b/app-modern/platform/windows/platform.go @@ -1,14 +1,28 @@ package windows +import ( + "errors" + + "softbox.local/core/application/launch" +) + // Edition identifies the application build channel shown by the UI. type Edition string const EditionModern Edition = "Modern" +// ErrUnsupported reports that a Windows-only capability is unavailable on the +// current host. Callers must treat it as an unknown state, never as a stopped +// process or a compatible system. +var ErrUnsupported = errors.New("windows platform capability is unsupported on this host") + // Platform is the minimal boundary for target-specific capabilities. type Platform interface { OS() string Edition() Edition + IsCompatible(minOS string) (bool, error) + IsRunning(appID, entrypoint string) (bool, error) + Start(command launch.Command) (int, error) } // New returns the platform implementation selected by build tags. diff --git a/app-modern/platform/windows/platform_stub.go b/app-modern/platform/windows/platform_stub.go index b9c8914..dbdaae9 100644 --- a/app-modern/platform/windows/platform_stub.go +++ b/app-modern/platform/windows/platform_stub.go @@ -2,7 +2,11 @@ package windows -import "runtime" +import ( + "runtime" + + "softbox.local/core/application/launch" +) type platformStub struct{} @@ -17,3 +21,15 @@ func (platformStub) OS() string { func (platformStub) Edition() Edition { return EditionModern } + +func (platformStub) IsCompatible(string) (bool, error) { + return false, ErrUnsupported +} + +func (platformStub) IsRunning(string, string) (bool, error) { + return false, ErrUnsupported +} + +func (platformStub) Start(launch.Command) (int, error) { + return 0, ErrUnsupported +} diff --git a/app-modern/platform/windows/platform_stub_test.go b/app-modern/platform/windows/platform_stub_test.go new file mode 100644 index 0000000..ff696fa --- /dev/null +++ b/app-modern/platform/windows/platform_stub_test.go @@ -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) + } +} diff --git a/app-modern/platform/windows/platform_test.go b/app-modern/platform/windows/platform_test.go index ca1f6c9..78cb028 100644 --- a/app-modern/platform/windows/platform_test.go +++ b/app-modern/platform/windows/platform_test.go @@ -1,6 +1,10 @@ package windows -import "testing" +import ( + "errors" + "path/filepath" + "testing" +) func TestPlatformStubContract(t *testing.T) { platform := New() @@ -11,3 +15,86 @@ func TestPlatformStubContract(t *testing.T) { t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionModern) } } + +func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "one", "App.exe") + collision := filepath.Join(root, "two", "App.exe") + + running, err := entrypointIsRunning(target, func() (processSnapshot, error) { + return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil + }) + if err != nil { + t.Fatalf("entrypointIsRunning() error = %v", err) + } + if running { + t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory") + } + + running, err = entrypointIsRunning(target, func() (processSnapshot, error) { + return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil + }) + if err != nil || !running { + t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err) + } +} + +func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) { + expected := errors.New("snapshot failed") + _, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) { + return nil, expected + }) + if !errors.Is(err, expected) { + t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected) + } + + _, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) { + return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil + }) + if !errors.Is(err, expected) { + t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected) + } +} + +func TestVersionSupports(t *testing.T) { + cases := []struct { + minOS string + major, minor, build, service uint32 + want bool + wantErr bool + }{ + {minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true}, + {minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false}, + {minOS: "windows-10", major: 10, want: true}, + {minOS: "windows-11", major: 10, build: 19045, want: false}, + {minOS: "windows-11", major: 10, build: 22000, want: true}, + {minOS: "unknown", wantErr: true}, + } + for _, test := range cases { + got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service) + if (err != nil) != test.wantErr || got != test.want { + t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr) + } + } +} + +type snapshotItem struct { + path string + err error +} + +type scriptedSnapshot struct { + items []snapshotItem + next int +} + +func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) { + if snapshot.next == len(snapshot.items) { + return "", false, nil + } + item := snapshot.items[snapshot.next] + snapshot.next++ + return item.path, true, item.err +} + +func (*scriptedSnapshot) Close() error { return nil } diff --git a/app-modern/platform/windows/platform_windows.go b/app-modern/platform/windows/platform_windows.go index 9b3d7f6..230b2fd 100644 --- a/app-modern/platform/windows/platform_windows.go +++ b/app-modern/platform/windows/platform_windows.go @@ -2,6 +2,25 @@ package windows +import ( + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "softbox.local/core/application/launch" +) + +const seeMaskNoCloseProcess = 0x00000040 + +var ( + procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion") + procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW") +) + type platform struct{} func newPlatform() Platform { @@ -15,3 +34,179 @@ func (platform) OS() string { func (platform) Edition() Edition { return EditionModern } + +func (platform) IsCompatible(minOS string) (bool, error) { + version, err := currentVersion() + if err != nil { + return false, err + } + return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack)) +} + +func (platform) IsRunning(_ string, entrypoint string) (bool, error) { + return entrypointIsRunning(entrypoint, func() (processSnapshot, error) { + return newToolhelpSnapshot(filepath.Base(entrypoint)) + }) +} + +func (platform) Start(command launch.Command) (int, error) { + if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) { + return 0, fmt.Errorf("launch command must contain absolute paths") + } + if command.RequiresAdmin { + return startElevated(command) + } + + cmd := exec.Command(command.Entrypoint) + cmd.Dir = command.WorkingDirectory + if err := cmd.Start(); err != nil { + return 0, err + } + return cmd.Process.Pid, nil +} + +type toolhelpSnapshot struct { + handle windows.Handle + targetName string + entry windows.ProcessEntry32 + started bool +} + +func newToolhelpSnapshot(targetName string) (processSnapshot, error) { + handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil +} + +func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) { + for { + var err error + if !snapshot.started { + snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry)) + err = windows.Process32First(snapshot.handle, &snapshot.entry) + snapshot.started = true + } else { + err = windows.Process32Next(snapshot.handle, &snapshot.entry) + } + if err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return "", false, nil + } + return "", false, err + } + // ExeFile only narrows the expensive query. The identity decision below + // always uses QueryFullProcessImageName's normalized full path. + if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) { + continue + } + path, err := fullProcessImagePath(snapshot.entry.ProcessID) + if err != nil { + return "", false, err + } + return path, true, nil + } +} + +func (snapshot *toolhelpSnapshot) Close() error { + return windows.CloseHandle(snapshot.handle) +} + +func fullProcessImagePath(pid uint32) (string, error) { + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return "", err + } + defer windows.CloseHandle(process) + + for size := uint32(260); size <= 32768; size *= 2 { + buffer := make([]uint16, size) + length := size + err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length) + if err == nil { + return windows.UTF16ToString(buffer[:length]), nil + } + if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) { + return "", err + } + } + return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units") +} + +type rtlOSVersionInfoEx struct { + size uint32 + major uint32 + minor uint32 + build uint32 + platformID uint32 + csdVersion [128]uint16 + servicePack uint16 + servicePackMinor uint16 + suiteMask uint16 + productType byte + reserved byte +} + +func currentVersion() (rtlOSVersionInfoEx, error) { + version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))} + status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version))) + if status != 0 { + return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status) + } + return version, nil +} + +type shellExecuteInfo struct { + size uint32 + mask uint32 + hwnd uintptr + verb *uint16 + file *uint16 + parameters *uint16 + directory *uint16 + show int32 + instance uintptr + idList uintptr + class *uint16 + keyClass uintptr + hotKey uint32 + icon uintptr + process windows.Handle +} + +func startElevated(command launch.Command) (int, error) { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return 0, err + } + file, err := windows.UTF16PtrFromString(command.Entrypoint) + if err != nil { + return 0, err + } + directory, err := windows.UTF16PtrFromString(command.WorkingDirectory) + if err != nil { + return 0, err + } + info := shellExecuteInfo{ + size: uint32(unsafe.Sizeof(shellExecuteInfo{})), + mask: seeMaskNoCloseProcess, + verb: verb, + file: file, + directory: directory, + show: 1, + } + result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info))) + if result == 0 { + return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr) + } + if info.process == 0 { + return 0, fmt.Errorf("ShellExecuteExW did not return a process handle") + } + defer windows.CloseHandle(info.process) + pid, err := windows.GetProcessId(info.process) + if err != nil { + return 0, err + } + return int(pid), nil +} diff --git a/app-modern/platform/windows/process.go b/app-modern/platform/windows/process.go new file mode 100644 index 0000000..4ef6b81 --- /dev/null +++ b/app-modern/platform/windows/process.go @@ -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 +} diff --git a/app-win7/go.mod b/app-win7/go.mod index 644841a..586d05b 100644 --- a/app-win7/go.mod +++ b/app-win7/go.mod @@ -4,5 +4,6 @@ go 1.20 require ( gioui.org v0.6.0 + golang.org/x/sys v0.5.0 softbox.local/core v0.0.0 ) diff --git a/app-win7/go.work.sum b/app-win7/go.work.sum index 396a3a7..591d551 100644 --- a/app-win7/go.work.sum +++ b/app-win7/go.work.sum @@ -7,4 +7,5 @@ golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+UL golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0= golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= diff --git a/app-win7/platform/windows/compatibility.go b/app-win7/platform/windows/compatibility.go new file mode 100644 index 0000000..14f6c2b --- /dev/null +++ b/app-win7/platform/windows/compatibility.go @@ -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) + } +} diff --git a/app-win7/platform/windows/platform.go b/app-win7/platform/windows/platform.go index 8964347..a42d2d3 100644 --- a/app-win7/platform/windows/platform.go +++ b/app-win7/platform/windows/platform.go @@ -1,14 +1,28 @@ package windows +import ( + "errors" + + "softbox.local/core/application/launch" +) + // Edition identifies the application build channel shown by the UI. type Edition string const EditionLegacy Edition = "Legacy" +// ErrUnsupported reports that a Windows-only capability is unavailable on the +// current host. Callers must treat it as an unknown state, never as a stopped +// process or a compatible system. +var ErrUnsupported = errors.New("windows platform capability is unsupported on this host") + // Platform is the minimal boundary for target-specific capabilities. type Platform interface { OS() string Edition() Edition + IsCompatible(minOS string) (bool, error) + IsRunning(appID, entrypoint string) (bool, error) + Start(command launch.Command) (int, error) } // New returns the platform implementation selected by build tags. diff --git a/app-win7/platform/windows/platform_stub.go b/app-win7/platform/windows/platform_stub.go index 363aa52..0a78272 100644 --- a/app-win7/platform/windows/platform_stub.go +++ b/app-win7/platform/windows/platform_stub.go @@ -2,7 +2,11 @@ package windows -import "runtime" +import ( + "runtime" + + "softbox.local/core/application/launch" +) type platformStub struct{} @@ -17,3 +21,15 @@ func (platformStub) OS() string { func (platformStub) Edition() Edition { return EditionLegacy } + +func (platformStub) IsCompatible(string) (bool, error) { + return false, ErrUnsupported +} + +func (platformStub) IsRunning(string, string) (bool, error) { + return false, ErrUnsupported +} + +func (platformStub) Start(launch.Command) (int, error) { + return 0, ErrUnsupported +} diff --git a/app-win7/platform/windows/platform_stub_test.go b/app-win7/platform/windows/platform_stub_test.go new file mode 100644 index 0000000..ff696fa --- /dev/null +++ b/app-win7/platform/windows/platform_stub_test.go @@ -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) + } +} diff --git a/app-win7/platform/windows/platform_test.go b/app-win7/platform/windows/platform_test.go index 588364d..ac3c657 100644 --- a/app-win7/platform/windows/platform_test.go +++ b/app-win7/platform/windows/platform_test.go @@ -1,6 +1,10 @@ package windows -import "testing" +import ( + "errors" + "path/filepath" + "testing" +) func TestPlatformStubContract(t *testing.T) { platform := New() @@ -11,3 +15,86 @@ func TestPlatformStubContract(t *testing.T) { t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionLegacy) } } + +func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "one", "App.exe") + collision := filepath.Join(root, "two", "App.exe") + + running, err := entrypointIsRunning(target, func() (processSnapshot, error) { + return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil + }) + if err != nil { + t.Fatalf("entrypointIsRunning() error = %v", err) + } + if running { + t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory") + } + + running, err = entrypointIsRunning(target, func() (processSnapshot, error) { + return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil + }) + if err != nil || !running { + t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err) + } +} + +func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) { + expected := errors.New("snapshot failed") + _, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) { + return nil, expected + }) + if !errors.Is(err, expected) { + t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected) + } + + _, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) { + return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil + }) + if !errors.Is(err, expected) { + t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected) + } +} + +func TestVersionSupports(t *testing.T) { + cases := []struct { + minOS string + major, minor, build, service uint32 + want bool + wantErr bool + }{ + {minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true}, + {minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false}, + {minOS: "windows-10", major: 10, want: true}, + {minOS: "windows-11", major: 10, build: 19045, want: false}, + {minOS: "windows-11", major: 10, build: 22000, want: true}, + {minOS: "unknown", wantErr: true}, + } + for _, test := range cases { + got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service) + if (err != nil) != test.wantErr || got != test.want { + t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr) + } + } +} + +type snapshotItem struct { + path string + err error +} + +type scriptedSnapshot struct { + items []snapshotItem + next int +} + +func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) { + if snapshot.next == len(snapshot.items) { + return "", false, nil + } + item := snapshot.items[snapshot.next] + snapshot.next++ + return item.path, true, item.err +} + +func (*scriptedSnapshot) Close() error { return nil } diff --git a/app-win7/platform/windows/platform_windows.go b/app-win7/platform/windows/platform_windows.go index e1b2b59..03bdbb7 100644 --- a/app-win7/platform/windows/platform_windows.go +++ b/app-win7/platform/windows/platform_windows.go @@ -2,6 +2,25 @@ package windows +import ( + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "softbox.local/core/application/launch" +) + +const seeMaskNoCloseProcess = 0x00000040 + +var ( + procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion") + procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW") +) + type platform struct{} func newPlatform() Platform { @@ -15,3 +34,179 @@ func (platform) OS() string { func (platform) Edition() Edition { return EditionLegacy } + +func (platform) IsCompatible(minOS string) (bool, error) { + version, err := currentVersion() + if err != nil { + return false, err + } + return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack)) +} + +func (platform) IsRunning(_ string, entrypoint string) (bool, error) { + return entrypointIsRunning(entrypoint, func() (processSnapshot, error) { + return newToolhelpSnapshot(filepath.Base(entrypoint)) + }) +} + +func (platform) Start(command launch.Command) (int, error) { + if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) { + return 0, fmt.Errorf("launch command must contain absolute paths") + } + if command.RequiresAdmin { + return startElevated(command) + } + + cmd := exec.Command(command.Entrypoint) + cmd.Dir = command.WorkingDirectory + if err := cmd.Start(); err != nil { + return 0, err + } + return cmd.Process.Pid, nil +} + +type toolhelpSnapshot struct { + handle windows.Handle + targetName string + entry windows.ProcessEntry32 + started bool +} + +func newToolhelpSnapshot(targetName string) (processSnapshot, error) { + handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil +} + +func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) { + for { + var err error + if !snapshot.started { + snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry)) + err = windows.Process32First(snapshot.handle, &snapshot.entry) + snapshot.started = true + } else { + err = windows.Process32Next(snapshot.handle, &snapshot.entry) + } + if err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return "", false, nil + } + return "", false, err + } + // ExeFile only narrows the expensive query. The identity decision below + // always uses QueryFullProcessImageName's normalized full path. + if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) { + continue + } + path, err := fullProcessImagePath(snapshot.entry.ProcessID) + if err != nil { + return "", false, err + } + return path, true, nil + } +} + +func (snapshot *toolhelpSnapshot) Close() error { + return windows.CloseHandle(snapshot.handle) +} + +func fullProcessImagePath(pid uint32) (string, error) { + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return "", err + } + defer windows.CloseHandle(process) + + for size := uint32(260); size <= 32768; size *= 2 { + buffer := make([]uint16, size) + length := size + err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length) + if err == nil { + return windows.UTF16ToString(buffer[:length]), nil + } + if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) { + return "", err + } + } + return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units") +} + +type rtlOSVersionInfoEx struct { + size uint32 + major uint32 + minor uint32 + build uint32 + platformID uint32 + csdVersion [128]uint16 + servicePack uint16 + servicePackMinor uint16 + suiteMask uint16 + productType byte + reserved byte +} + +func currentVersion() (rtlOSVersionInfoEx, error) { + version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))} + status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version))) + if status != 0 { + return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status) + } + return version, nil +} + +type shellExecuteInfo struct { + size uint32 + mask uint32 + hwnd uintptr + verb *uint16 + file *uint16 + parameters *uint16 + directory *uint16 + show int32 + instance uintptr + idList uintptr + class *uint16 + keyClass uintptr + hotKey uint32 + icon uintptr + process windows.Handle +} + +func startElevated(command launch.Command) (int, error) { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return 0, err + } + file, err := windows.UTF16PtrFromString(command.Entrypoint) + if err != nil { + return 0, err + } + directory, err := windows.UTF16PtrFromString(command.WorkingDirectory) + if err != nil { + return 0, err + } + info := shellExecuteInfo{ + size: uint32(unsafe.Sizeof(shellExecuteInfo{})), + mask: seeMaskNoCloseProcess, + verb: verb, + file: file, + directory: directory, + show: 1, + } + result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info))) + if result == 0 { + return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr) + } + if info.process == 0 { + return 0, fmt.Errorf("ShellExecuteExW did not return a process handle") + } + defer windows.CloseHandle(info.process) + pid, err := windows.GetProcessId(info.process) + if err != nil { + return 0, err + } + return int(pid), nil +} diff --git a/app-win7/platform/windows/process.go b/app-win7/platform/windows/process.go new file mode 100644 index 0000000..4ef6b81 --- /dev/null +++ b/app-win7/platform/windows/process.go @@ -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 +} diff --git a/core/application/install/service.go b/core/application/install/service.go index eddf633..3e40a17 100644 --- a/core/application/install/service.go +++ b/core/application/install/service.go @@ -195,9 +195,13 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e SHA256: file.SHA256, }) } + record.Entrypoint = expectation.App.Entrypoint + record.WorkingDirectory = extracted.WorkingDir + record.MinOS = expectation.App.MinOS + record.RequiresAdmin = expectation.App.RequiresAdmin var recordWriteErr error - switcher := installer.NewSwitcher(func(currentPath string) error { + switcher := installer.NewSwitcherWithPreSwitchCheck(func(currentPath string) error { if err := service.health(currentPath); err != nil { return err } @@ -206,7 +210,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err) } return nil - }) + }, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint)) if err := switcher.Switch(appRoot); err != nil { return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err) } @@ -218,6 +222,26 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e }, nil } +func (service *InstallService) preSwitchCheck( + appRoot string, + appID string, + entrypoint string, +) installer.PreSwitchCheck { + return func() error { + running, err := service.targetState.IsRunning( + appID, + filepath.Join(appRoot, "current", entrypoint), + ) + if err != nil { + return fmt.Errorf("%w: %w", ErrTargetStateCheck, err) + } + if running { + return ErrTargetRunning + } + return nil + } +} + func (service *InstallService) preExtractCheck( appRoot string, appID string, diff --git a/core/application/install/service_test.go b/core/application/install/service_test.go index 6dc0f14..7bb8d1c 100644 --- a/core/application/install/service_test.go +++ b/core/application/install/service_test.go @@ -45,6 +45,10 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing. if record.Version != "1.2.3" || len(record.Files) != 2 { t.Fatalf("record = %#v", record) } + if record.Entrypoint != "bin/App.exe" || record.WorkingDirectory != "." || + record.MinOS != "windows-10" || record.RequiresAdmin { + t.Fatalf("launch metadata = %#v", record) + } if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) { t.Fatalf("record first file = %#v", record.Files[0]) } @@ -184,6 +188,101 @@ func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) { } } +func TestInstallServiceRechecksTargetImmediatelyBeforeUpdateSwitch(t *testing.T) { + appsRoot := filepath.Join(t.TempDir(), "apps") + store := storage.NewInstalledAppStore(appsRoot) + oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable") + initial := newInstallService(t, store, func(string) error { return nil }) + if _, err := initial.Install(InstallRequest{ + Entry: installEntry(oldPackage, "1.0.0"), + Architecture: catalog.ArchitectureAMD64, + DownloadPath: oldArchive, + }); err != nil { + t.Fatalf("initial Install() error = %v", err) + } + + archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable") + appRoot := filepath.Join(appsRoot, "test-app") + oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")) + targetErr := errors.New("target probe failed") + tests := []struct { + name string + probe func(int) (bool, error) + wantErr error + wantCode FailureCode + }{ + { + name: "target starts during extraction", + probe: func(call int) (bool, error) { + return call == 2, nil + }, + wantErr: ErrTargetRunning, + wantCode: FailureCodeAppRunning, + }, + { + name: "target state fails at switch", + probe: func(call int) (bool, error) { + if call == 2 { + return false, targetErr + } + return false, nil + }, + wantErr: targetErr, + wantCode: FailureCodeTargetStateUnavailable, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + service := newInstallServiceWithCheckers( + t, + store, + func(string) error { return nil }, + diskSpaceCheckerFunc(func(string) (int64, error) { + return StagingDiskReserveBytes + 64*1024, nil + }), + targetStateCheckerFunc(func(appID, entrypoint string) (bool, error) { + calls++ + if appID != "test-app" || entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") { + t.Fatalf("target check = (%q, %q)", appID, entrypoint) + } + return test.probe(calls) + }), + ) + + _, err := service.Install(InstallRequest{ + Entry: installEntry(publishedPackage, "1.1.0"), + Architecture: catalog.ArchitectureAMD64, + DownloadPath: archivePath, + }) + if !errors.Is(err, test.wantErr) { + t.Fatalf("Install() error = %v, want %v", err, test.wantErr) + } + if stage := installErrorStage(t, err); stage != InstallStageSwitch { + t.Fatalf("stage = %q, want %q", stage, InstallStageSwitch) + } + if code := installErrorCode(t, err); code != test.wantCode { + t.Fatalf("code = %q, want %q", code, test.wantCode) + } + if calls != 2 { + t.Fatalf("target check calls = %d, want 2", calls) + } + if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" { + t.Fatalf("current after failed update = %q", got) + } + if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord { + t.Fatal("installed-app record changed after failed update") + } + for _, path := range []string{"staging", "backup", "install-transaction.json"} { + if _, statErr := os.Stat(filepath.Join(appRoot, path)); !os.IsNotExist(statErr) { + t.Fatalf("failed update left %s, stat error = %v", path, statErr) + } + } + }) + } +} + func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) { extractor, err := installer.NewExtractor(installTestLimits()) if err != nil { diff --git a/core/application/launch/service.go b/core/application/launch/service.go new file mode 100644 index 0000000..304ce2e --- /dev/null +++ b/core/application/launch/service.go @@ -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 + } +} diff --git a/core/application/launch/service_test.go b/core/application/launch/service_test.go new file mode 100644 index 0000000..a3eed7f --- /dev/null +++ b/core/application/launch/service_test.go @@ -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 +} diff --git a/core/installer/extractor.go b/core/installer/extractor.go index fb39852..6a306a3 100644 --- a/core/installer/extractor.go +++ b/core/installer/extractor.go @@ -48,6 +48,7 @@ type ExtractResult struct { Files int Bytes int64 EntrypointPath string + WorkingDir string PayloadFiles []ExtractedFile } diff --git a/core/installer/switcher.go b/core/installer/switcher.go index b8d09a2..022227e 100644 --- a/core/installer/switcher.go +++ b/core/installer/switcher.go @@ -14,6 +14,11 @@ var ( type HealthCheck func(currentPath string) error +// PreSwitchCheck runs immediately before replacing an existing current +// directory. It is intentionally core-only so callers can inject process +// state without bringing platform APIs into the transaction. +type PreSwitchCheck func() error + type switchStep string const ( @@ -42,13 +47,27 @@ func (err *RollbackError) Unwrap() []error { // Switcher activates a verified staging directory and runs an injected check. type Switcher struct { - health HealthCheck - afterStep func(switchStep) error - durability durabilityFence + health HealthCheck + preSwitchCheck PreSwitchCheck + afterStep func(switchStep) error + durability durabilityFence } func NewSwitcher(health HealthCheck) *Switcher { - return &Switcher{health: health, durability: defaultDurability()} + return NewSwitcherWithPreSwitchCheck(health, nil) +} + +// NewSwitcherWithPreSwitchCheck creates a switcher that performs the optional +// check only for updates with an existing current directory. +func NewSwitcherWithPreSwitchCheck( + health HealthCheck, + preSwitchCheck PreSwitchCheck, +) *Switcher { + return &Switcher{ + health: health, + preSwitchCheck: preSwitchCheck, + durability: defaultDurability(), + } } func (switcher *Switcher) Switch(root string) error { @@ -75,6 +94,14 @@ func (switcher *Switcher) Switch(root string) error { if state.backup { return ErrBackupExists } + if state.current && switcher.preSwitchCheck != nil { + if err := switcher.preSwitchCheck(); err != nil { + if cleanupErr := removeManagedDirectoryWithFence(layout, layout.staging, fence); cleanupErr != nil { + return errors.Join(err, cleanupErr) + } + return err + } + } record := newTransaction(phasePrepared, state.current) if err := writeTransactionWithFence(layout, record, fence); err != nil { diff --git a/core/installer/switcher_test.go b/core/installer/switcher_test.go index 7930f7f..70006be 100644 --- a/core/installer/switcher_test.go +++ b/core/installer/switcher_test.go @@ -57,6 +57,41 @@ func TestSwitcherRemovesFailedInitialInstall(t *testing.T) { assertMissing(t, filepath.Join(root, transactionFileName)) } +func TestSwitcherPreSwitchCheckPreventsUpdateAndCleansStaging(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + checkErr := errors.New("target is running") + switcher := NewSwitcherWithPreSwitchCheck( + func(string) error { return nil }, + func() error { + assertVersion(t, filepath.Join(root, "current"), "old") + assertVersion(t, filepath.Join(root, "staging"), "new") + return checkErr + }, + ) + + err := switcher.Switch(root) + if !errors.Is(err, checkErr) { + t.Fatalf("Switch() error = %v, want %v", err, checkErr) + } + assertVersion(t, filepath.Join(root, "current"), "old") + assertMissing(t, filepath.Join(root, "staging")) + assertMissing(t, filepath.Join(root, "backup")) + assertMissing(t, filepath.Join(root, transactionFileName)) +} + +func TestSwitcherSkipsPreSwitchCheckForInitialInstall(t *testing.T) { + root := makeInstallRoot(t, "", "new") + switcher := NewSwitcherWithPreSwitchCheck( + func(string) error { return nil }, + func() error { return errors.New("must not run") }, + ) + + if err := switcher.Switch(root); err != nil { + t.Fatalf("Switch() error = %v", err) + } + assertVersion(t, filepath.Join(root, "current"), "new") +} + func TestRecoverInterruptedSwitch(t *testing.T) { tests := []struct { name string diff --git a/core/installer/verified_package.go b/core/installer/verified_package.go index d64f0a1..4ccb221 100644 --- a/core/installer/verified_package.go +++ b/core/installer/verified_package.go @@ -197,6 +197,7 @@ func (extractor Extractor) ExtractVerifiedFileWithCheck( if err != nil { return ExtractResult{}, packageError(PackageStageExtract, err) } + result.WorkingDir = manifest.WorkingDir return result, nil } diff --git a/core/storage/installed_app.go b/core/storage/installed_app.go index 16569f7..365c1e7 100644 --- a/core/storage/installed_app.go +++ b/core/storage/installed_app.go @@ -39,12 +39,16 @@ type InstalledFile struct { // InstalledApp is the local installed-app.json v1 protocol. type InstalledApp struct { - SchemaVersion int `json:"schema_version"` - ID string `json:"id"` - Version string `json:"version"` - Architecture string `json:"architecture"` - Channel string `json:"channel"` - Files []InstalledFile `json:"files"` + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Version string `json:"version"` + Architecture string `json:"architecture"` + Channel string `json:"channel"` + Entrypoint string `json:"entrypoint,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` + MinOS string `json:"min_os,omitempty"` + RequiresAdmin bool `json:"requires_admin"` + Files []InstalledFile `json:"files"` } // InstallationSnapshot contains disk facts without deriving UI status. @@ -107,6 +111,37 @@ func (store *InstalledAppStore) Read(appID string) (record InstalledApp, found b return store.readLocked(appID) } +// ResolveCurrent returns a validated record and the real current directory +// below its app root. It never creates directories and rejects unsafe layouts. +func (store *InstalledAppStore) ResolveCurrent(appID string) (InstalledApp, string, error) { + store.mu.Lock() + defer store.mu.Unlock() + + record, found, err := store.readLocked(appID) + if err != nil { + return InstalledApp{}, "", err + } + if !found { + return InstalledApp{}, "", os.ErrNotExist + } + appRoot, exists, err := store.inspectAppRoot(appID) + if err != nil { + return InstalledApp{}, "", err + } + if !exists { + return InstalledApp{}, "", os.ErrNotExist + } + current := filepath.Join(appRoot, "current") + if err := requireRealDirectory(current); err != nil { + if errors.Is(err, os.ErrNotExist) { + return InstalledApp{}, "", fmt.Errorf("%w: current directory is missing: %w", ErrStorageLayoutUnsafe, err) + } + return InstalledApp{}, "", err + } + record.Files = append([]InstalledFile(nil), record.Files...) + return record, current, nil +} + // Inspect reads the installed record and detects an unfinished install journal. func (store *InstalledAppStore) Inspect(appID string) (InstallationSnapshot, error) { store.mu.Lock() @@ -283,6 +318,20 @@ func (record InstalledApp) validate() error { if record.Channel != "stable" { return fmt.Errorf("%w: channel=%q", ErrInstalledAppInvalid, record.Channel) } + if record.Entrypoint != "" { + if err := safepath.ValidateRelative(record.Entrypoint); err != nil { + return fmt.Errorf("%w: entrypoint: %v", ErrInstalledAppInvalid, err) + } + } + if record.WorkingDirectory != "" && record.WorkingDirectory != "." { + if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil { + return fmt.Errorf("%w: working_directory: %v", ErrInstalledAppInvalid, err) + } + } + if record.MinOS != "" && record.MinOS != "windows-7-sp1" && + record.MinOS != "windows-10" && record.MinOS != "windows-11" { + return fmt.Errorf("%w: min_os=%q", ErrInstalledAppInvalid, record.MinOS) + } if record.Files == nil { return fmt.Errorf("%w: files must be an array", ErrInstalledAppInvalid) } diff --git a/core/storage/installed_app_test.go b/core/storage/installed_app_test.go index 2e44938..e271c2e 100644 --- a/core/storage/installed_app_test.go +++ b/core/storage/installed_app_test.go @@ -239,6 +239,66 @@ func TestInstalledAppStoreMissingRecord(t *testing.T) { } } +func TestInstalledAppStoreResolveCurrentRequiresRealCurrentDirectory(t *testing.T) { + appsRoot := filepath.Join(t.TempDir(), "apps") + store := NewInstalledAppStore(appsRoot) + record := validInstalledApp() + if err := store.Write(record); err != nil { + t.Fatalf("Write() error = %v", err) + } + if _, _, err := store.ResolveCurrent(record.ID); !errors.Is(err, ErrStorageLayoutUnsafe) { + t.Fatalf("ResolveCurrent(missing) error = %v, want %v", err, ErrStorageLayoutUnsafe) + } + + current := filepath.Join(appsRoot, record.ID, "current") + if err := os.MkdirAll(current, 0o700); err != nil { + t.Fatalf("MkdirAll(current) error = %v", err) + } + loaded, resolvedCurrent, err := store.ResolveCurrent(record.ID) + if err != nil { + t.Fatalf("ResolveCurrent() error = %v", err) + } + if loaded.ID != record.ID || resolvedCurrent != current { + t.Fatalf("ResolveCurrent() = (%#v, %q), want (%#v, %q)", loaded, resolvedCurrent, record, current) + } +} + +func TestInstalledAppStoreValidatesOptionalLaunchMetadata(t *testing.T) { + tests := []struct { + name string + mutate func(*InstalledApp) + }{ + { + name: "unsafe entrypoint", + mutate: func(record *InstalledApp) { + record.Entrypoint = "../App.exe" + }, + }, + { + name: "unsafe working directory", + mutate: func(record *InstalledApp) { + record.WorkingDirectory = "bin/.. " + }, + }, + { + name: "unsupported minimum OS", + mutate: func(record *InstalledApp) { + record.MinOS = "windows-12" + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + record := validInstalledApp() + test.mutate(&record) + err := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")).Write(record) + if !errors.Is(err, ErrInstalledAppInvalid) { + t.Fatalf("Write() error = %v, want %v", err, ErrInstalledAppInvalid) + } + }) + } +} + func validInstalledApp() InstalledApp { return InstalledApp{ SchemaVersion: 1, diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 199f31f..18da753 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -144,7 +144,7 @@ soft_quay/ T-202 将本地识别拆为两层: -- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。 +- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。T-401 新记录同时保存已验证的 `entrypoint`、`working_directory`、`min_os` 与 `requires_admin`;旧 v1 记录仍可读取,但缺少完整元数据不能启动。 - `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。 磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。 @@ -173,13 +173,13 @@ T-301 下载队列: → 以同一普通完成文件核对实际长度 = Catalog size → 以同句柄校验 SHA-256 → 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 有界严格读取根 app.json → 比对 ID/版本/通道/系统/架构/入口/管理员标记 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging 并记录实际文件 hash → 校验 entry_exe -→ 恢复旧 transaction(如有)→ current 改名 backup → staging 原子切换为 current +→ 恢复旧 transaction(如有)→ 更新时在 current 改名 backup 紧邻前复查精确 entrypoint 未运行 → current 改名 backup → staging 原子切换为 current → 必需 health check → 原子写 installed-app.json → 成功延迟清理 backup / 任一失败恢复 backup ``` 必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 -Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。core 不包含 Windows API、进程枚举、等待、强杀或启动,具体 Toolhelp 适配与进程退出协议由 T-401 在 `platform/windows`/命令装配时实现。 +Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。T-401 在 `Switcher` 的 current→backup 临界区重复精确运行状态检查,仅明确运行中映射 `app_running` 并清理 staging,checker 故障映射 `target_state_unavailable`;没有旧 current 的首次安装跳过该 hook。`core/application/launch` 保持纯 core 接口边界,按受控 current/记录、兼容、授权、运行状态、平台启动器的顺序 fail closed;Toolhelp、`RtlGetVersion` 和无参数进程创建/固定 `runas` elevation 仅在两端 `platform/windows`,并有非 Windows 不支持 stub。当前 cmd 没有可信 Catalog、许可证或下载完成文件的生产装配来源,所以没有注入 allow-all 授权或伪装端到端启动按钮;Gio Layout 仍只处理内存事件。 Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。 diff --git a/docs/api.md b/docs/api.md index 76afb31..1a0cccc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -164,7 +164,7 @@ T-102 Phase 1 原型进一步固定: ### 2.4 安装记录 installed-app.json(本地) -记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps//`。 +记录实际安装的软件 ID、版本、架构、channel、已验证的启动元数据和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps//`。 ```json { @@ -173,6 +173,10 @@ T-102 Phase 1 原型进一步固定: "version": "1.4.2", "architecture": "amd64", "channel": "stable", + "entrypoint": "JsonParser.exe", + "working_directory": ".", + "min_os": "windows-7-sp1", + "requires_admin": false, "files": [ { "path": "JsonParser.exe", @@ -187,6 +191,7 @@ T-102 Phase 1 原型进一步固定: - Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、共享 Windows 安全相对路径以外的文件名、大小写折叠重复路径和非法 SHA-256 均拒绝。 - `files` 必须是数组;v1 可以为空。T-302 从实际已验证、CRC/长度检查后写入 staging 的每个 payload 文件计算路径、size 和 SHA-256 并写入完整清单;可选 `files.json` 不是新的信任根,仍保留给后续修复功能。 +- T-401 新安装必须写入 `entrypoint`、`working_directory`、`min_os`、`requires_admin`,其值来自已与可信 Catalog 精确比对的 `app.json`。前 3 个字段沿用共享安全相对路径/已知系统版本规则(working directory 额外允许 `.`);entrypoint 必须也是 `files` 内的普通文件。为保持 v1 可读,历史记录可缺少这些可选字段,但启动用例不得猜测默认 EXE、工作目录或最低系统,必须 fail closed。 - 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。 - SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。 @@ -227,7 +232,7 @@ T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再 在同句柄 size/SHA、ZIP 预扫描和严格 `app.json` 身份比对均通过后,提取器会在创建 `staging/` 前提供已规划 payload 的准确展开字节数、普通文件数和安全 entrypoint。安装 use case 必须以 `payload_bytes + 64 MiB` 查询 app root 所在卷的可用空间;可用空间不足时不创建 staging。预检不能替代写入、同步、切换或回滚阶段的 fail-closed I/O 错误处理。 -安装 use case 同时在上述位置检查当前 `current/` 是否正在运行。容量、storage failure classifier 与运行状态均通过 core 接口注入,且均为必需依赖;classifier 只对已带 `ErrStagingOutput` 的原始 I/O 链识别磁盘满,core 不导入 Windows API。检查失败按不可安全继续处理。v1 不强杀、不启动、不等待进程退出。Windows Toolhelp 枚举、正常退出等待与启动协议属于后续 T-401,不能进入 core。 +安装 use case 同时在上述位置检查当前 `current/` 是否正在运行。容量、storage failure classifier 与运行状态均通过 core 接口注入,且均为必需依赖;classifier 只对已带 `ErrStagingOutput` 的原始 I/O 链识别磁盘满,core 不导入 Windows API。检查失败按不可安全继续处理。T-401 又在存在旧 `current` 的更新中、`current → backup` rename 紧邻前复查同一目标;明确运行中清理本次 staging 并返回 `app_running`,检测错误返回 `target_state_unavailable`,其他 switch/rename 错误仍为原有安装失败归因。首次安装不做第二次检查。v1 不强杀、不等待进程退出。 安装结果面向调用方的错误码为下表的稳定英文枚举;UI 负责本地化,原始错误只保留给 `errors.Is`、日志和诊断,不得进入 UI payload。 @@ -245,6 +250,23 @@ T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再 上述任一预检或验证失败都发生在 switch 前;已有版本的 `current` 和 `installed-app.json` 必须保持可用,首次安装不得留下 executable `current`。 +### 2.6.1 受控启动与失败码 + +`core/application/launch` 的请求仅接受 app ID。它从本地记录取得启动元数据,确认 `current` 与工作目录是真实受控目录、entrypoint 是 `current` 内列入 `files` 的普通非 symlink 文件,再依次检查最低系统、授权和该精确 entrypoint 的运行状态,最后调用平台启动器。请求或 UI 不得提供 EXE、路径、参数、URL 或 shell command;授权 checker 是必需注入边界,许可证策略仍由 T-501~T-503 实现。 + +Windows 平台用 Toolhelp32 快照枚举,并以 `QueryFullProcessImageName` 的规范绝对路径作最终身份匹配;同名 EXE 只可作为查询优化,不能成为运行结论。快照/枚举/候选路径查询失败必须返回错误,不能当作“不在运行”。启动器只接收已验证的绝对 entrypoint 和工作目录:普通启动不经命令 shell 或 PATH 搜索;`requires_admin` 使用固定 `runas` Windows 动词且不传参数。非 Windows stub 对兼容、进程检测和启动均返回明确不支持错误。 + +| code | 含义 | +| --- | --- | +| `not_installed` | 没有可读取的安装记录 | +| `launch_metadata_invalid` | 历史记录缺少完整启动元数据,或元数据不安全/不一致 | +| `launch_target_unsafe` | `current`、工作目录或 entrypoint 布局不是受控真实对象 | +| `entrypoint_missing` | 记录中的 entrypoint 文件不存在 | +| `compatibility_unavailable` / `app_incompatible` | 无法可靠判定系统,或系统不满足最低版本 | +| `authorization_unavailable` / `not_authorized` | 授权边界不可用,或当前 app 未获授权 | +| `app_running` / `target_state_unavailable` | 精确 entrypoint 已运行,或不能可靠判断其状态 | +| `launch_failed` | 平台拒绝或未能创建受控进程 | + ### 2.7 下载任务元数据 download-task.json(本地) 每个任务以稳定 `request_id` 为主键,元数据位于 `downloads/tasks/.json`,字节文件位于 `downloads/files/.part|.download`。本地路径只由客户端从 request_id 派生,不接受 URL 或 Content-Disposition 提供的文件名。 diff --git a/docs/current-state.md b/docs/current-state.md index 4b992e8..488ddb9 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,22 +13,22 @@ ## 当前快照 - 日期:2026-07-19 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 已完成;T-401 已正式落成,当前执行进程检测、受控启动与切换临界区复查 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查已完成 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 -- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 写入 installed-app;health 或记录写失败经 Switcher 回滚),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 -- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 +- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:T-401 正在定义并实现 Toolhelp 运行状态、启动元数据/受控启动、授权注入及 `TargetStateChecker` 切换临界区复查;下载 completed 文件的生产消费、许可证策略与完整端到端编排仍不在本任务。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调,但不阻止 T-401。物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入 +- 当前 blocker:可领取 T-402。下载 completed 文件的生产消费、许可证策略与完整端到端编排仍未装配;T-401 因此没有向 cmd 注入 allow-all 授权或伪装启动闭环。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615 已完成;T-401 已正式落成,正在执行进程检测与软件启动任务 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615 与 T-401 已完成;下一项为 T-402 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`。 - 正在进行:无。 -- 正在准备领取:T-401(其依赖 T-303、T-615 均已完成);完成并提交后才可正式落成 T-402。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 +- 正在准备领取:T-402(依赖 T-401 已完成);应先正式落成并提交该任务,再开始子软件更新流程。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 ## 当前可运行内容 diff --git a/docs/tasks/T-401.md b/docs/tasks/T-401.md index c0855cf..8de4584 100644 --- a/docs/tasks/T-401.md +++ b/docs/tasks/T-401.md @@ -3,12 +3,12 @@ id: T-401 title: 进程检测、受控启动与切换临界区复查 phase: 4 deps: [T-303, T-615] -status: TODO +status: DONE created: 2026-07-19 issue: null -context_ref: null +context_ref: d0cf3333946af1eaace7b9c7f1edc58c38891f4d claim_branch: null -work_branch: null +work_branch: agent/codex/T-401 write_paths: - docs/tasks/T-401.md - core/application/install/ @@ -17,6 +17,9 @@ write_paths: - core/storage/ - app-modern/platform/windows/ - app-win7/platform/windows/ + - app-modern/go.mod + - app-win7/go.mod + - app-win7/go.work.sum - app-modern/cmd/softbox/ - app-win7/cmd/softbox/ - docs/api.md @@ -65,3 +68,5 @@ T-303 已定义 `TargetStateChecker` 并在 staging 前拒绝运行中的旧版 ## 执行记录 - 2026-07-19:正式落成。依据 Phase 3 交叉复核冻结 Toolhelp 身份匹配、启动元数据、受控启动、授权注入和 switch 临界区复查;明确 O3、退出协议、许可证策略与 T-601 真机验证的边界。 +- 2026-07-19:领取任务,基于 `d0cf3333946af1eaace7b9c7f1edc58c38891f4d` 在 `agent/codex/T-401` 执行;先重新记录基线,再按任务规格实现。 +- 2026-07-19:完成。`installed-app.json` 新安装写入受验证启动元数据;`application/launch` 只接受 app ID,按受控 current/普通 entrypoint、兼容、授权、精确运行状态和平台启动器顺序 fail closed。双端 Windows 平台以 Toolhelp32 + 完整映像绝对路径匹配、`RtlGetVersion` 兼容判断和无参数受控启动(管理员包固定 `runas`)实现,非 Windows stub 明确返回不支持。更新在 current→backup 紧邻前复查并清理拒绝的 staging,旧 current/记录不变;首次安装跳过复查。当前 cmd 没有可信 Catalog、许可证或 download 消费源,因此未注入 allow-all 授权、未伪装端到端启动按钮;Gio Layout 仍无 I/O。验证通过:`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./application/install ./application/launch ./installer`、两端 Windows amd64 构建、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。 diff --git a/schemas/installed-app.schema.json b/schemas/installed-app.schema.json index 7b4487f..b56a59f 100644 --- a/schemas/installed-app.schema.json +++ b/schemas/installed-app.schema.json @@ -30,6 +30,24 @@ "channel": { "const": "stable" }, + "entrypoint": { + "type": "string", + "minLength": 1, + "$comment": "Optional for pre-T-401 v1 records; new installations write a runtime-validated safe relative payload path.", + "pattern": "^(?!/)(?! )(?!.*(/ ))(?!.*\\\\)(?!.*[:<>\"|?*])(?!\\.\\.?(/|$))(?!.*(/\\.\\.?)(/|$))(?!.*//)(?!.*[ .](/|$)).+$" + }, + "working_directory": { + "type": "string", + "minLength": 1, + "$comment": "Optional for pre-T-401 v1 records; new installations write . or a runtime-validated safe relative payload directory.", + "pattern": "^(\\.|(?!/)(?! )(?!.*(/ ))(?!.*\\\\)(?!.*[:<>\"|?*])(?!\\.\\.?(/|$))(?!.*(/\\.\\.?)(/|$))(?!.*//)(?!.*[ .](/|$)).+)$" + }, + "min_os": { + "enum": ["windows-7-sp1", "windows-10", "windows-11"] + }, + "requires_admin": { + "type": "boolean" + }, "files": { "type": "array", "items": {