Implement self-update recovery (T-403)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 环境的断电/干扰注入。
|
||||||
|
|
||||||
## 领取任务规则
|
## 领取任务规则
|
||||||
|
|
||||||
|
|||||||
@@ -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 工具后续确认 |
|
||||||
|
|||||||
@@ -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
@@ -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 推荐,非强制)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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`。
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user