Implement self-update recovery (T-403)

This commit is contained in:
ila
2026-07-19 22:00:38 +08:00
parent 2900deba4f
commit 13004218cf
35 changed files with 1754 additions and 16 deletions
+6
View File
@@ -19,6 +19,12 @@ import (
const applicationEventCapacity = 32 const applicationEventCapacity = 32
func main() { 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() { go func() {
if err := run(); err != nil { if err := run(); err != nil {
log.Printf("%s stopped: %v", core.ProductName, err) log.Printf("%s stopped: %v", core.ProductName, err)
+26
View File
@@ -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
}
+63
View File
@@ -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 <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
}
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
}
@@ -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")
}
}
+4
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
"softbox.local/core/updater"
) )
// Edition identifies the application build channel shown by the UI. // Edition identifies the application build channel shown by the UI.
@@ -26,6 +27,9 @@ type Platform interface {
IsRunning(appID, entrypoint string) (bool, error) IsRunning(appID, entrypoint string) (bool, error)
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
Start(command launch.Command) (int, 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. // New returns the platform implementation selected by build tags.
@@ -8,6 +8,7 @@ import (
"time" "time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
"softbox.local/core/updater"
) )
type platformStub struct{} type platformStub struct{}
@@ -39,3 +40,15 @@ func (platformStub) WaitForExit(context.Context, string, string, time.Duration)
func (platformStub) Start(launch.Command) (int, error) { func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported 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
}
@@ -9,6 +9,7 @@ import (
"time" "time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
"softbox.local/core/updater"
) )
func TestPlatformStubFailsClosed(t *testing.T) { func TestPlatformStubFailsClosed(t *testing.T) {
@@ -25,4 +26,13 @@ func TestPlatformStubFailsClosed(t *testing.T) {
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) { if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Start() error = %v, want ErrUnsupported", err) 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)
}
} }
+49
View File
@@ -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:
}
}
}
@@ -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)
}
}
@@ -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) }
+6
View File
@@ -19,6 +19,12 @@ import (
const applicationEventCapacity = 32 const applicationEventCapacity = 32
func main() { 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() { go func() {
if err := run(); err != nil { if err := run(); err != nil {
log.Printf("%s Legacy stopped: %v", core.ProductName, err) log.Printf("%s Legacy stopped: %v", core.ProductName, err)
+26
View File
@@ -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
}
+63
View File
@@ -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 <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
}
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
}
+14
View File
@@ -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")
}
}
+4
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
"softbox.local/core/updater"
) )
// Edition identifies the application build channel shown by the UI. // Edition identifies the application build channel shown by the UI.
@@ -26,6 +27,9 @@ type Platform interface {
IsRunning(appID, entrypoint string) (bool, error) IsRunning(appID, entrypoint string) (bool, error)
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
Start(command launch.Command) (int, 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. // New returns the platform implementation selected by build tags.
@@ -8,6 +8,7 @@ import (
"time" "time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
"softbox.local/core/updater"
) )
type platformStub struct{} type platformStub struct{}
@@ -39,3 +40,15 @@ func (platformStub) WaitForExit(context.Context, string, string, time.Duration)
func (platformStub) Start(launch.Command) (int, error) { func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported 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
}
@@ -9,6 +9,7 @@ import (
"time" "time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
"softbox.local/core/updater"
) )
func TestPlatformStubFailsClosed(t *testing.T) { func TestPlatformStubFailsClosed(t *testing.T) {
@@ -25,4 +26,13 @@ func TestPlatformStubFailsClosed(t *testing.T) {
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) { if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Start() error = %v, want ErrUnsupported", err) 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)
}
} }
+49
View File
@@ -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:
}
}
}
@@ -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)
}
}
@@ -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) }
+123
View File
@@ -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
})
}
+125
View File
@@ -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 <root>/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
}
+184
View File
@@ -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
}
}
+102
View File
@@ -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)
}
+87
View File
@@ -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
}
+198
View File
@@ -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)
}
+254
View File
@@ -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)
}
+2 -2
View File
@@ -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 安全解压、原子切换回滚原型。 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 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 环境的断电/干扰注入。
## 领取任务规则 ## 领取任务规则
+1 -1
View File
@@ -19,7 +19,7 @@
| 包完整性 | SHA-256 | 已定 | 下载后、执行前强制校验 | | 包完整性 | SHA-256 | 已定 | 下载后、执行前强制校验 |
| 传输 | HTTPS | 已定 | 清单与软件包一律 HTTPS | | 传输 | HTTPS | 已定 | 清单与软件包一律 HTTPS |
| Windows API | golang.org/x/sys/windows + 动态加载(LoadLibrary) | 已定 | Win10 专属 API 动态加载并降级,不进导入表 | | 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 | | 测试 | go test(core 无头可测) + fake/stub 注入 | 已定 | domain/application 不依赖 Gio 与 Windows API |
| CI | Gitea Actions 模板 + 本地 Phase 0 脚本 | 部分已定 | `.gitea/workflows/phase0-build.yml` 复用本地入口;远端 runner 可用性待确认 | | CI | Gitea Actions 模板 + 本地 Phase 0 脚本 | 部分已定 | `.gitea/workflows/phase0-build.yml` 复用本地入口;远端 runner 可用性待确认 |
| 静态检查 | go vet(+ 待定 golangci-lint) | 部分已定 | vet 必跑;lint 工具后续确认 | | 静态检查 | go vet(+ 待定 golangci-lint) | 部分已定 | vet 必跑;lint 工具后续确认 |
+2 -1
View File
@@ -83,6 +83,7 @@ soft_quay/
│ └─ updater/ # 盒子自更新编排 │ └─ updater/ # 盒子自更新编排
├─ app-modern/ # 现代版(go.mod,Go 1.25 + Gio v0.10.1) ├─ app-modern/ # 现代版(go.mod,Go 1.25 + Gio v0.10.1)
│ ├─ cmd/softbox/ │ ├─ cmd/softbox/
│ ├─ cmd/softboxupdater/# 无 Gio 的受限自更新助手
│ ├─ ui/gio/ # shell 根编排 + header/catalog/detail/style 职责文件 │ ├─ ui/gio/ # shell 根编排 + header/catalog/detail/style 职责文件
│ └─ platform/windows/ │ └─ platform/windows/
├─ app-win7/ # Win7 遗留版(go.mod,Go 1.20 + Gio v0.6.0) ├─ 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 的代码级链路与单元测试不替代硬件级断电验证。 真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-601 在 Windows VM/真机做故障注入;T-302 的代码级链路与单元测试不替代硬件级断电验证。
盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。 T-403 的盒子自更新由独立、无 Gio 的 `SoftBoxUpdater.exe` 完成。它仅接受正 PID、绝对 `<root>/app` 和绝对 `<root>/staging/<request-id>`;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。新版仅能从自身 `<root>/app/SoftBox.exe` 在匹配的已激活 transaction 下写最小 `<root>/self-update-health.json` 确认;确认前旧 backup 不删除,失败优先 restore,文件锁导致 restore 失败则保留 journal/backup 而不强杀。它不提供下载、签名校验、版本选择或 UI 触发,可信 package→staging 链继续后置。
授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。 授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。
+7 -1
View File
@@ -366,7 +366,13 @@ SoftBox.exe --repair-app <id> # 按 files.json 修复安装(V1.1)
SoftBoxUpdater.exe --pid <主程序PID> --staging <暂存目录> --target <目标目录> SoftBoxUpdater.exe --pid <主程序PID> --staging <暂存目录> --target <目标目录>
``` ```
等待主进程退出 → 备份旧版 → 切换新版 → 启动新版 SoftBox → 失败时恢复备份。退出码:0 成功;非 0 失败并写日志。 这是受约束的本地激活助手,**不是**自更新下载或签名校验入口。`target` 必须是绝对 `<root>/app`,`staging` 必须是绝对 `<root>/staging/<request-id>`;`request-id` 不作为 CLI 参数,助手只从已准备 staging 的末段取得并重新校验安全字符。它拒绝相对路径、交叉 root、符号链接、非目录、已有同 ID backup 和任意可执行路径/参数。
助手先等待正 PID 的旧主进程自然退出(OpenProcess/等待失败、取消或超时均不把它当成已退出),再恢复遗留 transaction 并执行 `prepared → target_backed_up → staging_activated → launched → committed`。它只允许 `app → backups/<request-id>`、`staging/<request-id> → app` 两次受限 rename,并以原子 JSON transaction 和目录同步建立崩溃恢复栅栏;不会写 `data/` 或 `licenses/`,也不会强杀进程。
新版只以 `<root>/app/SoftBox.exe --softbox-update-health <request-id>`、固定工作目录启动。主程序在 Gio Layout 前从自身 EXE 推导 root,且仅当本地 transaction 处于已激活/已启动阶段并匹配 request ID 时,才在 `<root>/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 推荐,非强制) ### 5.3 子软件推荐参数(v1 推荐,非强制)
+6 -6
View File
@@ -13,23 +13,24 @@
## 当前快照 ## 当前快照
- 日期:2026-07-19 - 日期:2026-07-19
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 与 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 不交叉解析 - 技术栈:根 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 镜像职责拆文件 - 生产代码: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-402 更新用例:`core/application/update` 只接收外层可信的 install selection,验证已装版本/旧 entrypoint后,运行中才请求关闭确认并以 1 秒~10 分钟上限等待自然退出,随后委托已有 `InstallService` 的双重运行复查与 rollback;取消、超时、Toolhelp 检测错误和安装错误均保留稳定 code/错误链,不强杀且不改 `data/`、`licenses/`。两端 platform 对齐 `WaitForExit` 契约,Windows 固定短轮询完整路径,非 Windows 返回明确不支持。
- T-403 自更新:`core/updater` 只接受正 PID、真实 `<root>/app` 与同 root `<root>/staging/<safe-request-id>`,先等待旧 PID 自然退出,随后恢复遗留 journal 或以 `prepared → target_backed_up → staging_activated → launched → committed` 切换;目录 rename、原子 JSON 和目录 durability 均由受限路径与平台同步栅栏保护。助手只启动固定 `<root>/app/SoftBox.exe --softbox-update-health <request-id>`,主程序在 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` 详情语义;安装恢复矩阵保持通过 - 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:T-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/` | 已有 | 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 双平台验证入口 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
@@ -41,9 +42,8 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`、`T-402`。 - 已完成: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 的物理断电与干扰故障注入仍保留为发布前环境验证。
- 正在进行:T-403(依赖 T-402 已完成);完成、验证并提交后,才可按路线图正式落成 Phase 5 的 T-501。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
## 当前可运行内容 ## 当前可运行内容
+7 -5
View File
@@ -3,12 +3,12 @@ id: T-403
title: SoftBox 自更新助手与健康确认恢复 title: SoftBox 自更新助手与健康确认恢复
phase: 4 phase: 4
deps: [T-402] deps: [T-402]
status: TODO status: DONE
created: 2026-07-19 created: 2026-07-19
issue: null issue: null
context_ref: null context_ref: 2900deba4f6ea2e442555e52044118d62983fd74
claim_branch: null claim_branch: null
work_branch: null work_branch: agent/codex/T-403
write_paths: write_paths:
- docs/tasks/T-403.md - docs/tasks/T-403.md
- core/updater/ - core/updater/
@@ -36,10 +36,10 @@ Phase 4 的子软件更新已能在用户关闭子软件后安全委托既有安
## 方案 ## 方案
1. 新建 Go 1.20 兼容的 `core/updater`,固定布局为 `<root>/app`(target)、`<root>/staging/<request-id>`(已由可信外层准备的新目录)、`<root>/backups/<request-id>`、`self-update-transaction.json` 与健康确认文件。请求只接受正主进程 PID、safe request ID、绝对且严格满足该布局的 staging/target;逐项 `Lstat` 拒绝 symlink、非目录、交叉 root、预存 backup/journal 异常,绝不接受 UI/CLI 传入可执行参数或任意移动路径。 1. 新建 Go 1.20 兼容的 `core/updater`,固定布局为 `<root>/app`(target)、`<root>/staging/<request-id>`(已由可信外层准备的新目录)、`<root>/backups/<request-id>`、`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. 新版只能以已验证绝对 `<root>/app/SoftBox.exe`、固定工作目录和内部固定 `--softbox-update-health <request-id>` 参数启动。`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 退出后恢复。 3. 新版只能以已验证绝对 `<root>/app/SoftBox.exe`、固定工作目录和内部固定 `--softbox-update-health <request-id>` 参数启动。`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 错误与参数固定性。 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 参数,而是由受信任预备器创建的规范 `<root>/staging/<request-id>` 目录名确定并被助手重新校验,装配 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:正式落成。以 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`,固定 `<root>/app`、`staging/<request-id>`、`backups/<request-id>`、原子 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`。
+4
View File
@@ -84,6 +84,8 @@ Invoke-Step "Test and build modern target with Go 1.25.0" {
$env:GOOS = "windows" $env:GOOS = "windows"
$env:GOARCH = "amd64" $env:GOARCH = "amd64"
go -C app-modern build -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox.exe ./cmd/softbox 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" { 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:GOOS = "windows"
$env:GOARCH = "amd64" $env:GOARCH = "amd64"
go -C app-win7 build -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox-win7.exe ./cmd/softbox 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." Write-Host "Phase 0 verification passed."
+8
View File
@@ -54,6 +54,10 @@ GOTOOLCHAIN=go1.25.0 GOWORK="$ROOT_DIR/go.work" CGO_ENABLED=0 \
GOOS=windows GOARCH=amd64 \ GOOS=windows GOARCH=amd64 \
go -C app-modern build -trimpath -ldflags="-H=windowsgui" \ go -C app-modern build -trimpath -ldflags="-H=windowsgui" \
-o ../dist/SoftBox.exe ./cmd/softbox -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" 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 \ 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 \ GOOS=windows GOARCH=amd64 \
go -C app-win7 build -trimpath -ldflags="-H=windowsgui" \ go -C app-win7 build -trimpath -ldflags="-H=windowsgui" \
-o ../dist/SoftBox-win7.exe ./cmd/softbox -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." echo "Phase 0 verification passed."