Implement app update orchestration (T-402)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -22,6 +24,7 @@ type Platform interface {
|
||||
Edition() Edition
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
IsRunning(appID, entrypoint string) (bool, error)
|
||||
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
|
||||
Start(command launch.Command) (int, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -30,6 +32,10 @@ func (platformStub) IsRunning(string, string) (bool, error) {
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) Start(launch.Command) (int, error) {
|
||||
return 0, ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -17,6 +19,9 @@ func TestPlatformStubFailsClosed(t *testing.T) {
|
||||
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlatformStubContract(t *testing.T) {
|
||||
@@ -78,6 +80,71 @@ func TestVersionSupports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForExit(t *testing.T) {
|
||||
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
|
||||
errSnapshot := errors.New("snapshot failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
running []bool
|
||||
runErr error
|
||||
timeout time.Duration
|
||||
wantErr error
|
||||
wantSleeps int
|
||||
}{
|
||||
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
|
||||
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
|
||||
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
|
||||
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
|
||||
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
clock := &fakeExitWaitClock{now: initial}
|
||||
index := 0
|
||||
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
|
||||
if test.runErr != nil {
|
||||
return false, test.runErr
|
||||
}
|
||||
if index >= len(test.running) {
|
||||
return test.running[len(test.running)-1], nil
|
||||
}
|
||||
running := test.running[index]
|
||||
index++
|
||||
return running, nil
|
||||
}, clock)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if clock.sleeps != test.wantSleeps {
|
||||
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func canceledWaitContext() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type fakeExitWaitClock struct {
|
||||
now time.Time
|
||||
sleeps int
|
||||
}
|
||||
|
||||
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
|
||||
|
||||
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
clock.sleeps++
|
||||
clock.now = clock.now.Add(duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotItem struct {
|
||||
path string
|
||||
err error
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -49,6 +51,12 @@ func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
return waitForExit(ctx, timeout, func() (bool, error) {
|
||||
return target.IsRunning(appID, entrypoint)
|
||||
}, systemExitWaitClock{})
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const exitPollInterval = 250 * time.Millisecond
|
||||
|
||||
type exitWaitClock interface {
|
||||
Now() time.Time
|
||||
Wait(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type systemExitWaitClock struct{}
|
||||
|
||||
func (systemExitWaitClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("wait context is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("exit timeout must be positive")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := clock.Now().Add(timeout)
|
||||
for {
|
||||
isRunning, err := running()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isRunning {
|
||||
return nil
|
||||
}
|
||||
remaining := deadline.Sub(clock.Now())
|
||||
if remaining <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
interval := exitPollInterval
|
||||
if remaining < interval {
|
||||
interval = remaining
|
||||
}
|
||||
if err := clock.Wait(ctx, interval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -22,6 +24,7 @@ type Platform interface {
|
||||
Edition() Edition
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
IsRunning(appID, entrypoint string) (bool, error)
|
||||
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
|
||||
Start(command launch.Command) (int, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -30,6 +32,10 @@ func (platformStub) IsRunning(string, string) (bool, error) {
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) Start(launch.Command) (int, error) {
|
||||
return 0, ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -17,6 +19,9 @@ func TestPlatformStubFailsClosed(t *testing.T) {
|
||||
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlatformStubContract(t *testing.T) {
|
||||
@@ -78,6 +80,71 @@ func TestVersionSupports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForExit(t *testing.T) {
|
||||
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
|
||||
errSnapshot := errors.New("snapshot failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
running []bool
|
||||
runErr error
|
||||
timeout time.Duration
|
||||
wantErr error
|
||||
wantSleeps int
|
||||
}{
|
||||
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
|
||||
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
|
||||
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
|
||||
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
|
||||
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
clock := &fakeExitWaitClock{now: initial}
|
||||
index := 0
|
||||
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
|
||||
if test.runErr != nil {
|
||||
return false, test.runErr
|
||||
}
|
||||
if index >= len(test.running) {
|
||||
return test.running[len(test.running)-1], nil
|
||||
}
|
||||
running := test.running[index]
|
||||
index++
|
||||
return running, nil
|
||||
}, clock)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if clock.sleeps != test.wantSleeps {
|
||||
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func canceledWaitContext() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type fakeExitWaitClock struct {
|
||||
now time.Time
|
||||
sleeps int
|
||||
}
|
||||
|
||||
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
|
||||
|
||||
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
clock.sleeps++
|
||||
clock.now = clock.now.Add(duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotItem struct {
|
||||
path string
|
||||
err error
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -49,6 +51,12 @@ func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
return waitForExit(ctx, timeout, func() (bool, error) {
|
||||
return target.IsRunning(appID, entrypoint)
|
||||
}, systemExitWaitClock{})
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const exitPollInterval = 250 * time.Millisecond
|
||||
|
||||
type exitWaitClock interface {
|
||||
Now() time.Time
|
||||
Wait(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type systemExitWaitClock struct{}
|
||||
|
||||
func (systemExitWaitClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("wait context is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("exit timeout must be positive")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := clock.Now().Add(timeout)
|
||||
for {
|
||||
isRunning, err := running()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isRunning {
|
||||
return nil
|
||||
}
|
||||
remaining := deadline.Sub(clock.Now())
|
||||
if remaining <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
interval := exitPollInterval
|
||||
if remaining < interval {
|
||||
interval = remaining
|
||||
}
|
||||
if err := clock.Wait(ctx, interval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// Package update contains the core-only application update orchestration.
|
||||
// It never obtains downloads, controls a window, or terminates a process.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/install"
|
||||
"softbox.local/core/domain"
|
||||
"softbox.local/core/internal/safepath"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
minCloseTimeout = time.Second
|
||||
maxCloseTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUpdateConfig = errors.New("invalid update service configuration")
|
||||
ErrUpdateRequest = errors.New("invalid update request")
|
||||
ErrAppNotInstalled = errors.New("app is not installed for update")
|
||||
ErrUpdateMetadata = errors.New("installed update metadata is invalid")
|
||||
ErrUpdateTargetUnsafe = errors.New("installed update target is unsafe")
|
||||
ErrUpdateNotAvailable = errors.New("catalog target is not newer than installed version")
|
||||
ErrTargetStateCheck = errors.New("update target state check failed")
|
||||
ErrCloseConfirmation = errors.New("update close confirmation failed")
|
||||
ErrCloseDeclined = errors.New("update close request was declined")
|
||||
ErrExitWait = errors.New("wait for update target exit failed")
|
||||
updateAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
)
|
||||
|
||||
// FailureCode is the stable, non-localized result of a failed update attempt.
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
FailureCodeNotInstalled FailureCode = "not_installed"
|
||||
FailureCodeUpdateMetadataInvalid FailureCode = "update_metadata_invalid"
|
||||
FailureCodeUpdateTargetUnsafe FailureCode = "update_target_unsafe"
|
||||
FailureCodeUpdateNotAvailable FailureCode = "update_not_available"
|
||||
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
||||
FailureCodeCloseConfirmationFailed FailureCode = "close_confirmation_unavailable"
|
||||
FailureCodeCloseDeclined FailureCode = "close_declined"
|
||||
FailureCodeExitWaitCanceled FailureCode = "exit_wait_canceled"
|
||||
FailureCodeExitWaitTimedOut FailureCode = "exit_wait_timeout"
|
||||
FailureCodeExitWaitFailed FailureCode = "exit_wait_unavailable"
|
||||
FailureCodeInstallFailed FailureCode = "install_failed"
|
||||
)
|
||||
|
||||
// Error preserves a stable update code and the diagnostic cause. Installation
|
||||
// errors remain in the unwrap chain so their stage and code stay observable.
|
||||
type Error struct {
|
||||
Code FailureCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *Error) Error() string {
|
||||
return fmt.Sprintf("update (%s): %v", err.Code, err.Err)
|
||||
}
|
||||
|
||||
func (err *Error) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// Request carries the trusted Catalog selection and completed-file candidate
|
||||
// already required by install.InstallService. It accepts no process path,
|
||||
// command, URL, or UI-provided version.
|
||||
type Request struct {
|
||||
Install install.InstallRequest
|
||||
}
|
||||
|
||||
// Result reports the version committed by the existing installer.
|
||||
type Result struct {
|
||||
AppID string
|
||||
Version string
|
||||
}
|
||||
|
||||
// InstalledAppResolver supplies one verified record and its real current root.
|
||||
type InstalledAppResolver interface {
|
||||
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
|
||||
}
|
||||
|
||||
// TargetStateChecker observes the precise old entrypoint before a close
|
||||
// request. It must not turn a query failure into a stopped result.
|
||||
type TargetStateChecker interface {
|
||||
IsRunning(appID string, entrypointPath string) (bool, error)
|
||||
}
|
||||
|
||||
// CloseConfirmer obtains the user's decision outside of Gio Layout. It must
|
||||
// not close or terminate the process itself.
|
||||
type CloseConfirmer interface {
|
||||
ConfirmClose(ctx context.Context, appID string) (bool, error)
|
||||
}
|
||||
|
||||
// ExitWaiter waits only for natural exit of one precise entrypoint. A timeout
|
||||
// is reported as context.DeadlineExceeded and cancellation retains ctx.Err().
|
||||
type ExitWaiter interface {
|
||||
WaitForExit(ctx context.Context, appID, entrypointPath string, timeout time.Duration) error
|
||||
}
|
||||
|
||||
// InstallRunner is the verified installation boundary. The update service
|
||||
// delegates all staging, switch, rollback and second running-state checks to it.
|
||||
type InstallRunner interface {
|
||||
Install(request install.InstallRequest) (install.InstallResult, error)
|
||||
}
|
||||
|
||||
// ServiceConfig makes the close confirmation and exit-wait policy explicit.
|
||||
type ServiceConfig struct {
|
||||
Records InstalledAppResolver
|
||||
TargetState TargetStateChecker
|
||||
Confirmation CloseConfirmer
|
||||
ExitWaiter ExitWaiter
|
||||
Installer InstallRunner
|
||||
CloseTimeout time.Duration
|
||||
}
|
||||
|
||||
// Service implements a safe, non-destructive update flow.
|
||||
type Service struct {
|
||||
records InstalledAppResolver
|
||||
targetState TargetStateChecker
|
||||
confirmation CloseConfirmer
|
||||
exitWaiter ExitWaiter
|
||||
installer InstallRunner
|
||||
closeTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewService validates every dependency. There is deliberately no default
|
||||
// confirmation, wait policy, or installer implementation.
|
||||
func NewService(config ServiceConfig) (*Service, error) {
|
||||
if config.Records == nil {
|
||||
return nil, fmt.Errorf("%w: installed app resolver is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.TargetState == nil {
|
||||
return nil, fmt.Errorf("%w: target state checker is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.Confirmation == nil {
|
||||
return nil, fmt.Errorf("%w: close confirmer is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.ExitWaiter == nil {
|
||||
return nil, fmt.Errorf("%w: exit waiter is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.Installer == nil {
|
||||
return nil, fmt.Errorf("%w: install runner is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.CloseTimeout < minCloseTimeout || config.CloseTimeout > maxCloseTimeout {
|
||||
return nil, fmt.Errorf("%w: close timeout must be between %s and %s", ErrUpdateConfig, minCloseTimeout, maxCloseTimeout)
|
||||
}
|
||||
return &Service{
|
||||
records: config.Records,
|
||||
targetState: config.TargetState,
|
||||
confirmation: config.Confirmation,
|
||||
exitWaiter: config.ExitWaiter,
|
||||
installer: config.Installer,
|
||||
closeTimeout: config.CloseTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update confirms a running app may be closed, waits for its natural exit, and
|
||||
// then delegates to the verified installer. The installer repeats target-state
|
||||
// checks before extraction and immediately before replacing current.
|
||||
func (service *Service) Update(ctx context.Context, request Request) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, updateError(ErrUpdateRequest)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
appID := request.Install.Entry.App.ID
|
||||
if !updateAppIDPattern.MatchString(appID) {
|
||||
return Result{}, updateError(ErrUpdateRequest)
|
||||
}
|
||||
record, current, err := service.records.ResolveCurrent(appID)
|
||||
if err != nil {
|
||||
return Result{}, updateResolverError(err)
|
||||
}
|
||||
entrypoint, err := updateEntrypoint(record, current)
|
||||
if err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
if err := validateTargetVersion(record, request.Install); err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
|
||||
running, err := service.targetState.IsRunning(record.ID, entrypoint)
|
||||
if err != nil {
|
||||
return Result{}, updateError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
|
||||
}
|
||||
if running {
|
||||
confirmed, err := service.confirmation.ConfirmClose(ctx, record.ID)
|
||||
if err != nil {
|
||||
return Result{}, updateError(fmt.Errorf("%w: %w", ErrCloseConfirmation, err))
|
||||
}
|
||||
if !confirmed {
|
||||
return Result{}, updateError(ErrCloseDeclined)
|
||||
}
|
||||
if err := service.exitWaiter.WaitForExit(ctx, record.ID, entrypoint, service.closeTimeout); err != nil {
|
||||
return Result{}, updateError(fmt.Errorf("%w: %w", ErrExitWait, err))
|
||||
}
|
||||
}
|
||||
|
||||
installed, err := service.installer.Install(request.Install)
|
||||
if err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
return Result{AppID: installed.AppID, Version: installed.Version}, nil
|
||||
}
|
||||
|
||||
func validateTargetVersion(record storage.InstalledApp, request install.InstallRequest) error {
|
||||
app := request.Entry.App
|
||||
if request.DownloadPath == "" || app.ID != record.ID || request.Entry.Package == nil || !request.Entry.Installable {
|
||||
return ErrUpdateRequest
|
||||
}
|
||||
if request.Architecture != "386" && request.Architecture != "amd64" {
|
||||
return ErrUpdateRequest
|
||||
}
|
||||
publishedPackage, exists := app.Packages[request.Architecture]
|
||||
if !exists || publishedPackage != *request.Entry.Package {
|
||||
return ErrUpdateRequest
|
||||
}
|
||||
comparison, err := domain.CompareSemVer(app.Version, record.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUpdateRequest, err)
|
||||
}
|
||||
if comparison <= 0 {
|
||||
return ErrUpdateNotAvailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateEntrypoint(record storage.InstalledApp, current string) (string, error) {
|
||||
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
|
||||
return "", ErrUpdateMetadata
|
||||
}
|
||||
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
||||
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateMetadata, err)
|
||||
}
|
||||
if record.WorkingDirectory != "." {
|
||||
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
||||
return "", fmt.Errorf("%w: working directory: %v", ErrUpdateMetadata, err)
|
||||
}
|
||||
}
|
||||
if !validMinOS(record.MinOS) {
|
||||
return "", ErrUpdateMetadata
|
||||
}
|
||||
if !containsEntrypoint(record.Files, record.Entrypoint) {
|
||||
return "", fmt.Errorf("%w: entrypoint is not in installed files", ErrUpdateMetadata)
|
||||
}
|
||||
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateTargetUnsafe, err)
|
||||
}
|
||||
info, err := os.Lstat(entrypoint)
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("%w: entrypoint is missing", ErrUpdateTargetUnsafe)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: inspect entrypoint: %w", ErrUpdateTargetUnsafe, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("%w: entrypoint is not a regular file", ErrUpdateTargetUnsafe)
|
||||
}
|
||||
return entrypoint, 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 updateResolverError(err error) error {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return updateError(ErrAppNotInstalled)
|
||||
}
|
||||
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
|
||||
return updateError(fmt.Errorf("%w: %w", ErrUpdateTargetUnsafe, err))
|
||||
}
|
||||
return updateError(fmt.Errorf("resolve installed app: %w", err))
|
||||
}
|
||||
|
||||
func updateError(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, ErrUpdateMetadata):
|
||||
return FailureCodeUpdateMetadataInvalid
|
||||
case errors.Is(err, ErrUpdateTargetUnsafe):
|
||||
return FailureCodeUpdateTargetUnsafe
|
||||
case errors.Is(err, ErrUpdateNotAvailable):
|
||||
return FailureCodeUpdateNotAvailable
|
||||
case errors.Is(err, ErrTargetStateCheck):
|
||||
return FailureCodeTargetStateUnavailable
|
||||
case errors.Is(err, ErrCloseDeclined):
|
||||
return FailureCodeCloseDeclined
|
||||
case errors.Is(err, context.Canceled):
|
||||
return FailureCodeExitWaitCanceled
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return FailureCodeExitWaitTimedOut
|
||||
case errors.Is(err, ErrCloseConfirmation):
|
||||
return FailureCodeCloseConfirmationFailed
|
||||
case errors.Is(err, ErrExitWait):
|
||||
return FailureCodeExitWaitFailed
|
||||
default:
|
||||
return FailureCodeInstallFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/install"
|
||||
"softbox.local/core/catalog"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
func TestServiceUpdatesAfterNaturalExit(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t, "1.0.0")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
result, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if result != (Result{AppID: "test-app", Version: "1.1.0"}) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if confirmation.calls != 1 || waiter.calls != 1 || installer.calls != 1 {
|
||||
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 1 each", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
if waiter.appID != "test-app" || waiter.entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") || waiter.timeout != 30*time.Second {
|
||||
t.Fatalf("waiter input = %#v", waiter)
|
||||
}
|
||||
if installer.request.Entry.App.Version != "1.1.0" {
|
||||
t.Fatalf("installer request = %#v", installer.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSkipsCloseFlowWhenTargetAlreadyStopped(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
if _, err := service.Update(context.Background(), updateRequest("1.1.0")); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 1 {
|
||||
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 0/0/1", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsInvalidOrUnreadyUpdatesBeforeSideEffects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(t *testing.T) (InstalledAppResolver, Request)
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "not installed",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
return storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")), updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrAppNotInstalled,
|
||||
wantCode: FailureCodeNotInstalled,
|
||||
},
|
||||
{
|
||||
name: "legacy record lacks launch metadata",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
record, found, err := store.Read("test-app")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Read() found=%t err=%v", found, err)
|
||||
}
|
||||
record.Entrypoint = ""
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
return store, updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrUpdateMetadata,
|
||||
wantCode: FailureCodeUpdateMetadataInvalid,
|
||||
},
|
||||
{
|
||||
name: "same version",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
return store, updateRequest("1.0.0")
|
||||
},
|
||||
wantErr: ErrUpdateNotAvailable,
|
||||
wantCode: FailureCodeUpdateNotAvailable,
|
||||
},
|
||||
{
|
||||
name: "catalog package mismatches selected architecture",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
request := updateRequest("1.1.0")
|
||||
different := *request.Install.Entry.Package
|
||||
different.Size++
|
||||
request.Install.Entry.Package = &different
|
||||
return store, request
|
||||
},
|
||||
wantErr: ErrUpdateRequest,
|
||||
wantCode: FailureCodeInstallFailed,
|
||||
},
|
||||
{
|
||||
name: "unsafe current layout",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
return resolverFunc(func(string) (storage.InstalledApp, string, error) {
|
||||
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
|
||||
}), updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrUpdateTargetUnsafe,
|
||||
wantCode: FailureCodeUpdateTargetUnsafe,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
records, request := test.prepare(t)
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, records, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), request)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
||||
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCloseFailuresDoNotInstallOrModifyUserRoots(t *testing.T) {
|
||||
errConfirmation := errors.New("confirmation unavailable")
|
||||
errWait := errors.New("snapshot unavailable")
|
||||
tests := []struct {
|
||||
name string
|
||||
context func() context.Context
|
||||
confirm recordingConfirmation
|
||||
wait recordingExitWaiter
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "close declined",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: false},
|
||||
wantErr: ErrCloseDeclined,
|
||||
wantCode: FailureCodeCloseDeclined,
|
||||
},
|
||||
{
|
||||
name: "confirmation unavailable",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{err: errConfirmation},
|
||||
wantErr: errConfirmation,
|
||||
wantCode: FailureCodeCloseConfirmationFailed,
|
||||
},
|
||||
{
|
||||
name: "wait canceled",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: context.Canceled},
|
||||
wantErr: context.Canceled,
|
||||
wantCode: FailureCodeExitWaitCanceled,
|
||||
},
|
||||
{
|
||||
name: "wait timed out",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: context.DeadlineExceeded},
|
||||
wantErr: context.DeadlineExceeded,
|
||||
wantCode: FailureCodeExitWaitTimedOut,
|
||||
},
|
||||
{
|
||||
name: "wait unavailable",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: errWait},
|
||||
wantErr: errWait,
|
||||
wantCode: FailureCodeExitWaitFailed,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t, "1.0.0")
|
||||
data := filepath.Join(filepath.Dir(appRoot), "..", "data", "test-app", "data.txt")
|
||||
license := filepath.Join(filepath.Dir(appRoot), "..", "licenses", "license.txt")
|
||||
for _, path := range []string{data, license} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(%q): %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(path), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||
}
|
||||
}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), &test.confirm, &test.wait, installer)
|
||||
|
||||
_, err := service.Update(test.context(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if installer.calls != 0 {
|
||||
t.Fatalf("installer calls = %d, want 0", installer.calls)
|
||||
}
|
||||
for _, path := range []string{data, license} {
|
||||
contents, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(contents) != path {
|
||||
t.Fatalf("protected file %q = %q, err=%v", path, contents, readErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTargetStateFailureDoesNotRequestCloseOrInstall(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
expected := errors.New("Toolhelp unavailable")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, expected
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, expected)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != FailureCodeTargetStateUnavailable {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeTargetStateUnavailable)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
||||
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePreservesInstallErrorForRestartRace(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
installErr := &install.InstallError{Stage: install.InstallStagePreflight, Code: install.FailureCodeAppRunning, Err: install.ErrTargetRunning}
|
||||
installer := &recordingInstaller{err: installErr}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}), &recordingConfirmation{}, &recordingExitWaiter{}, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, install.ErrTargetRunning) {
|
||||
t.Fatalf("Update() error = %v, want preserved %v", err, install.ErrTargetRunning)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != FailureCodeInstallFailed {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeInstallFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresAllDependenciesAndBoundedTimeout(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
config := ServiceConfig{
|
||||
Records: store,
|
||||
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
||||
Confirmation: &recordingConfirmation{},
|
||||
ExitWaiter: &recordingExitWaiter{},
|
||||
Installer: &recordingInstaller{},
|
||||
CloseTimeout: 30 * time.Second,
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*ServiceConfig)
|
||||
}{
|
||||
{"records", func(config *ServiceConfig) { config.Records = nil }},
|
||||
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
|
||||
{"confirmation", func(config *ServiceConfig) { config.Confirmation = nil }},
|
||||
{"exit waiter", func(config *ServiceConfig) { config.ExitWaiter = nil }},
|
||||
{"installer", func(config *ServiceConfig) { config.Installer = nil }},
|
||||
{"timeout too short", func(config *ServiceConfig) { config.CloseTimeout = time.Millisecond }},
|
||||
{"timeout too long", func(config *ServiceConfig) { config.CloseTimeout = 11 * time.Minute }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := config
|
||||
test.mutate(&candidate)
|
||||
if _, err := NewService(candidate); !errors.Is(err, ErrUpdateConfig) {
|
||||
t.Fatalf("NewService() error = %v, want %v", err, ErrUpdateConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedInstalledApp(t *testing.T, version string) (*storage.InstalledAppStore, string) {
|
||||
t.Helper()
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
record := storage.InstalledApp{
|
||||
SchemaVersion: 1,
|
||||
ID: "test-app",
|
||||
Version: version,
|
||||
Architecture: "amd64",
|
||||
Channel: "stable",
|
||||
Entrypoint: "bin/App.exe",
|
||||
WorkingDirectory: "bin",
|
||||
MinOS: "windows-10",
|
||||
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("old executable"), 0o700); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
return store, appRoot
|
||||
}
|
||||
|
||||
func updateRequest(version string) Request {
|
||||
pkg := catalog.Package{URL: "https://download.invalid/test-app.zip", Size: 1, SHA256: "0000000000000000000000000000000000000000000000000000000000000000", Signature: "placeholder"}
|
||||
return Request{Install: install.InstallRequest{
|
||||
Entry: catalog.Entry{
|
||||
Installable: true,
|
||||
Package: &pkg,
|
||||
App: catalog.App{
|
||||
ID: "test-app",
|
||||
Version: version,
|
||||
Packages: map[catalog.Architecture]catalog.Package{
|
||||
catalog.ArchitectureAMD64: pkg,
|
||||
},
|
||||
},
|
||||
},
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: "untrusted-candidate.download",
|
||||
}}
|
||||
}
|
||||
|
||||
func newService(t *testing.T, records InstalledAppResolver, target TargetStateChecker, confirmation CloseConfirmer, waiter ExitWaiter, installer InstallRunner) *Service {
|
||||
t.Helper()
|
||||
service, err := NewService(ServiceConfig{
|
||||
Records: records,
|
||||
TargetState: target,
|
||||
Confirmation: confirmation,
|
||||
ExitWaiter: waiter,
|
||||
Installer: installer,
|
||||
CloseTimeout: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func updateErrorCode(t *testing.T, err error) FailureCode {
|
||||
t.Helper()
|
||||
var updateErr *Error
|
||||
if !errors.As(err, &updateErr) {
|
||||
t.Fatalf("error = %v, want update Error", err)
|
||||
}
|
||||
return updateErr.Code
|
||||
}
|
||||
|
||||
type resolverFunc func(string) (storage.InstalledApp, string, error)
|
||||
|
||||
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
|
||||
return resolver(appID)
|
||||
}
|
||||
|
||||
type targetStateFunc func(string, string) (bool, error)
|
||||
|
||||
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
|
||||
return checker(appID, entrypoint)
|
||||
}
|
||||
|
||||
type recordingConfirmation struct {
|
||||
confirmed bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (confirmation *recordingConfirmation) ConfirmClose(context.Context, string) (bool, error) {
|
||||
confirmation.calls++
|
||||
return confirmation.confirmed, confirmation.err
|
||||
}
|
||||
|
||||
type recordingExitWaiter struct {
|
||||
err error
|
||||
calls int
|
||||
appID string
|
||||
entrypoint string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (waiter *recordingExitWaiter) WaitForExit(_ context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
waiter.calls++
|
||||
waiter.appID = appID
|
||||
waiter.entrypoint = entrypoint
|
||||
waiter.timeout = timeout
|
||||
return waiter.err
|
||||
}
|
||||
|
||||
type recordingInstaller struct {
|
||||
result install.InstallResult
|
||||
err error
|
||||
calls int
|
||||
request install.InstallRequest
|
||||
}
|
||||
|
||||
func (installer *recordingInstaller) Install(request install.InstallRequest) (install.InstallResult, error) {
|
||||
installer.calls++
|
||||
installer.request = request
|
||||
return installer.result, installer.err
|
||||
}
|
||||
@@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败恢复落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已正式落成,下一步实现子软件更新的确认关闭与自然退出等待编排。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
||||
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败恢复落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已完成子软件更新的确认关闭、自然退出等待和可信安装器编排。下一步正式落成 T-403。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
||||
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
|
||||
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
|
||||
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
|
||||
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已正式落成,当前执行子软件更新编排;完成后再继续 T-403 与 Phase 5-6。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
||||
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已完成关闭确认、自然退出等待和更新编排;下一步正式落成 T-403。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ T-301 下载队列:
|
||||
|
||||
必须防止:绝对路径、`../` 与 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。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 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。T-402 的 `core/application/update` 先验证已装记录与严格更新版本,再在后台按“检测运行→取得关闭确认→有限自然退出等待→委托 InstallService”的顺序编排;未运行时直接委托安装器。等待器只以 Toolhelp 全路径身份轮询,context 取消、超时和检测错误均 fail closed,绝不强杀。InstallService 保留其 staging 前和 switch 临界区两次复查,覆盖等待结束后的重新启动竞态;更新 use case 从不改 transaction 或 data/licenses。当前 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)。
|
||||
|
||||
|
||||
+18
@@ -382,6 +382,24 @@ SoftBoxUpdater.exe --pid <主程序PID> --staging <暂存目录> --target <目
|
||||
v1:进程快照判断运行 → 提示用户保存关闭 → 等待正常退出 → 超时取消更新,**不默认强杀**。
|
||||
V1.1:命名管道 `\\.\pipe\softbox.<app-id>`,盒子发送 `{"command": "prepare_update", "request_id": "..."}`,子软件保存数据回复 `ready` 后自行退出。
|
||||
|
||||
### 6.1 子软件更新编排 v1
|
||||
|
||||
`core/application/update` 只接受外层已经从验证/过滤 Catalog 取得的 `install.InstallRequest`,并再次要求 package 与 architecture 精确匹配。它先读取受控本地 `installed-app.json` + `current`,要求旧记录有完整且安全的启动元数据,并且 Catalog 目标版本严格高于本地 SemVer;不得由 UI 或下载事件提供 EXE、路径、参数、URL、版本或 hash。
|
||||
|
||||
若旧 entrypoint 未运行,编排直接委托 `InstallService`;若运行,则顺序固定为:后台取得关闭确认 → 按配置的 1 秒至 10 分钟上限等待**自然退出** → 委托 `InstallService`。等待器仅轮询 T-401 的完整 entrypoint 身份,支持 context 取消;不能调用 kill/TerminateProcess、shell、脚本或 IPC。等待完成后安装器仍在 staging 前和 `current → backup` 紧邻前复查运行状态,因此用户重新启动旧版本会安全返回既有 `app_running`,不覆盖 current。
|
||||
|
||||
| code | 含义 |
|
||||
| --- | --- |
|
||||
| `not_installed` | 没有受控安装记录 |
|
||||
| `update_metadata_invalid` / `update_target_unsafe` | 历史记录缺启动元数据,或旧 entrypoint/current 布局不安全 |
|
||||
| `update_not_available` | Catalog 版本与本地相同或更低 |
|
||||
| `target_state_unavailable` | 无法可靠判定旧 entrypoint 是否运行 |
|
||||
| `close_confirmation_unavailable` / `close_declined` | 无法取得关闭确认,或用户拒绝关闭 |
|
||||
| `exit_wait_canceled` / `exit_wait_timeout` / `exit_wait_unavailable` | 等待被取消、达到上限,或进程快照等待不可用 |
|
||||
| `install_failed` | 已委托安装器但安装失败;原始 `InstallError` 链仍保留其 stage/code |
|
||||
|
||||
更新编排自身不写 `current`、`backup`、transaction、`data/` 或 `licenses/`。production cmd 目前没有可信 Catalog、许可证和 completed-download 消费来源,也没有关闭确认 UI,因此尚未装配该用例;不得用 allow-all 授权或测试 fake 宣称更新闭环已经启用。
|
||||
|
||||
## 待实现时确认
|
||||
|
||||
- 清单密钥 ID/轮换字段定稿后同步 `schemas/`。
|
||||
|
||||
@@ -13,22 +13,23 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期: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 与 Phase 4 的 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 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排已完成
|
||||
- 技术栈:根 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 与受验证 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 镜像职责拆文件
|
||||
- T-402 更新用例:`core/application/update` 只接收外层可信的 install selection,验证已装版本/旧 entrypoint后,运行中才请求关闭确认并以 1 秒~10 分钟上限等待自然退出,随后委托已有 `InstallService` 的双重运行复查与 rollback;取消、超时、Toolhelp 检测错误和安装错误均保留稳定 code/错误链,不强杀且不改 `data/`、`licenses/`。两端 platform 对齐 `WaitForExit` 契约,Windows 固定短轮询完整路径,非 Windows 返回明确不支持。
|
||||
- 测试: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-402 已正式落成,当前执行子软件更新的关闭确认、自然退出等待和可信安装器编排。下载 completed 文件的生产消费、许可证策略与完整端到端 cmd/UI 编排仍未装配;不得为此注入 allow-all 授权或伪装更新闭环。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入
|
||||
- 当前 blocker:可正式落成 T-403。下载 completed 文件的生产消费、许可证策略与完整端到端 cmd/UI 编排仍未装配;T-402 因而没有注入 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 已完成;T-402 已正式落成,正在执行 |
|
||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615、T-401 与 T-402 已完成;下一项为 T-403 |
|
||||
| `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 +41,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 4 的 `T-401`。
|
||||
- 已完成: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-402`。
|
||||
- 正在进行:无。
|
||||
- 正在进行:T-402(依赖 T-401 已完成);完成、验证并提交后才可正式落成 T-403。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||
- 正在准备领取:T-403(依赖 T-402 已完成);应先正式落成并提交该任务,再开始 SoftBox 自身更新实现。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+5
-3
@@ -3,12 +3,12 @@ id: T-402
|
||||
title: 子软件更新编排与自然退出等待
|
||||
phase: 4
|
||||
deps: [T-401]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-19
|
||||
issue: null
|
||||
context_ref: null
|
||||
context_ref: fda52a57ca0d4c449e206c163864bdd3e354cbaf
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
work_branch: agent/codex/T-402
|
||||
write_paths:
|
||||
- docs/tasks/T-402.md
|
||||
- core/application/update/
|
||||
@@ -58,3 +58,5 @@ T-401 已提供精确 entrypoint 的 Toolhelp 运行状态和 `InstallService`
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-19:正式落成。以 T-401 的完整路径运行检测和 Switcher 临界区复查为基础,冻结“确认关闭→有限自然退出等待→委托可信安装器→二次运行复查”的更新顺序;明确下载/授权生产装配、命名管道、强杀和 T-601 真机验证不在本任务。
|
||||
- 2026-07-19:领取任务,基于 `fda52a57ca0d4c449e206c163864bdd3e354cbaf` 在 `agent/codex/T-402` 执行;T-401 基线与正式任务文档校验已通过,再实现更新编排。
|
||||
- 2026-07-19:完成。新增纯 core `application/update`,以受控本地记录验证严格更新版本,在运行中经关闭确认和有限自然退出等待后才委托原始可信 install request;安装器保留 staging 前与 switch 临界区复查,所有安装错误链可观察。双端 `platform/windows` 增加可取消的完整路径自然退出轮询与非 Windows fail-closed stub。production cmd 没有可信 Catalog/许可证/completed-download 消费和关闭确认 UI,故未注入 fake 或 allow-all 闭环。验证通过:`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./application/update ./application/install ./installer`、双端 Windows amd64 构建与 Linux stub test 编译、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。
|
||||
|
||||
Reference in New Issue
Block a user