diff --git a/app-modern/cmd/softbox/main.go b/app-modern/cmd/softbox/main.go index e252b96..66fe0bb 100644 --- a/app-modern/cmd/softbox/main.go +++ b/app-modern/cmd/softbox/main.go @@ -19,6 +19,12 @@ import ( const applicationEventCapacity = 32 func main() { + if handled, err := acknowledgeInternalUpdateHealth(os.Args[1:]); handled { + if err != nil { + log.Printf("%s internal update health failed: %v", core.ProductName, err) + os.Exit(1) + } + } go func() { if err := run(); err != nil { log.Printf("%s stopped: %v", core.ProductName, err) diff --git a/app-modern/cmd/softbox/update_health.go b/app-modern/cmd/softbox/update_health.go new file mode 100644 index 0000000..c030ba6 --- /dev/null +++ b/app-modern/cmd/softbox/update_health.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" + + "softbox.local/app-modern/platform/windows" + "softbox.local/core/updater" +) + +func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) { + if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag { + return false, nil + } + if len(arguments) != 2 { + return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag) + } + executable, err := os.Executable() + if err != nil { + return true, fmt.Errorf("locate current executable: %w", err) + } + if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil { + return true, fmt.Errorf("acknowledge self-update health: %w", err) + } + return true, nil +} diff --git a/app-modern/cmd/softboxupdater/main.go b/app-modern/cmd/softboxupdater/main.go new file mode 100644 index 0000000..7e7f4ad --- /dev/null +++ b/app-modern/cmd/softboxupdater/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "softbox.local/app-modern/platform/windows" + "softbox.local/core/updater" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(arguments []string) error { + for _, option := range []string{"--pid", "--staging", "--target"} { + if err := requireOneOption(arguments, option); err != nil { + return err + } + } + flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError) + flags.SetOutput(io.Discard) + pid := flags.Int("pid", 0, "main SoftBox PID") + staging := flags.String("staging", "", "prepared staging directory") + target := flags.String("target", "", "fixed app target directory") + if err := flags.Parse(arguments); err != nil { + return fmt.Errorf("parse updater arguments: %w", err) + } + if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) { + return fmt.Errorf("usage: SoftBoxUpdater --pid --staging --target ") + } + requestID := filepath.Base(filepath.Clean(*staging)) + platform := windows.New() + service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{ + ParentExit: 2 * time.Minute, + Health: 45 * time.Second, + }) + return service.Update(context.Background(), updater.Request{ + ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID, + }) +} + +func requireOneOption(arguments []string, option string) error { + count := 0 + for _, argument := range arguments { + if argument == option || strings.HasPrefix(argument, option+"=") { + count++ + } + } + if count != 1 { + return fmt.Errorf("%s must appear exactly once", option) + } + return nil +} diff --git a/app-modern/cmd/softboxupdater/main_test.go b/app-modern/cmd/softboxupdater/main_test.go new file mode 100644 index 0000000..9c81f3e --- /dev/null +++ b/app-modern/cmd/softboxupdater/main_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) { + if err := run(nil); err == nil { + t.Fatal("run(nil) succeeded") + } + if err := run([]string{ + "--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app", + }); err == nil { + t.Fatal("run() accepted duplicate --pid") + } +} diff --git a/app-modern/platform/windows/platform.go b/app-modern/platform/windows/platform.go index 5ee5536..01b0cb4 100644 --- a/app-modern/platform/windows/platform.go +++ b/app-modern/platform/windows/platform.go @@ -6,6 +6,7 @@ import ( "time" "softbox.local/core/application/launch" + "softbox.local/core/updater" ) // Edition identifies the application build channel shown by the UI. @@ -26,6 +27,9 @@ type Platform interface { IsRunning(appID, entrypoint string) (bool, error) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error Start(command launch.Command) (int, error) + WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error + StartSelfUpdate(command updater.StartCommand) (int, error) + SyncDirectory(path string) 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 26c3d6a..4e3c900 100644 --- a/app-modern/platform/windows/platform_stub.go +++ b/app-modern/platform/windows/platform_stub.go @@ -8,6 +8,7 @@ import ( "time" "softbox.local/core/application/launch" + "softbox.local/core/updater" ) type platformStub struct{} @@ -39,3 +40,15 @@ func (platformStub) WaitForExit(context.Context, string, string, time.Duration) func (platformStub) Start(launch.Command) (int, error) { return 0, ErrUnsupported } + +func (platformStub) WaitForProcessExit(context.Context, int, time.Duration) error { + return ErrUnsupported +} + +func (platformStub) StartSelfUpdate(updater.StartCommand) (int, error) { + return 0, ErrUnsupported +} + +func (platformStub) SyncDirectory(string) error { + return ErrUnsupported +} diff --git a/app-modern/platform/windows/platform_stub_test.go b/app-modern/platform/windows/platform_stub_test.go index e2c87ce..fe94b08 100644 --- a/app-modern/platform/windows/platform_stub_test.go +++ b/app-modern/platform/windows/platform_stub_test.go @@ -9,6 +9,7 @@ import ( "time" "softbox.local/core/application/launch" + "softbox.local/core/updater" ) func TestPlatformStubFailsClosed(t *testing.T) { @@ -25,4 +26,13 @@ func TestPlatformStubFailsClosed(t *testing.T) { if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) { t.Fatalf("Start() error = %v, want ErrUnsupported", err) } + if err := platform.WaitForProcessExit(context.Background(), 1, time.Second); !errors.Is(err, ErrUnsupported) { + t.Fatalf("WaitForProcessExit() error = %v, want ErrUnsupported", err) + } + if _, err := platform.StartSelfUpdate(updater.StartCommand{}); !errors.Is(err, ErrUnsupported) { + t.Fatalf("StartSelfUpdate() error = %v, want ErrUnsupported", err) + } + if err := platform.SyncDirectory("/tmp"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("SyncDirectory() error = %v, want ErrUnsupported", err) + } } diff --git a/app-modern/platform/windows/selfupdate.go b/app-modern/platform/windows/selfupdate.go new file mode 100644 index 0000000..435bd76 --- /dev/null +++ b/app-modern/platform/windows/selfupdate.go @@ -0,0 +1,49 @@ +package windows + +import ( + "context" + "fmt" + "time" +) + +type pidWaitHandle interface { + Wait(time.Duration) (bool, error) + Close() error +} + +type pidOpener func(int) (pidWaitHandle, error) + +func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error { + if pid <= 0 { + return fmt.Errorf("process PID must be positive") + } + if timeout <= 0 { + return fmt.Errorf("process wait timeout must be positive") + } + handle, err := open(pid) + if err != nil { + return fmt.Errorf("open process %d: %w", pid, err) + } + defer handle.Close() + deadline := time.NewTimer(timeout) + defer deadline.Stop() + for { + if err := ctx.Err(); err != nil { + return err + } + exited, err := handle.Wait(250 * time.Millisecond) + if err != nil { + return fmt.Errorf("wait for process %d: %w", pid, err) + } + if exited { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return context.DeadlineExceeded + default: + } + } +} diff --git a/app-modern/platform/windows/selfupdate_test.go b/app-modern/platform/windows/selfupdate_test.go new file mode 100644 index 0000000..37feafb --- /dev/null +++ b/app-modern/platform/windows/selfupdate_test.go @@ -0,0 +1,49 @@ +package windows + +import ( + "context" + "errors" + "testing" + "time" +) + +type fakePIDHandle struct { + exited bool + waitErr error + closed bool +} + +func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr } +func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil } + +func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) { + handle := &fakePIDHandle{exited: true} + err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) { + if pid != 9 { + t.Fatalf("PID = %d, want 9", pid) + } + return handle, nil + }) + if err != nil || !handle.closed { + t.Fatalf("wait error = %v, closed = %v", err, handle.closed) + } +} + +func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) { + openErr := errors.New("access denied") + if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) { + t.Fatalf("open error = %v", err) + } + waitErr := errors.New("wait failed") + if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) { + t.Fatalf("wait error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error = %v", err) + } + if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout error = %v", err) + } +} diff --git a/app-modern/platform/windows/selfupdate_windows.go b/app-modern/platform/windows/selfupdate_windows.go new file mode 100644 index 0000000..223aab9 --- /dev/null +++ b/app-modern/platform/windows/selfupdate_windows.go @@ -0,0 +1,88 @@ +//go:build windows + +package windows + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "syscall" + "time" + + "golang.org/x/sys/windows" + "softbox.local/core/updater" +) + +func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error { + return waitForProcessExit(ctx, pid, timeout, openWindowsProcess) +} + +func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) { + if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) { + return 0, fmt.Errorf("self-update launch paths must be absolute") + } + if filepath.Base(command.Entrypoint) != updater.ProductExecutableName || + filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) || + command.HealthRequestID == "" { + return 0, fmt.Errorf("invalid fixed self-update launch command") + } + commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID) + commandLine.Dir = command.WorkingDirectory + if err := commandLine.Start(); err != nil { + return 0, err + } + return commandLine.Process.Pid, nil +} + +func (platform) SyncDirectory(path string) error { + pathPointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return fmt.Errorf("encode directory path: %w", err) + } + handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE, + syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, + syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return fmt.Errorf("open directory handle: %w", err) + } + if err := syscall.FlushFileBuffers(handle); err != nil { + _ = syscall.CloseHandle(handle) + return fmt.Errorf("flush directory handle: %w", err) + } + if err := syscall.CloseHandle(handle); err != nil { + return fmt.Errorf("close directory handle: %w", err) + } + return nil +} + +type windowsPIDHandle struct{ handle windows.Handle } + +func openWindowsProcess(pid int) (pidWaitHandle, error) { + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + return nil, err + } + return windowsPIDHandle{handle: handle}, nil +} + +func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) { + milliseconds := uint32(timeout / time.Millisecond) + if milliseconds == 0 { + milliseconds = 1 + } + result, err := windows.WaitForSingleObject(handle.handle, milliseconds) + if err != nil { + return false, err + } + switch result { + case windows.WAIT_OBJECT_0: + return true, nil + case uint32(windows.WAIT_TIMEOUT): + return false, nil + default: + return false, fmt.Errorf("WaitForSingleObject returned %d", result) + } +} + +func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) } diff --git a/app-win7/cmd/softbox/main.go b/app-win7/cmd/softbox/main.go index d622b40..8293e0c 100644 --- a/app-win7/cmd/softbox/main.go +++ b/app-win7/cmd/softbox/main.go @@ -19,6 +19,12 @@ import ( const applicationEventCapacity = 32 func main() { + if handled, err := acknowledgeInternalUpdateHealth(os.Args[1:]); handled { + if err != nil { + log.Printf("%s internal update health failed: %v", core.ProductName, err) + os.Exit(1) + } + } go func() { if err := run(); err != nil { log.Printf("%s Legacy stopped: %v", core.ProductName, err) diff --git a/app-win7/cmd/softbox/update_health.go b/app-win7/cmd/softbox/update_health.go new file mode 100644 index 0000000..03e322a --- /dev/null +++ b/app-win7/cmd/softbox/update_health.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" + + "softbox.local/app-win7/platform/windows" + "softbox.local/core/updater" +) + +func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) { + if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag { + return false, nil + } + if len(arguments) != 2 { + return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag) + } + executable, err := os.Executable() + if err != nil { + return true, fmt.Errorf("locate current executable: %w", err) + } + if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil { + return true, fmt.Errorf("acknowledge self-update health: %w", err) + } + return true, nil +} diff --git a/app-win7/cmd/softboxupdater/main.go b/app-win7/cmd/softboxupdater/main.go new file mode 100644 index 0000000..2b798bf --- /dev/null +++ b/app-win7/cmd/softboxupdater/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "softbox.local/app-win7/platform/windows" + "softbox.local/core/updater" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(arguments []string) error { + for _, option := range []string{"--pid", "--staging", "--target"} { + if err := requireOneOption(arguments, option); err != nil { + return err + } + } + flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError) + flags.SetOutput(io.Discard) + pid := flags.Int("pid", 0, "main SoftBox PID") + staging := flags.String("staging", "", "prepared staging directory") + target := flags.String("target", "", "fixed app target directory") + if err := flags.Parse(arguments); err != nil { + return fmt.Errorf("parse updater arguments: %w", err) + } + if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) { + return fmt.Errorf("usage: SoftBoxUpdater --pid --staging --target ") + } + requestID := filepath.Base(filepath.Clean(*staging)) + platform := windows.New() + service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{ + ParentExit: 2 * time.Minute, + Health: 45 * time.Second, + }) + return service.Update(context.Background(), updater.Request{ + ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID, + }) +} + +func requireOneOption(arguments []string, option string) error { + count := 0 + for _, argument := range arguments { + if argument == option || strings.HasPrefix(argument, option+"=") { + count++ + } + } + if count != 1 { + return fmt.Errorf("%s must appear exactly once", option) + } + return nil +} diff --git a/app-win7/cmd/softboxupdater/main_test.go b/app-win7/cmd/softboxupdater/main_test.go new file mode 100644 index 0000000..9c81f3e --- /dev/null +++ b/app-win7/cmd/softboxupdater/main_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) { + if err := run(nil); err == nil { + t.Fatal("run(nil) succeeded") + } + if err := run([]string{ + "--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app", + }); err == nil { + t.Fatal("run() accepted duplicate --pid") + } +} diff --git a/app-win7/platform/windows/platform.go b/app-win7/platform/windows/platform.go index 68f9e3e..f93f8bc 100644 --- a/app-win7/platform/windows/platform.go +++ b/app-win7/platform/windows/platform.go @@ -6,6 +6,7 @@ import ( "time" "softbox.local/core/application/launch" + "softbox.local/core/updater" ) // Edition identifies the application build channel shown by the UI. @@ -26,6 +27,9 @@ type Platform interface { IsRunning(appID, entrypoint string) (bool, error) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error Start(command launch.Command) (int, error) + WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error + StartSelfUpdate(command updater.StartCommand) (int, error) + SyncDirectory(path string) 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 217088c..90d3cbe 100644 --- a/app-win7/platform/windows/platform_stub.go +++ b/app-win7/platform/windows/platform_stub.go @@ -8,6 +8,7 @@ import ( "time" "softbox.local/core/application/launch" + "softbox.local/core/updater" ) type platformStub struct{} @@ -39,3 +40,15 @@ func (platformStub) WaitForExit(context.Context, string, string, time.Duration) func (platformStub) Start(launch.Command) (int, error) { return 0, ErrUnsupported } + +func (platformStub) WaitForProcessExit(context.Context, int, time.Duration) error { + return ErrUnsupported +} + +func (platformStub) StartSelfUpdate(updater.StartCommand) (int, error) { + return 0, ErrUnsupported +} + +func (platformStub) SyncDirectory(string) error { + return ErrUnsupported +} diff --git a/app-win7/platform/windows/platform_stub_test.go b/app-win7/platform/windows/platform_stub_test.go index e2c87ce..fe94b08 100644 --- a/app-win7/platform/windows/platform_stub_test.go +++ b/app-win7/platform/windows/platform_stub_test.go @@ -9,6 +9,7 @@ import ( "time" "softbox.local/core/application/launch" + "softbox.local/core/updater" ) func TestPlatformStubFailsClosed(t *testing.T) { @@ -25,4 +26,13 @@ func TestPlatformStubFailsClosed(t *testing.T) { if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) { t.Fatalf("Start() error = %v, want ErrUnsupported", err) } + if err := platform.WaitForProcessExit(context.Background(), 1, time.Second); !errors.Is(err, ErrUnsupported) { + t.Fatalf("WaitForProcessExit() error = %v, want ErrUnsupported", err) + } + if _, err := platform.StartSelfUpdate(updater.StartCommand{}); !errors.Is(err, ErrUnsupported) { + t.Fatalf("StartSelfUpdate() error = %v, want ErrUnsupported", err) + } + if err := platform.SyncDirectory("/tmp"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("SyncDirectory() error = %v, want ErrUnsupported", err) + } } diff --git a/app-win7/platform/windows/selfupdate.go b/app-win7/platform/windows/selfupdate.go new file mode 100644 index 0000000..435bd76 --- /dev/null +++ b/app-win7/platform/windows/selfupdate.go @@ -0,0 +1,49 @@ +package windows + +import ( + "context" + "fmt" + "time" +) + +type pidWaitHandle interface { + Wait(time.Duration) (bool, error) + Close() error +} + +type pidOpener func(int) (pidWaitHandle, error) + +func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error { + if pid <= 0 { + return fmt.Errorf("process PID must be positive") + } + if timeout <= 0 { + return fmt.Errorf("process wait timeout must be positive") + } + handle, err := open(pid) + if err != nil { + return fmt.Errorf("open process %d: %w", pid, err) + } + defer handle.Close() + deadline := time.NewTimer(timeout) + defer deadline.Stop() + for { + if err := ctx.Err(); err != nil { + return err + } + exited, err := handle.Wait(250 * time.Millisecond) + if err != nil { + return fmt.Errorf("wait for process %d: %w", pid, err) + } + if exited { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return context.DeadlineExceeded + default: + } + } +} diff --git a/app-win7/platform/windows/selfupdate_test.go b/app-win7/platform/windows/selfupdate_test.go new file mode 100644 index 0000000..37feafb --- /dev/null +++ b/app-win7/platform/windows/selfupdate_test.go @@ -0,0 +1,49 @@ +package windows + +import ( + "context" + "errors" + "testing" + "time" +) + +type fakePIDHandle struct { + exited bool + waitErr error + closed bool +} + +func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr } +func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil } + +func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) { + handle := &fakePIDHandle{exited: true} + err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) { + if pid != 9 { + t.Fatalf("PID = %d, want 9", pid) + } + return handle, nil + }) + if err != nil || !handle.closed { + t.Fatalf("wait error = %v, closed = %v", err, handle.closed) + } +} + +func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) { + openErr := errors.New("access denied") + if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) { + t.Fatalf("open error = %v", err) + } + waitErr := errors.New("wait failed") + if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) { + t.Fatalf("wait error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error = %v", err) + } + if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout error = %v", err) + } +} diff --git a/app-win7/platform/windows/selfupdate_windows.go b/app-win7/platform/windows/selfupdate_windows.go new file mode 100644 index 0000000..223aab9 --- /dev/null +++ b/app-win7/platform/windows/selfupdate_windows.go @@ -0,0 +1,88 @@ +//go:build windows + +package windows + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "syscall" + "time" + + "golang.org/x/sys/windows" + "softbox.local/core/updater" +) + +func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error { + return waitForProcessExit(ctx, pid, timeout, openWindowsProcess) +} + +func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) { + if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) { + return 0, fmt.Errorf("self-update launch paths must be absolute") + } + if filepath.Base(command.Entrypoint) != updater.ProductExecutableName || + filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) || + command.HealthRequestID == "" { + return 0, fmt.Errorf("invalid fixed self-update launch command") + } + commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID) + commandLine.Dir = command.WorkingDirectory + if err := commandLine.Start(); err != nil { + return 0, err + } + return commandLine.Process.Pid, nil +} + +func (platform) SyncDirectory(path string) error { + pathPointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return fmt.Errorf("encode directory path: %w", err) + } + handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE, + syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, + syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return fmt.Errorf("open directory handle: %w", err) + } + if err := syscall.FlushFileBuffers(handle); err != nil { + _ = syscall.CloseHandle(handle) + return fmt.Errorf("flush directory handle: %w", err) + } + if err := syscall.CloseHandle(handle); err != nil { + return fmt.Errorf("close directory handle: %w", err) + } + return nil +} + +type windowsPIDHandle struct{ handle windows.Handle } + +func openWindowsProcess(pid int) (pidWaitHandle, error) { + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + return nil, err + } + return windowsPIDHandle{handle: handle}, nil +} + +func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) { + milliseconds := uint32(timeout / time.Millisecond) + if milliseconds == 0 { + milliseconds = 1 + } + result, err := windows.WaitForSingleObject(handle.handle, milliseconds) + if err != nil { + return false, err + } + switch result { + case windows.WAIT_OBJECT_0: + return true, nil + case uint32(windows.WAIT_TIMEOUT): + return false, nil + default: + return false, fmt.Errorf("WaitForSingleObject returned %d", result) + } +} + +func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) } diff --git a/core/updater/filesystem.go b/core/updater/filesystem.go new file mode 100644 index 0000000..ce9611b --- /dev/null +++ b/core/updater/filesystem.go @@ -0,0 +1,123 @@ +package updater + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" +) + +func replaceRegularFile(directory, target, pattern string, data []byte, syncer DirectorySyncer) error { + if info, err := os.Lstat(target); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("%w: file target is not a regular file", ErrUnsafeLayout) + } + } else if !os.IsNotExist(err) { + return err + } + temporary, err := os.CreateTemp(directory, pattern) + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, target); err != nil { + return err + } + return syncer.SyncDirectory(directory) +} + +func readRegularFile(path string) ([]byte, error) { + if err := validateRegularFile(path); err != nil { + return nil, err + } + return os.ReadFile(path) +} + +func removeRegularFile(path, directory string, syncer DirectorySyncer) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("%w: refuse to remove non-regular file", ErrUnsafeLayout) + } + if err := os.Remove(path); err != nil { + return err + } + return syncer.SyncDirectory(directory) +} + +func renameDirectory(source, target string, layout updateLayout, syncer DirectorySyncer, description string) error { + if source != layout.target && source != layout.staging && source != layout.backup { + return fmt.Errorf("%w: source outside managed root", ErrUnsafeLayout) + } + if target != layout.target && target != layout.staging && target != layout.backup { + return fmt.Errorf("%w: target outside managed root", ErrUnsafeLayout) + } + if err := os.Rename(source, target); err != nil { + return fmt.Errorf("%s: %w", description, err) + } + if err := syncer.SyncDirectory(filepath.Dir(source)); err != nil { + return err + } + if filepath.Dir(target) != filepath.Dir(source) { + if err := syncer.SyncDirectory(filepath.Dir(target)); err != nil { + return err + } + } + return nil +} + +func removeManagedTree(path string, layout updateLayout, syncer DirectorySyncer) error { + if filepath.Dir(path) != filepath.Join(layout.root, backupsDirectoryName) && filepath.Dir(path) != filepath.Join(layout.root, stagingDirectoryName) { + return fmt.Errorf("%w: refuse removal outside managed staging or backups", ErrUnsafeLayout) + } + if err := verifyTreeNoLinks(path); err != nil { + return err + } + if err := os.RemoveAll(path); err != nil { + return err + } + return syncer.SyncDirectory(filepath.Dir(path)) +} + +func verifyTreeNoLinks(path string) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: managed tree is not a real directory", ErrUnsafeLayout) + } + return filepath.WalkDir(path, func(current string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("%w: managed tree contains a symbolic link", ErrUnsafeLayout) + } + return nil + }) +} diff --git a/core/updater/health.go b/core/updater/health.go new file mode 100644 index 0000000..a7edfb7 --- /dev/null +++ b/core/updater/health.go @@ -0,0 +1,125 @@ +package updater + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +type healthRecord struct { + SchemaVersion int `json:"schema_version"` + RequestID string `json:"request_id"` +} + +// FileHealthWaiter reads the fixed root health file without accepting a +// caller-provided locator. +type FileHealthWaiter struct { + PollInterval time.Duration +} + +// WaitForHealth waits for a matching acknowledgement below target's root. +func (waiter FileHealthWaiter) WaitForHealth(ctx context.Context, targetDir, requestID string, timeout time.Duration) error { + layout, err := inspectTarget(targetDir) + if err != nil { + return err + } + if !validRequestID(requestID) { + return fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest) + } + interval := waiter.PollInterval + if interval <= 0 { + interval = 250 * time.Millisecond + } + timer := time.NewTimer(timeout) + defer timer.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + record, readErr := readHealth(layout.health) + if readErr == nil { + if record.RequestID != requestID { + return fmt.Errorf("%w: request ID does not match", ErrHealthInvalid) + } + return nil + } + if !os.IsNotExist(readErr) { + return readErr + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return ErrHealthTimeout + case <-ticker.C: + } + } +} + +// AcknowledgeHealthFromExecutable writes the acknowledgement only when the +// running executable is exactly /app/SoftBox.exe. +func AcknowledgeHealthFromExecutable(executablePath, requestID string, syncer DirectorySyncer) error { + if syncer == nil { + return fmt.Errorf("%w: directory syncer is required", ErrInvalidRequest) + } + if !validRequestID(requestID) { + return fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest) + } + if !filepath.IsAbs(executablePath) || filepath.Base(filepath.Clean(executablePath)) != ProductExecutableName { + return fmt.Errorf("%w: executable is not SoftBox.exe", ErrUnsafeLayout) + } + appDir := filepath.Dir(filepath.Clean(executablePath)) + if filepath.Base(appDir) != appDirectoryName { + return fmt.Errorf("%w: executable is outside root/app", ErrUnsafeLayout) + } + layout, err := inspectTarget(appDir) + if err != nil { + return err + } + if err := requireRealDirectory(layout.target, "target"); err != nil { + return err + } + if err := validateRegularFile(executablePath); err != nil { + return fmt.Errorf("%w: %v", ErrUnsafeLayout, err) + } + transaction, exists, err := loadTransaction(layout) + if err != nil { + return err + } + if !exists || transaction.RequestID != requestID || + (transaction.Phase != phaseStagingActivated && transaction.Phase != phaseLaunched) { + return fmt.Errorf("%w: no matching activated transaction", ErrHealthInvalid) + } + record := healthRecord{SchemaVersion: transactionSchemaVersion, RequestID: requestID} + data, err := json.Marshal(record) + if err != nil { + return err + } + data = append(data, '\n') + return replaceRegularFile(layout.root, layout.health, ".self-update-health-*.tmp", data, syncer) +} + +func readHealth(path string) (healthRecord, error) { + data, err := readRegularFile(path) + if err != nil { + return healthRecord{}, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var record healthRecord + if err := decoder.Decode(&record); err != nil { + return healthRecord{}, fmt.Errorf("%w: %v", ErrHealthInvalid, err) + } + var extra interface{} + if err := decoder.Decode(&extra); err != io.EOF { + return healthRecord{}, ErrHealthInvalid + } + if record.SchemaVersion != transactionSchemaVersion || !validRequestID(record.RequestID) { + return healthRecord{}, ErrHealthInvalid + } + return record, nil +} diff --git a/core/updater/layout.go b/core/updater/layout.go new file mode 100644 index 0000000..6ceefc9 --- /dev/null +++ b/core/updater/layout.go @@ -0,0 +1,184 @@ +package updater + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + appDirectoryName = "app" + stagingDirectoryName = "staging" + backupsDirectoryName = "backups" + transactionFileName = "self-update-transaction.json" + healthFileName = "self-update-health.json" + transactionSchemaVersion = 1 +) + +type updateLayout struct { + root string + target string + staging string + backup string + transaction string + health string + requestID string +} + +func inspectRequest(request Request) (updateLayout, error) { + if request.ParentPID <= 0 { + return updateLayout{}, fmt.Errorf("%w: parent PID must be positive", ErrInvalidRequest) + } + if !validRequestID(request.RequestID) { + return updateLayout{}, fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest) + } + layout, err := inspectTarget(request.TargetDir) + if err != nil { + return updateLayout{}, err + } + if err := requireRealDirectory(layout.target, "target"); err != nil { + return updateLayout{}, err + } + layout.requestID = request.RequestID + expectedStaging := filepath.Join(layout.root, stagingDirectoryName, request.RequestID) + if !filepath.IsAbs(request.StagingDir) || !samePath(request.StagingDir, expectedStaging) { + return updateLayout{}, fmt.Errorf("%w: staging path does not match fixed layout", ErrUnsafeLayout) + } + layout.staging = expectedStaging + layout.backup = filepath.Join(layout.root, backupsDirectoryName, request.RequestID) + if err := requireRealDirectory(filepath.Join(layout.root, stagingDirectoryName), "staging parent"); err != nil { + return updateLayout{}, err + } + if err := requireRealDirectory(layout.staging, "staging"); err != nil { + if os.IsNotExist(unwrapPathError(err)) { + return updateLayout{}, ErrStagingMissing + } + return updateLayout{}, err + } + return layout, nil +} + +func inspectTarget(targetDir string) (updateLayout, error) { + if !filepath.IsAbs(targetDir) { + return updateLayout{}, fmt.Errorf("%w: target path must be absolute", ErrUnsafeLayout) + } + target := filepath.Clean(targetDir) + if filepath.Base(target) != appDirectoryName { + return updateLayout{}, fmt.Errorf("%w: target must be root/app", ErrUnsafeLayout) + } + root := filepath.Dir(target) + if filepath.Base(root) == "." || root == target { + return updateLayout{}, fmt.Errorf("%w: target root is invalid", ErrUnsafeLayout) + } + if err := requireRealDirectory(root, "root"); err != nil { + return updateLayout{}, err + } + if exists, err := realDirectoryState(target, "target"); err != nil { + return updateLayout{}, err + } else if !exists { + // target can be absent while recovery restores a backed-up app. + return updateLayout{root: root, target: target, transaction: filepath.Join(root, transactionFileName), health: filepath.Join(root, healthFileName)}, nil + } + return updateLayout{root: root, target: target, transaction: filepath.Join(root, transactionFileName), health: filepath.Join(root, healthFileName)}, nil +} + +func (layout updateLayout) validateReady(syncer DirectorySyncer) error { + if err := requireRealDirectory(layout.target, "target"); err != nil { + return err + } + if err := requireRealDirectory(layout.staging, "staging"); err != nil { + return err + } + if err := verifyTreeNoLinks(layout.staging); err != nil { + return err + } + if err := validateRegularFile(filepath.Join(layout.staging, ProductExecutableName)); err != nil { + return fmt.Errorf("%w: staged %s: %v", ErrUnsafeLayout, ProductExecutableName, err) + } + backups := filepath.Dir(layout.backup) + if info, err := os.Lstat(backups); os.IsNotExist(err) { + if err := os.Mkdir(backups, 0o700); err != nil { + return fmt.Errorf("create backups directory: %w", err) + } + if err := syncer.SyncDirectory(layout.root); err != nil { + return fmt.Errorf("sync root after creating backups directory: %w", err) + } + } else if err != nil { + return fmt.Errorf("%w: inspect backups parent: %v", ErrUnsafeLayout, err) + } else if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: backups parent is not a real directory", ErrUnsafeLayout) + } + if exists, err := realDirectoryState(layout.backup, "backup"); err != nil { + return err + } else if exists { + return ErrBackupExists + } + return nil +} + +func (layout updateLayout) entrypoint() string { + return filepath.Join(layout.target, ProductExecutableName) +} + +func requireRealDirectory(path string, description string) error { + exists, err := realDirectoryState(path, description) + if err != nil { + return err + } + if !exists { + return &os.PathError{Op: "lstat", Path: path, Err: os.ErrNotExist} + } + return nil +} + +func realDirectoryState(path string, description string) (bool, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("%w: inspect %s: %v", ErrUnsafeLayout, description, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, fmt.Errorf("%w: %s is not a real directory", ErrUnsafeLayout, description) + } + return true, nil +} + +func validateRegularFile(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("file is not a regular non-symlink file") + } + return nil +} + +func validRequestID(value string) bool { + if len(value) < 8 || len(value) > 64 || strings.HasPrefix(value, "-") || strings.HasSuffix(value, "-") { + return false + } + for _, char := range value { + if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { + return false + } + } + return true +} + +func samePath(left string, right string) bool { + return filepath.Clean(left) == filepath.Clean(right) +} + +func unwrapPathError(err error) error { + for { + pathErr, ok := err.(*os.PathError) + if !ok { + return err + } + err = pathErr.Err + } +} diff --git a/core/updater/recovery.go b/core/updater/recovery.go new file mode 100644 index 0000000..d3d79e7 --- /dev/null +++ b/core/updater/recovery.go @@ -0,0 +1,102 @@ +package updater + +import ( + "fmt" + "path/filepath" +) + +func recoverLayout(layout updateLayout, syncer DirectorySyncer) error { + record, exists, err := loadTransaction(layout) + if err != nil || !exists { + return err + } + layout.requestID = record.RequestID + layout.staging = filepath.Join(layout.root, stagingDirectoryName, record.RequestID) + layout.backup = filepath.Join(layout.root, backupsDirectoryName, record.RequestID) + switch record.Phase { + case phasePrepared: + return removeTransaction(layout, syncer) + case phaseTargetBackedUp: + if err := restoreBackup(layout, syncer); err != nil { + return err + } + return removeTransaction(layout, syncer) + case phaseStagingActivated, phaseLaunched: + if healthMatches(layout, record.RequestID) { + if err := removeManagedTree(layout.backup, layout, syncer); err != nil { + return fmt.Errorf("finalize recovered self-update: %w", err) + } + return removeTransaction(layout, syncer) + } + return rollbackLayout(layout, syncer) + case phaseCommitted: + if err := removeManagedTree(layout.backup, layout, syncer); err != nil { + return fmt.Errorf("finalize committed self-update: %w", err) + } + return removeTransaction(layout, syncer) + default: + return ErrTransactionCorrupt + } +} + +func rollbackLayout(layout updateLayout, syncer DirectorySyncer) error { + backupExists, err := realDirectoryState(layout.backup, "backup") + if err != nil || !backupExists { + return fmt.Errorf("%w: old app backup is unavailable", ErrRecoveryRequired) + } + targetExists, err := realDirectoryState(layout.target, "target") + if err != nil { + return err + } + if targetExists { + stagingExists, err := realDirectoryState(layout.staging, "staging") + if err != nil { + return err + } + if stagingExists { + return fmt.Errorf("%w: staged and active app both exist", ErrRecoveryRequired) + } + if err := renameDirectory(layout.target, layout.staging, layout, syncer, "preserve failed staged app"); err != nil { + return fmt.Errorf("%w: %v", ErrRecoveryRequired, err) + } + } + if err := renameDirectory(layout.backup, layout.target, layout, syncer, "restore previous app"); err != nil { + return fmt.Errorf("%w: %v", ErrRecoveryRequired, err) + } + if err := removeTransaction(layout, syncer); err != nil { + return err + } + if err := removeHealth(layout, syncer); err != nil { + return err + } + return nil +} + +func restoreBackup(layout updateLayout, syncer DirectorySyncer) error { + targetExists, err := realDirectoryState(layout.target, "target") + if err != nil { + return err + } + if targetExists { + return fmt.Errorf("%w: target exists before backup restoration", ErrRecoveryRequired) + } + if exists, err := realDirectoryState(layout.backup, "backup"); err != nil || !exists { + if err != nil { + return err + } + return fmt.Errorf("%w: backup is unavailable", ErrRecoveryRequired) + } + if err := renameDirectory(layout.backup, layout.target, layout, syncer, "restore previous app"); err != nil { + return fmt.Errorf("%w: %v", ErrRecoveryRequired, err) + } + return nil +} + +func healthMatches(layout updateLayout, requestID string) bool { + record, err := readHealth(layout.health) + return err == nil && record.RequestID == requestID +} + +func removeHealth(layout updateLayout, syncer DirectorySyncer) error { + return removeRegularFile(layout.health, layout.root, syncer) +} diff --git a/core/updater/transaction.go b/core/updater/transaction.go new file mode 100644 index 0000000..db4dcbb --- /dev/null +++ b/core/updater/transaction.go @@ -0,0 +1,87 @@ +package updater + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" +) + +type transactionPhase string + +const ( + phasePrepared transactionPhase = "prepared" + phaseTargetBackedUp transactionPhase = "target_backed_up" + phaseStagingActivated transactionPhase = "staging_activated" + phaseLaunched transactionPhase = "launched" + phaseCommitted transactionPhase = "committed" +) + +type transaction struct { + SchemaVersion int `json:"schema_version"` + RequestID string `json:"request_id"` + Phase transactionPhase `json:"phase"` +} + +func (record transaction) validate() error { + if record.SchemaVersion != transactionSchemaVersion || !validRequestID(record.RequestID) { + return ErrTransactionCorrupt + } + switch record.Phase { + case phasePrepared, phaseTargetBackedUp, phaseStagingActivated, phaseLaunched, phaseCommitted: + return nil + default: + return ErrTransactionCorrupt + } +} + +func writeTransaction(layout updateLayout, phase transactionPhase, syncer DirectorySyncer) error { + record := transaction{SchemaVersion: transactionSchemaVersion, RequestID: layout.requestID, Phase: phase} + if err := record.validate(); err != nil { + return err + } + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("encode self-update transaction: %w", err) + } + data = append(data, '\n') + if err := replaceRegularFile(layout.root, layout.transaction, ".self-update-transaction-*.tmp", data, syncer); err != nil { + return fmt.Errorf("write self-update transaction: %w", err) + } + return nil +} + +func loadTransaction(layout updateLayout) (transaction, bool, error) { + data, err := readRegularFile(layout.transaction) + if os.IsNotExist(err) { + return transaction{}, false, nil + } + if err != nil { + return transaction{}, false, fmt.Errorf("read self-update transaction: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var record transaction + if err := decoder.Decode(&record); err != nil { + return transaction{}, false, fmt.Errorf("%w: %v", ErrTransactionCorrupt, err) + } + var extra interface{} + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return transaction{}, false, fmt.Errorf("%w: trailing JSON value", ErrTransactionCorrupt) + } + return transaction{}, false, fmt.Errorf("%w: trailing data: %v", ErrTransactionCorrupt, err) + } + if err := record.validate(); err != nil { + return transaction{}, false, err + } + return record, true, nil +} + +func removeTransaction(layout updateLayout, syncer DirectorySyncer) error { + if err := removeRegularFile(layout.transaction, layout.root, syncer); err != nil { + return fmt.Errorf("remove self-update transaction: %w", err) + } + return nil +} diff --git a/core/updater/updater.go b/core/updater/updater.go new file mode 100644 index 0000000..c2c7502 --- /dev/null +++ b/core/updater/updater.go @@ -0,0 +1,198 @@ +// Package updater performs the constrained on-disk activation of a prepared +// SoftBox self-update. It deliberately does not download, verify, or select an +// update package. +package updater + +import ( + "context" + "errors" + "fmt" + "time" +) + +const ( + // ProductExecutableName is the only executable the updater may start. + ProductExecutableName = "SoftBox.exe" + // InternalHealthFlag is accepted only by SoftBox itself after an update. + InternalHealthFlag = "--softbox-update-health" +) + +var ( + ErrInvalidRequest = errors.New("invalid self-update request") + ErrUnsafeLayout = errors.New("unsafe self-update layout") + ErrStagingMissing = errors.New("self-update staging directory is missing") + ErrBackupExists = errors.New("self-update backup directory already exists") + ErrTransactionCorrupt = errors.New("self-update transaction is corrupt") + ErrRecoveryRequired = errors.New("self-update recovery is required") + ErrParentWait = errors.New("wait for SoftBox parent process") + ErrLaunch = errors.New("launch updated SoftBox") + ErrHealthTimeout = errors.New("updated SoftBox health confirmation timed out") + ErrHealthInvalid = errors.New("updated SoftBox health confirmation is invalid") +) + +// Request identifies one prepared self-update. StagingDir and TargetDir are +// both checked against the fixed layout; callers cannot choose arbitrary move +// endpoints. +type Request struct { + ParentPID int + StagingDir string + TargetDir string + RequestID string +} + +// StartCommand contains the only process launch allowed by this package. +// Platform adapters must pass precisely the fixed internal health flag. +type StartCommand struct { + Entrypoint string + WorkingDirectory string + HealthRequestID string +} + +// ProcessWaiter waits for the old main-process PID to end naturally. +type ProcessWaiter interface { + WaitForProcessExit(context.Context, int, time.Duration) error +} + +// Launcher starts the verified new main executable without a shell. +type Launcher interface { + StartSelfUpdate(StartCommand) (int, error) +} + +// DirectorySyncer is implemented by the platform boundary. Directory flushing +// requires a Windows handle implementation and must not leak into core. +type DirectorySyncer interface { + SyncDirectory(path string) error +} + +// HealthWaiter observes the minimal acknowledgement written by the new main +// process. It must only accept the exact request ID. +type HealthWaiter interface { + WaitForHealth(context.Context, string, string, time.Duration) error +} + +// Timeouts controls externally-blocking update operations. +type Timeouts struct { + ParentExit time.Duration + Health time.Duration +} + +// Service owns one constrained self-update orchestration. +type Service struct { + waiter ProcessWaiter + launcher Launcher + syncer DirectorySyncer + health HealthWaiter + timeouts Timeouts +} + +// NewService constructs an updater. Missing dependencies are reported by +// Update, keeping command composition straightforward. +func NewService( + waiter ProcessWaiter, + launcher Launcher, + syncer DirectorySyncer, + health HealthWaiter, + timeouts Timeouts, +) *Service { + if timeouts.ParentExit <= 0 { + timeouts.ParentExit = 2 * time.Minute + } + if timeouts.Health <= 0 { + timeouts.Health = 45 * time.Second + } + return &Service{waiter: waiter, launcher: launcher, syncer: syncer, health: health, timeouts: timeouts} +} + +// Update waits for the old process, recovers a previous interrupted switch if +// needed, and activates the prepared directory. It never kills a process. +func (service *Service) Update(ctx context.Context, request Request) error { + if service == nil || service.waiter == nil || service.launcher == nil || service.syncer == nil || service.health == nil { + return fmt.Errorf("%w: updater dependencies are incomplete", ErrInvalidRequest) + } + layout, err := inspectRequest(request) + if err != nil { + return err + } + if err := service.waiter.WaitForProcessExit(ctx, request.ParentPID, service.timeouts.ParentExit); err != nil { + return fmt.Errorf("%w: %w", ErrParentWait, err) + } + if err := recoverLayout(layout, service.syncer); err != nil { + return err + } + if err := layout.validateReady(service.syncer); err != nil { + return err + } + if err := removeHealth(layout, service.syncer); err != nil { + return err + } + + if err := writeTransaction(layout, phasePrepared, service.syncer); err != nil { + return err + } + if err := renameDirectory(layout.target, layout.backup, layout, service.syncer, "back up current app"); err != nil { + return service.failBeforeActivation(err) + } + if err := writeTransaction(layout, phaseTargetBackedUp, service.syncer); err != nil { + return service.rollback(layout, err) + } + if err := renameDirectory(layout.staging, layout.target, layout, service.syncer, "activate staged app"); err != nil { + return service.rollback(layout, err) + } + if err := writeTransaction(layout, phaseStagingActivated, service.syncer); err != nil { + return service.rollback(layout, err) + } + + entrypoint := layout.entrypoint() + if err := validateRegularFile(entrypoint); err != nil { + return service.rollback(layout, fmt.Errorf("%w: %v", ErrUnsafeLayout, err)) + } + if _, err := service.launcher.StartSelfUpdate(StartCommand{ + Entrypoint: entrypoint, WorkingDirectory: layout.target, HealthRequestID: layout.requestID, + }); err != nil { + return service.rollback(layout, fmt.Errorf("%w: %w", ErrLaunch, err)) + } + if err := writeTransaction(layout, phaseLaunched, service.syncer); err != nil { + return service.rollback(layout, err) + } + if err := service.health.WaitForHealth(ctx, layout.target, layout.requestID, service.timeouts.Health); err != nil { + return service.rollback(layout, err) + } + if err := writeTransaction(layout, phaseCommitted, service.syncer); err != nil { + return service.rollback(layout, err) + } + if err := removeManagedTree(layout.backup, layout, service.syncer); err != nil { + return fmt.Errorf("commit self-update: %w", err) + } + if err := removeTransaction(layout, service.syncer); err != nil { + return err + } + if err := removeHealth(layout, service.syncer); err != nil { + return err + } + return nil +} + +func (service *Service) failBeforeActivation(cause error) error { + return fmt.Errorf("prepare self-update: %w", cause) +} + +func (service *Service) rollback(layout updateLayout, cause error) error { + rollbackErr := rollbackLayout(layout, service.syncer) + if rollbackErr != nil { + return errors.Join(cause, rollbackErr) + } + return cause +} + +// Recover restores or finalizes a previously interrupted transaction for the +// fixed target directory. Callers must wait for any old parent process first. +func Recover(targetDir string, syncer DirectorySyncer) error { + if syncer == nil { + return fmt.Errorf("%w: directory syncer is required", ErrInvalidRequest) + } + layout, err := inspectTarget(targetDir) + if err != nil { + return err + } + return recoverLayout(layout, syncer) +} diff --git a/core/updater/updater_test.go b/core/updater/updater_test.go new file mode 100644 index 0000000..92cc09f --- /dev/null +++ b/core/updater/updater_test.go @@ -0,0 +1,254 @@ +package updater + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +const testRequestID = "update-1234" + +type testSyncer struct{ err error } + +func (syncer testSyncer) SyncDirectory(string) error { return syncer.err } + +type testWaiter struct { + err error + calls int +} + +func (waiter *testWaiter) WaitForProcessExit(context.Context, int, time.Duration) error { + waiter.calls++ + return waiter.err +} + +type testLauncher struct { + command StartCommand + err error +} + +func (launcher *testLauncher) StartSelfUpdate(command StartCommand) (int, error) { + launcher.command = command + return 42, launcher.err +} + +type testHealth struct{ err error } + +func (health testHealth) WaitForHealth(context.Context, string, string, time.Duration) error { + return health.err +} + +func TestUpdateActivatesOnlyFixedLayoutAfterHealth(t *testing.T) { + request, root := testRequest(t) + waiter := &testWaiter{} + launcher := &testLauncher{} + service := NewService(waiter, launcher, testSyncer{}, testHealth{}, Timeouts{}) + + if err := service.Update(context.Background(), request); err != nil { + t.Fatalf("Update() error = %v", err) + } + if waiter.calls != 1 { + t.Fatalf("wait calls = %d, want 1", waiter.calls) + } + if got := readFile(t, filepath.Join(root, "app", ProductExecutableName)); got != "new" { + t.Fatalf("activated executable = %q, want new", got) + } + if got := launcher.command.Entrypoint; got != filepath.Join(root, "app", ProductExecutableName) { + t.Fatalf("launch entrypoint = %q", got) + } + if launcher.command.WorkingDirectory != filepath.Join(root, "app") || launcher.command.HealthRequestID != testRequestID { + t.Fatalf("launch command = %#v, want fixed app directory and request ID", launcher.command) + } + for _, path := range []string{ + filepath.Join(root, "backups", testRequestID), + filepath.Join(root, transactionFileName), + filepath.Join(root, healthFileName), + } { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("%s remains after commit: %v", path, err) + } + } + if got := readFile(t, filepath.Join(root, "data", "keep.txt")); got != "data" { + t.Fatalf("data changed: %q", got) + } + if got := readFile(t, filepath.Join(root, "licenses", "keep.txt")); got != "licenses" { + t.Fatalf("licenses changed: %q", got) + } +} + +func TestUpdateRestoresOldAppWhenHealthFails(t *testing.T) { + request, root := testRequest(t) + service := NewService(&testWaiter{}, &testLauncher{}, testSyncer{}, testHealth{err: ErrHealthTimeout}, Timeouts{}) + + err := service.Update(context.Background(), request) + if !errors.Is(err, ErrHealthTimeout) { + t.Fatalf("Update() error = %v, want ErrHealthTimeout", err) + } + if got := readFile(t, filepath.Join(root, "app", ProductExecutableName)); got != "old" { + t.Fatalf("restored executable = %q, want old", got) + } + if got := readFile(t, filepath.Join(root, "staging", testRequestID, ProductExecutableName)); got != "new" { + t.Fatalf("preserved staged executable = %q, want new", got) + } + if _, err := os.Lstat(filepath.Join(root, transactionFileName)); !os.IsNotExist(err) { + t.Fatalf("transaction remains after successful rollback: %v", err) + } +} + +func TestUpdateDoesNotTouchLayoutWhenParentWaitFails(t *testing.T) { + request, root := testRequest(t) + waitErr := errors.New("permission denied") + service := NewService(&testWaiter{err: waitErr}, &testLauncher{}, testSyncer{}, testHealth{}, Timeouts{}) + + err := service.Update(context.Background(), request) + if !errors.Is(err, ErrParentWait) || !errors.Is(err, waitErr) { + t.Fatalf("Update() error = %v, want wrapped parent wait error", err) + } + if got := readFile(t, filepath.Join(root, "app", ProductExecutableName)); got != "old" { + t.Fatalf("target changed after wait failure: %q", got) + } + if got := readFile(t, filepath.Join(root, "staging", testRequestID, ProductExecutableName)); got != "new" { + t.Fatalf("staging changed after wait failure: %q", got) + } + if _, err := os.Lstat(filepath.Join(root, transactionFileName)); !os.IsNotExist(err) { + t.Fatalf("transaction created after wait failure: %v", err) + } +} + +func TestUpdateRejectsCrossLayoutWithoutWaiting(t *testing.T) { + request, root := testRequest(t) + request.StagingDir = filepath.Join(root, "outside", testRequestID) + waiter := &testWaiter{} + service := NewService(waiter, &testLauncher{}, testSyncer{}, testHealth{}, Timeouts{}) + + err := service.Update(context.Background(), request) + if !errors.Is(err, ErrUnsafeLayout) { + t.Fatalf("Update() error = %v, want ErrUnsafeLayout", err) + } + if waiter.calls != 0 { + t.Fatalf("wait calls = %d, want 0", waiter.calls) + } +} + +func TestUpdateRejectsStagingSymlinkWithoutWaiting(t *testing.T) { + request, root := testRequest(t) + staging := filepath.Join(root, "staging", testRequestID) + if err := os.RemoveAll(staging); err != nil { + t.Fatal(err) + } + outside := filepath.Join(root, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(outside, ProductExecutableName), "new") + if err := os.Symlink(outside, staging); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + waiter := &testWaiter{} + service := NewService(waiter, &testLauncher{}, testSyncer{}, testHealth{}, Timeouts{}) + + err := service.Update(context.Background(), request) + if !errors.Is(err, ErrUnsafeLayout) { + t.Fatalf("Update() error = %v, want ErrUnsafeLayout", err) + } + if waiter.calls != 0 { + t.Fatalf("wait calls = %d, want 0", waiter.calls) + } +} + +func TestRecoverRestoresTargetBackedUpTransaction(t *testing.T) { + request, root := testRequest(t) + layout, err := inspectRequest(request) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, backupsDirectoryName), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Rename(layout.target, layout.backup); err != nil { + t.Fatal(err) + } + if err := writeTransaction(layout, phaseTargetBackedUp, testSyncer{}); err != nil { + t.Fatal(err) + } + if err := Recover(request.TargetDir, testSyncer{}); err != nil { + t.Fatalf("Recover() error = %v", err) + } + if got := readFile(t, filepath.Join(root, "app", ProductExecutableName)); got != "old" { + t.Fatalf("recovered executable = %q, want old", got) + } + if _, err := os.Lstat(filepath.Join(root, transactionFileName)); !os.IsNotExist(err) { + t.Fatalf("transaction remains after recovery: %v", err) + } +} + +func TestAcknowledgeHealthFromExecutableUsesFixedLocator(t *testing.T) { + request, root := testRequest(t) + layout, err := inspectRequest(request) + if err != nil { + t.Fatal(err) + } + if err := writeTransaction(layout, phaseStagingActivated, testSyncer{}); err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "app", ProductExecutableName) + if err := AcknowledgeHealthFromExecutable(executable, testRequestID, testSyncer{}); err != nil { + t.Fatalf("AcknowledgeHealthFromExecutable() error = %v", err) + } + record, err := readHealth(filepath.Join(root, healthFileName)) + if err != nil || record.RequestID != testRequestID { + t.Fatalf("health record = %#v, %v", record, err) + } + if err := AcknowledgeHealthFromExecutable(filepath.Join(root, "outside", ProductExecutableName), testRequestID, testSyncer{}); !errors.Is(err, ErrUnsafeLayout) { + t.Fatalf("outside acknowledgement error = %v, want ErrUnsafeLayout", err) + } + if err := AcknowledgeHealthFromExecutable(executable, "other-1234", testSyncer{}); !errors.Is(err, ErrHealthInvalid) { + t.Fatalf("unmatched acknowledgement error = %v, want ErrHealthInvalid", err) + } +} + +func TestFileHealthWaiterRejectsWrongRequestID(t *testing.T) { + _, root := testRequest(t) + writeFile(t, filepath.Join(root, healthFileName), "{\"schema_version\":1,\"request_id\":\"other-1234\"}\n") + err := (FileHealthWaiter{PollInterval: time.Millisecond}).WaitForHealth(context.Background(), filepath.Join(root, "app"), testRequestID, time.Second) + if !errors.Is(err, ErrHealthInvalid) { + t.Fatalf("WaitForHealth() error = %v, want ErrHealthInvalid", err) + } +} + +func testRequest(t *testing.T) (Request, string) { + t.Helper() + root := t.TempDir() + for _, directory := range []string{"app", filepath.Join("staging", testRequestID), "data", "licenses"} { + if err := os.MkdirAll(filepath.Join(root, directory), 0o700); err != nil { + t.Fatal(err) + } + } + writeFile(t, filepath.Join(root, "app", ProductExecutableName), "old") + writeFile(t, filepath.Join(root, "staging", testRequestID, ProductExecutableName), "new") + writeFile(t, filepath.Join(root, "data", "keep.txt"), "data") + writeFile(t, filepath.Join(root, "licenses", "keep.txt"), "licenses") + return Request{ + ParentPID: 1, TargetDir: filepath.Join(root, "app"), + StagingDir: filepath.Join(root, "staging", testRequestID), RequestID: testRequestID, + }, root +} + +func writeFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 1d215f2..3f3cdb0 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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-403 已正式落成,下一步实现 SoftBox 自更新助手、健康确认与恢复。物理断电、文件锁与杀毒软件干扰验证保留到 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 已完成受限 SoftBoxUpdater、自身 EXE 健康确认与可恢复切换。物理断电、文件锁与杀毒软件干扰验证保留到 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 已正式落成,当前执行 SoftBoxUpdater 自更新与健康恢复;完成后进入 Phase 5。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 已完成 SoftBoxUpdater 的受限 transaction、PID 自然退出、固定健康启动与恢复。下一步按路线图正式落成 Phase 5 的 T-501。T-601 仍须补真实 Windows 环境的断电/干扰注入。 ## 领取任务规则 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 1ec9563..f7e6715 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -19,7 +19,7 @@ | 包完整性 | SHA-256 | 已定 | 下载后、执行前强制校验 | | 传输 | HTTPS | 已定 | 清单与软件包一律 HTTPS | | Windows API | golang.org/x/sys/windows + 动态加载(LoadLibrary) | 已定 | Win10 专属 API 动态加载并降级,不进导入表 | -| EXE 签名 | Authenticode | 已定 | 主程序与 Updater 均签名;证书采购待确认 | +| EXE 签名 | Authenticode | 已定 | 主程序与 Updater 均签名;证书采购待确认;当前本地自更新只做受限切换,不消费在线签名包 | | 测试 | go test(core 无头可测) + fake/stub 注入 | 已定 | domain/application 不依赖 Gio 与 Windows API | | CI | Gitea Actions 模板 + 本地 Phase 0 脚本 | 部分已定 | `.gitea/workflows/phase0-build.yml` 复用本地入口;远端 runner 可用性待确认 | | 静态检查 | go vet(+ 待定 golangci-lint) | 部分已定 | vet 必跑;lint 工具后续确认 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 50db12f..e82e056 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -83,6 +83,7 @@ soft_quay/ │ └─ updater/ # 盒子自更新编排 ├─ app-modern/ # 现代版(go.mod,Go 1.25 + Gio v0.10.1) │ ├─ cmd/softbox/ +│ ├─ cmd/softboxupdater/# 无 Gio 的受限自更新助手 │ ├─ ui/gio/ # shell 根编排 + header/catalog/detail/style 职责文件 │ └─ platform/windows/ ├─ app-win7/ # Win7 遗留版(go.mod,Go 1.20 + Gio v0.6.0) @@ -187,7 +188,7 @@ T-613 已把该状态机的代码层耐久顺序收敛为:payload 的 CRC/长度 真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-601 在 Windows VM/真机做故障注入;T-302 的代码级链路与单元测试不替代硬件级断电验证。 -盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。 +T-403 的盒子自更新由独立、无 Gio 的 `SoftBoxUpdater.exe` 完成。它仅接受正 PID、绝对 `/app` 和绝对 `/staging/`;request ID 不走 CLI,而是从已准备 staging 的规范目录名派生并复验。旧 PID 自然退出后才检查/恢复上次 journal,随后以 `prepared → target_backed_up → staging_activated → launched → committed` 把 `app` 与同 root staging 受限 rename。`core/updater` 只依赖 PID waiter、固定 launcher、health waiter 和目录同步接口;Windows 的 `OpenProcess(SYNCHRONIZE)`/短等待、无 shell 固定 health 启动和 `FlushFileBuffers` 均保留在两端 `platform/windows`,非 Windows stub fail closed。新版仅能从自身 `/app/SoftBox.exe` 在匹配的已激活 transaction 下写最小 `/self-update-health.json` 确认;确认前旧 backup 不删除,失败优先 restore,文件锁导致 restore 失败则保留 journal/backup 而不强杀。它不提供下载、签名校验、版本选择或 UI 触发,可信 package→staging 链继续后置。 授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。 diff --git a/docs/api.md b/docs/api.md index cbd33bf..9075e95 100644 --- a/docs/api.md +++ b/docs/api.md @@ -366,7 +366,13 @@ SoftBox.exe --repair-app # 按 files.json 修复安装(V1.1) SoftBoxUpdater.exe --pid <主程序PID> --staging <暂存目录> --target <目标目录> ``` -等待主进程退出 → 备份旧版 → 切换新版 → 启动新版 SoftBox → 失败时恢复备份。退出码:0 成功;非 0 失败并写日志。 +这是受约束的本地激活助手,**不是**自更新下载或签名校验入口。`target` 必须是绝对 `/app`,`staging` 必须是绝对 `/staging/`;`request-id` 不作为 CLI 参数,助手只从已准备 staging 的末段取得并重新校验安全字符。它拒绝相对路径、交叉 root、符号链接、非目录、已有同 ID backup 和任意可执行路径/参数。 + +助手先等待正 PID 的旧主进程自然退出(OpenProcess/等待失败、取消或超时均不把它当成已退出),再恢复遗留 transaction 并执行 `prepared → target_backed_up → staging_activated → launched → committed`。它只允许 `app → backups/`、`staging/ → app` 两次受限 rename,并以原子 JSON transaction 和目录同步建立崩溃恢复栅栏;不会写 `data/` 或 `licenses/`,也不会强杀进程。 + +新版只以 `/app/SoftBox.exe --softbox-update-health `、固定工作目录启动。主程序在 Gio Layout 前从自身 EXE 推导 root,且仅当本地 transaction 处于已激活/已启动阶段并匹配 request ID 时,才在 `/self-update-health.json` 原子写入仅含 `schema_version` 和 `request_id` 的确认。助手只接受完全匹配的确认后才清理 backup/transaction;启动或健康失败会尽力恢复旧 `app`,若 Windows 文件锁禁止恢复则保留 journal/backup 供下次受控助手在旧 PID 退出后恢复。退出码:0 成功;非 0 表示未提交或恢复材料仍需处理。 + +当前没有可信 self-update 下载源、SHA-256/签名消费链、版本选择或 UI 触发器。任何调用方都必须在此助手之前独立完成可信包验证和安全 staging;真实文件锁、杀毒/断电和 UAC/签名发布由 T-601/T-603 的 Windows 环境验证覆盖。 ### 5.3 子软件推荐参数(v1 推荐,非强制) diff --git a/docs/current-state.md b/docs/current-state.md index dccd5ce..3376e0d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,23 +13,24 @@ ## 当前快照 - 日期: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 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排已完成 +- 阶段: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 子软件更新编排、T-403 受限盒子自更新事务已完成 - 技术栈:根 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 返回明确不支持。 +- T-403 自更新:`core/updater` 只接受正 PID、真实 `/app` 与同 root `/staging/`,先等待旧 PID 自然退出,随后恢复遗留 journal 或以 `prepared → target_backed_up → staging_activated → launched → committed` 切换;目录 rename、原子 JSON 和目录 durability 均由受限路径与平台同步栅栏保护。助手只启动固定 `/app/SoftBox.exe --softbox-update-health `,主程序在 Gio Layout 前从自身 EXE 写最小 health 确认;失败优先恢复旧 app,Windows 锁阻止恢复时保留 backup/journal,不强杀。两端有无 Gio `cmd/softboxupdater`,非 Windows 平台边界 fail closed;没有自更新下载、签名消费、版本选择或 UI 触发器。 - 测试: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-403 已正式落成,当前执行 SoftBoxUpdater 的受限 CLI、主 PID 自然退出、健康确认和可恢复自更新事务。可信自更新包下载/签名、许可证策略与完整端到端 cmd/UI 编排仍未装配;不得为此执行未验证 staging 或伪装自更新闭环。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入 +- 当前 blocker:可信自更新包下载/签名、版本选择、许可证策略与完整端到端 cmd/UI 编排仍未装配;不得为此执行未验证 staging 或伪装自更新闭环。下一步应按路线图正式落成 Phase 5 的 T-501。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 已完成;T-403 已正式落成,正在执行 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615、T-401~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 职责文件 | @@ -41,9 +42,8 @@ 任务状态以 `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`、`T-402`。 -- 正在进行:无。 -- 正在进行:T-403(依赖 T-402 已完成);完成、验证并提交后,才可按路线图正式落成 Phase 5 的 T-501。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 +- 已完成: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-403`。 +- 正在进行:无;下一步可按路线图正式落成 Phase 5 的 T-501。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 ## 当前可运行内容 diff --git a/docs/tasks/T-403.md b/docs/tasks/T-403.md index a5afa5e..053aa9b 100644 --- a/docs/tasks/T-403.md +++ b/docs/tasks/T-403.md @@ -3,12 +3,12 @@ id: T-403 title: SoftBox 自更新助手与健康确认恢复 phase: 4 deps: [T-402] -status: TODO +status: DONE created: 2026-07-19 issue: null -context_ref: null +context_ref: 2900deba4f6ea2e442555e52044118d62983fd74 claim_branch: null -work_branch: null +work_branch: agent/codex/T-403 write_paths: - docs/tasks/T-403.md - core/updater/ @@ -36,10 +36,10 @@ Phase 4 的子软件更新已能在用户关闭子软件后安全委托既有安 ## 方案 1. 新建 Go 1.20 兼容的 `core/updater`,固定布局为 `/app`(target)、`/staging/`(已由可信外层准备的新目录)、`/backups/`、`self-update-transaction.json` 与健康确认文件。请求只接受正主进程 PID、safe request ID、绝对且严格满足该布局的 staging/target;逐项 `Lstat` 拒绝 symlink、非目录、交叉 root、预存 backup/journal 异常,绝不接受 UI/CLI 传入可执行参数或任意移动路径。 -2. updater 先恢复上次未完成 transaction,再等待原主 PID 自然退出;取消、超时和等待错误均不触碰 target。退出后按 journal 栅栏执行 `prepared → target_backed_up → staging_activated → launched → committed`:只可把 old `app` 改名为本 request 的 backup、把同 root staging 改名为 `app`;每步用临时 JSON + 原子替换并同步目录。任何启动前/启动失败路径尝试恢复 old app;无法恢复时保留可验证 journal/backup,下一次 updater 可恢复,绝不删除 data/licenses。 +2. updater 先等待原主 PID 自然退出,才恢复上次未完成 transaction;取消、超时和等待错误均不触碰 target。退出后按 journal 栅栏执行 `prepared → target_backed_up → staging_activated → launched → committed`:只可把 old `app` 改名为本 request 的 backup、把同 root staging 改名为 `app`;每步用临时 JSON + 原子替换并同步目录。任何启动前/启动失败路径尝试恢复 old app;无法恢复时保留可验证 journal/backup,下一次 updater 可恢复,绝不删除 data/licenses。 3. 新版只能以已验证绝对 `/app/SoftBox.exe`、固定工作目录和内部固定 `--softbox-update-health ` 参数启动。`SoftBox.exe` 在 Gio Layout 之前(非 UI goroutine I/O)从自己的可执行路径推导同一 root,校验 request ID 和 health locator 后原子写入仅含 request ID 的健康确认;不得接受路径、URL 或任意命令。助手等待匹配确认后才 committed/清理 backup;超时、错误或错误 request ID 均返回稳定失败,保留恢复材料。新版已运行但未确认且 Windows 拒绝 rollback 时 fail closed 留 journal,不强杀;后续受控 updater 在主 PID 退出后恢复。 4. 两端 `platform/windows` 增加一致的主 PID 自然退出和固定内部健康启动边界。Windows 以 `OpenProcess(SYNCHRONIZE)` + 可取消的短 `WaitForSingleObject` 轮询实现,不将无权限/不存在 PID 当作“已退出”;启动不经 shell/PATH、只传固定 health flag。非 Windows stub 明确返回不支持。clock/process/launcher seam 覆盖已退出、正常退出、取消、超时、OpenProcess/wait 错误与参数固定性。 -5. 两端新增无 Gio 的 `cmd/softboxupdater`,严格解析 `--pid`、`--staging`、`--target`(内部 request ID 由助手生成),装配 core/platform 后以非 0 退出失败;主 cmd 只识别内部 health flag 并继续正常启动,不增加用户可传的任意执行入口。验证脚本同时构建主 EXE 和 Updater EXE;文档定义内部健康文件、恢复语义、真实下载/签名未装配事实与 T-601 真机限制。 +5. 两端新增无 Gio 的 `cmd/softboxupdater`,严格解析 `--pid`、`--staging`、`--target`;request ID 不作为 CLI 参数,而是由受信任预备器创建的规范 `/staging/` 目录名确定并被助手重新校验,装配 core/platform 后以非 0 退出失败;主 cmd 只识别内部 health flag 并继续正常启动,不增加用户可传的任意执行入口。验证脚本同时构建主 EXE 和 Updater EXE;文档定义内部健康文件、恢复语义、真实下载/签名未装配事实与 T-601 真机限制。 ## 验收要点 @@ -65,3 +65,5 @@ Phase 4 的子软件更新已能在用户关闭子软件后安全委托既有安 ## 执行记录 - 2026-07-19:正式落成。以 T-402 完成后的 Phase 4 依赖为基线,冻结独立 updater、受限根目录、PID 自然退出、健康确认 journal 和可恢复 rollback 的最小自更新协议;明确可信 self-update 下载/签名、真机故障和发布签名继续后置。 +- 2026-07-19:领取任务,基于 `2900deba4f6ea2e442555e52044118d62983fd74` 在 `agent/codex/T-403` 执行;先重跑基线,再实现独立 updater 与健康确认。 +- 2026-07-19:完成。新增 Go 1.20 `core/updater`,固定 `/app`、`staging/`、`backups/`、原子 transaction/health 文件与可恢复受限 rename;旧 PID 必须自然退出,健康确认必须来自自身 `app/SoftBox.exe` 且匹配已激活 transaction。双端 `platform/windows` 增加 `OpenProcess(SYNCHRONIZE)`/可取消等待、固定无 shell health 启动和目录 durability,non-Windows 明确 fail closed;两个无 Gio `cmd/softboxupdater` 严格处理 CLI,主 cmd 在 Gio Layout 前处理内部 health flag。规格实现前修正了“先恢复再等待 PID”会在旧主程序仍运行时改目录,以及“助手生成 ID”无法对应预备 staging 的矛盾:安全顺序为先等待再恢复,request ID 仅从受信任的规范 staging 目录名派生并复验。可信 self-update 下载/签名、版本选择和 UI 触发未装配。验证通过:`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./updater`、两端全包测试与 Windows amd64 的主程序/Updater 构建、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。 diff --git a/scripts/verify_phase0.ps1 b/scripts/verify_phase0.ps1 index 3665065..ed1199b 100644 --- a/scripts/verify_phase0.ps1 +++ b/scripts/verify_phase0.ps1 @@ -84,6 +84,8 @@ Invoke-Step "Test and build modern target with Go 1.25.0" { $env:GOOS = "windows" $env:GOARCH = "amd64" go -C app-modern build -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox.exe ./cmd/softbox + Assert-NativeSuccess -Step "Build modern SoftBox" + go -C app-modern build -trimpath -o ../dist/SoftBoxUpdater.exe ./cmd/softboxupdater } Invoke-Step "Test and build Win7 target with Go 1.20.14" { @@ -97,6 +99,8 @@ Invoke-Step "Test and build Win7 target with Go 1.20.14" { $env:GOOS = "windows" $env:GOARCH = "amd64" go -C app-win7 build -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox-win7.exe ./cmd/softbox + Assert-NativeSuccess -Step "Build Win7 SoftBox" + go -C app-win7 build -trimpath -o ../dist/SoftBoxUpdater-win7.exe ./cmd/softboxupdater } Write-Host "Phase 0 verification passed." diff --git a/scripts/verify_phase0.sh b/scripts/verify_phase0.sh index 9108441..c9029d0 100644 --- a/scripts/verify_phase0.sh +++ b/scripts/verify_phase0.sh @@ -54,6 +54,10 @@ GOTOOLCHAIN=go1.25.0 GOWORK="$ROOT_DIR/go.work" CGO_ENABLED=0 \ GOOS=windows GOARCH=amd64 \ go -C app-modern build -trimpath -ldflags="-H=windowsgui" \ -o ../dist/SoftBox.exe ./cmd/softbox +GOTOOLCHAIN=go1.25.0 GOWORK="$ROOT_DIR/go.work" CGO_ENABLED=0 \ + GOOS=windows GOARCH=amd64 \ + go -C app-modern build -trimpath \ + -o ../dist/SoftBoxUpdater.exe ./cmd/softboxupdater echo "==> Test and build Win7 target with Go 1.20.14" GOTOOLCHAIN=go1.20.14 GOWORK="$ROOT_DIR/app-win7/go.work" CGO_ENABLED=0 \ @@ -62,5 +66,9 @@ GOTOOLCHAIN=go1.20.14 GOWORK="$ROOT_DIR/app-win7/go.work" CGO_ENABLED=0 \ GOOS=windows GOARCH=amd64 \ go -C app-win7 build -trimpath -ldflags="-H=windowsgui" \ -o ../dist/SoftBox-win7.exe ./cmd/softbox +GOTOOLCHAIN=go1.20.14 GOWORK="$ROOT_DIR/app-win7/go.work" CGO_ENABLED=0 \ + GOOS=windows GOARCH=amd64 \ + go -C app-win7 build -trimpath \ + -o ../dist/SoftBoxUpdater-win7.exe ./cmd/softboxupdater echo "Phase 0 verification passed."