Harden self-update recovery (T-617)

This commit is contained in:
ila
2026-07-20 00:05:41 +08:00
parent ea1d6c8ee4
commit 24e9862192
12 changed files with 369 additions and 42 deletions
@@ -0,0 +1,29 @@
package main
import (
"testing"
"softbox.local/core/updater"
)
func TestAcknowledgeInternalUpdateHealthRejectsMalformedArguments(t *testing.T) {
tests := []struct {
name string
args []string
handled bool
wantErr bool
}{
{name: "ordinary launch", args: nil},
{name: "other internal-looking flag", args: []string{"--softbox-other"}},
{name: "missing request ID", args: []string{updater.InternalHealthFlag}, handled: true, wantErr: true},
{name: "extra argument", args: []string{updater.InternalHealthFlag, "update-1234", "extra"}, handled: true, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handled, err := acknowledgeInternalUpdateHealth(test.args)
if handled != test.handled || (err != nil) != test.wantErr {
t.Fatalf("acknowledgeInternalUpdateHealth(%q) = handled=%t, err=%v; want handled=%t, error=%t", test.args, handled, err, test.handled, test.wantErr)
}
})
}
}
@@ -0,0 +1,29 @@
package main
import (
"testing"
"softbox.local/core/updater"
)
func TestAcknowledgeInternalUpdateHealthRejectsMalformedArguments(t *testing.T) {
tests := []struct {
name string
args []string
handled bool
wantErr bool
}{
{name: "ordinary launch", args: nil},
{name: "other internal-looking flag", args: []string{"--softbox-other"}},
{name: "missing request ID", args: []string{updater.InternalHealthFlag}, handled: true, wantErr: true},
{name: "extra argument", args: []string{updater.InternalHealthFlag, "update-1234", "extra"}, handled: true, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handled, err := acknowledgeInternalUpdateHealth(test.args)
if handled != test.handled || (err != nil) != test.wantErr {
t.Fatalf("acknowledgeInternalUpdateHealth(%q) = handled=%t, err=%v; want handled=%t, error=%t", test.args, handled, err, test.handled, test.wantErr)
}
})
}
}
+31 -4
View File
@@ -15,7 +15,7 @@ func recoverLayout(layout updateLayout, syncer DirectorySyncer) error {
layout.backup = filepath.Join(layout.root, backupsDirectoryName, record.RequestID)
switch record.Phase {
case phasePrepared:
return removeTransaction(layout, syncer)
return recoverPrepared(layout, syncer)
case phaseTargetBackedUp:
if err := restoreBackup(layout, syncer); err != nil {
return err
@@ -39,6 +39,33 @@ func recoverLayout(layout updateLayout, syncer DirectorySyncer) error {
}
}
// recoverPrepared reconciles the only phase where the durable journal may be
// present while the first rename was interrupted between Rename and its
// directory fence. The physical managed layout is authoritative here: blindly
// removing the journal would strand the old app in backup after such a fence
// failure.
func recoverPrepared(layout updateLayout, syncer DirectorySyncer) error {
targetExists, err := realDirectoryState(layout.target, "target")
if err != nil {
return err
}
backupExists, err := realDirectoryState(layout.backup, "backup")
if err != nil {
return err
}
switch {
case targetExists && !backupExists:
return removeTransaction(layout, syncer)
case !targetExists && backupExists:
if err := restoreBackup(layout, syncer); err != nil {
return err
}
return removeTransaction(layout, syncer)
default:
return fmt.Errorf("%w: prepared transaction has target=%t backup=%t", ErrRecoveryRequired, targetExists, backupExists)
}
}
func rollbackLayout(layout updateLayout, syncer DirectorySyncer) error {
backupExists, err := realDirectoryState(layout.backup, "backup")
if err != nil || !backupExists {
@@ -57,11 +84,11 @@ func rollbackLayout(layout updateLayout, syncer DirectorySyncer) error {
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)
return fmt.Errorf("%w: %w", ErrRecoveryRequired, err)
}
}
if err := renameDirectory(layout.backup, layout.target, layout, syncer, "restore previous app"); err != nil {
return fmt.Errorf("%w: %v", ErrRecoveryRequired, err)
return fmt.Errorf("%w: %w", ErrRecoveryRequired, err)
}
if err := removeTransaction(layout, syncer); err != nil {
return err
@@ -87,7 +114,7 @@ func restoreBackup(layout updateLayout, syncer DirectorySyncer) error {
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 fmt.Errorf("%w: %w", ErrRecoveryRequired, err)
}
return nil
}
+7 -3
View File
@@ -130,7 +130,7 @@ func (service *Service) Update(ctx context.Context, request Request) error {
return err
}
if err := renameDirectory(layout.target, layout.backup, layout, service.syncer, "back up current app"); err != nil {
return service.failBeforeActivation(err)
return service.failBeforeActivation(layout, err)
}
if err := writeTransaction(layout, phaseTargetBackedUp, service.syncer); err != nil {
return service.rollback(layout, err)
@@ -172,8 +172,12 @@ func (service *Service) Update(ctx context.Context, request Request) error {
return nil
}
func (service *Service) failBeforeActivation(cause error) error {
return fmt.Errorf("prepare self-update: %w", cause)
func (service *Service) failBeforeActivation(layout updateLayout, cause error) error {
prepareErr := fmt.Errorf("prepare self-update: %w", cause)
if recoveryErr := recoverLayout(layout, service.syncer); recoveryErr != nil {
return errors.Join(prepareErr, recoveryErr)
}
return prepareErr
}
func (service *Service) rollback(layout updateLayout, cause error) error {
+252 -17
View File
@@ -11,9 +11,15 @@ import (
const testRequestID = "update-1234"
type testSyncer struct{ err error }
type testSyncer struct {
calls int
fails map[int]error
}
func (syncer testSyncer) SyncDirectory(string) error { return syncer.err }
func (syncer *testSyncer) SyncDirectory(string) error {
syncer.calls++
return syncer.fails[syncer.calls]
}
type testWaiter struct {
err error
@@ -41,11 +47,17 @@ func (health testHealth) WaitForHealth(context.Context, string, string, time.Dur
return health.err
}
type testHealthFunc func(context.Context, string, string, time.Duration) error
func (function testHealthFunc) WaitForHealth(ctx context.Context, target, requestID string, timeout time.Duration) error {
return function(ctx, target, requestID, timeout)
}
func TestUpdateActivatesOnlyFixedLayoutAfterHealth(t *testing.T) {
request, root := testRequest(t)
waiter := &testWaiter{}
launcher := &testLauncher{}
service := NewService(waiter, launcher, testSyncer{}, testHealth{}, Timeouts{})
service := NewService(waiter, launcher, &testSyncer{}, testHealth{}, Timeouts{})
if err := service.Update(context.Background(), request); err != nil {
t.Fatalf("Update() error = %v", err)
@@ -81,7 +93,7 @@ func TestUpdateActivatesOnlyFixedLayoutAfterHealth(t *testing.T) {
func TestUpdateRestoresOldAppWhenHealthFails(t *testing.T) {
request, root := testRequest(t)
service := NewService(&testWaiter{}, &testLauncher{}, testSyncer{}, testHealth{err: ErrHealthTimeout}, Timeouts{})
service := NewService(&testWaiter{}, &testLauncher{}, &testSyncer{}, testHealth{err: ErrHealthTimeout}, Timeouts{})
err := service.Update(context.Background(), request)
if !errors.Is(err, ErrHealthTimeout) {
@@ -101,7 +113,7 @@ func TestUpdateRestoresOldAppWhenHealthFails(t *testing.T) {
func TestUpdateDoesNotTouchLayoutWhenParentWaitFails(t *testing.T) {
request, root := testRequest(t)
waitErr := errors.New("permission denied")
service := NewService(&testWaiter{err: waitErr}, &testLauncher{}, testSyncer{}, testHealth{}, Timeouts{})
service := NewService(&testWaiter{err: waitErr}, &testLauncher{}, &testSyncer{}, testHealth{}, Timeouts{})
err := service.Update(context.Background(), request)
if !errors.Is(err, ErrParentWait) || !errors.Is(err, waitErr) {
@@ -122,7 +134,7 @@ 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{})
service := NewService(waiter, &testLauncher{}, &testSyncer{}, testHealth{}, Timeouts{})
err := service.Update(context.Background(), request)
if !errors.Is(err, ErrUnsafeLayout) {
@@ -148,7 +160,7 @@ func TestUpdateRejectsStagingSymlinkWithoutWaiting(t *testing.T) {
t.Skipf("symlink unavailable: %v", err)
}
waiter := &testWaiter{}
service := NewService(waiter, &testLauncher{}, testSyncer{}, testHealth{}, Timeouts{})
service := NewService(waiter, &testLauncher{}, &testSyncer{}, testHealth{}, Timeouts{})
err := service.Update(context.Background(), request)
if !errors.Is(err, ErrUnsafeLayout) {
@@ -165,16 +177,13 @@ func TestRecoverRestoresTargetBackedUpTransaction(t *testing.T) {
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 {
if err := writeTransaction(layout, phaseTargetBackedUp, &testSyncer{}); err != nil {
t.Fatal(err)
}
if err := Recover(request.TargetDir, testSyncer{}); err != nil {
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" {
@@ -191,21 +200,21 @@ func TestAcknowledgeHealthFromExecutableUsesFixedLocator(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := writeTransaction(layout, phaseStagingActivated, testSyncer{}); err != nil {
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 {
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) {
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) {
if err := AcknowledgeHealthFromExecutable(executable, "other-1234", &testSyncer{}); !errors.Is(err, ErrHealthInvalid) {
t.Fatalf("unmatched acknowledgement error = %v, want ErrHealthInvalid", err)
}
}
@@ -219,10 +228,236 @@ func TestFileHealthWaiterRejectsWrongRequestID(t *testing.T) {
}
}
func TestUpdateRepairsPreparedRenameSyncFailure(t *testing.T) {
request, root := testRequest(t)
syncErr := errors.New("sync root after backup rename")
syncer := &testSyncer{fails: map[int]error{2: syncErr}}
service := NewService(&testWaiter{}, &testLauncher{}, syncer, testHealth{}, Timeouts{})
err := service.Update(context.Background(), request)
if !errors.Is(err, syncErr) {
t.Fatalf("Update() error = %v, want sync error", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "old")
assertFileContents(t, filepath.Join(root, "staging", testRequestID, ProductExecutableName), "new")
assertNotExists(t, filepath.Join(root, transactionFileName))
}
func TestRecoverPreparedRestoresBackupAfterInterruptedRename(t *testing.T) {
request, root := testRequest(t)
layout, err := inspectRequest(request)
if err != nil {
t.Fatal(err)
}
if err := writeTransaction(layout, phasePrepared, &testSyncer{}); err != nil {
t.Fatal(err)
}
if err := os.Rename(layout.target, layout.backup); err != nil {
t.Fatal(err)
}
if err := Recover(request.TargetDir, &testSyncer{}); err != nil {
t.Fatalf("Recover() error = %v", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "old")
assertNotExists(t, filepath.Join(root, transactionFileName))
}
func TestRecoverPreparedRejectsContradictoryTargetAndBackup(t *testing.T) {
request, _ := testRequest(t)
layout, err := inspectRequest(request)
if err != nil {
t.Fatal(err)
}
if err := os.Mkdir(layout.backup, 0o700); err != nil {
t.Fatal(err)
}
if err := writeTransaction(layout, phasePrepared, &testSyncer{}); err != nil {
t.Fatal(err)
}
err = Recover(request.TargetDir, &testSyncer{})
if !errors.Is(err, ErrRecoveryRequired) {
t.Fatalf("Recover() error = %v, want ErrRecoveryRequired", err)
}
if _, err := os.Lstat(layout.transaction); err != nil {
t.Fatalf("prepared transaction was removed: %v", err)
}
}
func TestRecoverPreparedRetainsJournalWhenBackupRestoreFenceFails(t *testing.T) {
request, root := testRequest(t)
layout, err := inspectRequest(request)
if err != nil {
t.Fatal(err)
}
if err := writeTransaction(layout, phasePrepared, &testSyncer{}); err != nil {
t.Fatal(err)
}
if err := os.Rename(layout.target, layout.backup); err != nil {
t.Fatal(err)
}
syncErr := errors.New("sync restored target")
err = Recover(request.TargetDir, &testSyncer{fails: map[int]error{1: syncErr}})
if !errors.Is(err, ErrRecoveryRequired) || !errors.Is(err, syncErr) {
t.Fatalf("Recover() error = %v, want recovery and sync errors", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "old")
if _, err := os.Lstat(layout.transaction); err != nil {
t.Fatalf("transaction missing after failed restore fence: %v", err)
}
if err := Recover(request.TargetDir, &testSyncer{}); err != nil {
t.Fatalf("Recover() retry error = %v", err)
}
assertNotExists(t, layout.transaction)
}
func TestUpdatePreservesRecoveryMaterialWhenHealthRollbackIsBlocked(t *testing.T) {
request, root := testRequest(t)
blockedStaging := filepath.Join(root, stagingDirectoryName, testRequestID)
health := testHealthFunc(func(context.Context, string, string, time.Duration) error {
if err := os.Mkdir(blockedStaging, 0o700); err != nil {
return err
}
return ErrHealthTimeout
})
service := NewService(&testWaiter{}, &testLauncher{}, &testSyncer{}, health, Timeouts{})
err := service.Update(context.Background(), request)
if !errors.Is(err, ErrHealthTimeout) || !errors.Is(err, ErrRecoveryRequired) {
t.Fatalf("Update() error = %v, want health and recovery errors", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "new")
assertFileContents(t, filepath.Join(root, "backups", testRequestID, ProductExecutableName), "old")
if _, err := os.Lstat(filepath.Join(root, transactionFileName)); err != nil {
t.Fatalf("transaction missing after blocked rollback: %v", err)
}
if err := os.RemoveAll(blockedStaging); err != nil {
t.Fatal(err)
}
if err := Recover(request.TargetDir, &testSyncer{}); err != nil {
t.Fatalf("Recover() after unblock error = %v", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "old")
assertFileContents(t, filepath.Join(root, "staging", testRequestID, ProductExecutableName), "new")
}
func TestRecoverStagingActivatedRollsBackWithoutHealth(t *testing.T) {
request, layout, root := activatedLayout(t)
if err := writeTransaction(layout, phaseStagingActivated, &testSyncer{}); err != nil {
t.Fatal(err)
}
if err := Recover(request.TargetDir, &testSyncer{}); err != nil {
t.Fatalf("Recover() error = %v", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "old")
assertFileContents(t, filepath.Join(root, "staging", testRequestID, ProductExecutableName), "new")
}
func TestRecoverLaunchedFinalizesMatchingHealth(t *testing.T) {
request, layout, root := activatedLayout(t)
if err := writeTransaction(layout, phaseLaunched, &testSyncer{}); err != nil {
t.Fatal(err)
}
if err := AcknowledgeHealthFromExecutable(filepath.Join(root, "app", ProductExecutableName), testRequestID, &testSyncer{}); err != nil {
t.Fatal(err)
}
if err := Recover(request.TargetDir, &testSyncer{}); err != nil {
t.Fatalf("Recover() error = %v", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "new")
assertNotExists(t, filepath.Join(root, "backups", testRequestID))
assertNotExists(t, filepath.Join(root, transactionFileName))
}
func TestRecoverCommittedRetriesCleanupAfterSyncFailure(t *testing.T) {
request, layout, root := activatedLayout(t)
if err := writeTransaction(layout, phaseCommitted, &testSyncer{}); err != nil {
t.Fatal(err)
}
syncErr := errors.New("sync backup parent after cleanup")
err := Recover(request.TargetDir, &testSyncer{fails: map[int]error{1: syncErr}})
if !errors.Is(err, syncErr) {
t.Fatalf("Recover() error = %v, want cleanup sync error", err)
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "new")
assertNotExists(t, filepath.Join(root, "backups", testRequestID))
if _, err := os.Lstat(filepath.Join(root, transactionFileName)); err != nil {
t.Fatalf("transaction missing before cleanup retry: %v", err)
}
if err := Recover(request.TargetDir, &testSyncer{}); err != nil {
t.Fatalf("Recover() retry error = %v", err)
}
assertNotExists(t, filepath.Join(root, transactionFileName))
}
func TestRecoverPreparedTransactionAfterDirectorySyncFailure(t *testing.T) {
request, _ := testRequest(t)
layout, err := inspectRequest(request)
if err != nil {
t.Fatal(err)
}
syncErr := errors.New("sync prepared transaction")
if err := writeTransaction(layout, phasePrepared, &testSyncer{fails: map[int]error{1: syncErr}}); !errors.Is(err, syncErr) {
t.Fatalf("writeTransaction() error = %v, want sync error", err)
}
if err := Recover(request.TargetDir, &testSyncer{}); err != nil {
t.Fatalf("Recover() error = %v", err)
}
assertNotExists(t, layout.transaction)
}
func TestUpdateRejectsPreparedTransactionWriteFailureWithoutMovingApp(t *testing.T) {
request, root := testRequest(t)
if err := os.Mkdir(filepath.Join(root, transactionFileName), 0o700); err != nil {
t.Fatal(err)
}
service := NewService(&testWaiter{}, &testLauncher{}, &testSyncer{}, testHealth{}, Timeouts{})
err := service.Update(context.Background(), request)
if err == nil {
t.Fatal("Update() succeeded with a non-regular transaction target")
}
assertFileContents(t, filepath.Join(root, "app", ProductExecutableName), "old")
assertFileContents(t, filepath.Join(root, "staging", testRequestID, ProductExecutableName), "new")
}
func activatedLayout(t *testing.T) (Request, updateLayout, string) {
t.Helper()
request, root := testRequest(t)
layout, err := inspectRequest(request)
if err != nil {
t.Fatal(err)
}
if err := os.Rename(layout.target, layout.backup); err != nil {
t.Fatal(err)
}
if err := os.Rename(layout.staging, layout.target); err != nil {
t.Fatal(err)
}
return request, layout, root
}
func assertFileContents(t *testing.T, path, want string) {
t.Helper()
if got := readFile(t, path); got != want {
t.Fatalf("%s = %q, want %q", path, got, want)
}
}
func assertNotExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("%s exists or could not be inspected: %v", path, err)
}
}
func testRequest(t *testing.T) (Request, string) {
t.Helper()
root := t.TempDir()
for _, directory := range []string{"app", filepath.Join("staging", testRequestID), "data", "licenses"} {
for _, directory := range []string{"app", filepath.Join("staging", testRequestID), "backups", "data", "licenses"} {
if err := os.MkdirAll(filepath.Join(root, directory), 0o700); err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
## 当前阶段
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已完成子软件更新编排;T-403 已完成受限 SoftBoxUpdater、自身 EXE 健康确认与可恢复切换;T-616 已完成 production cmd 的 Catalog 快照事件投递和明确空态诊断。T-617 已正式落成,用于修复 Phase 4 审核发现的 prepared rename+sync 恢复不一致并补足自更新恢复测试。物理断电、文件锁与杀毒软件干扰验证保留到 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-616 已完成 production cmd 的 Catalog 快照事件投递和明确空态诊断;T-617 已修复 prepared rename+sync 后的自更新恢复不一致并补足无头故障恢复/health flag 测试。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
优先路径:
@@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已完成关闭确认、自然退出等待和更新编排;T-403 已完成 SoftBoxUpdater 的受限 transaction、PID 自然退出、固定健康启动与恢复;T-616 已完成 Catalog 快照启动投递与明确空态诊断。T-617 已正式落成,当前执行自更新恢复状态机修复与测试整改;完成后再按路线图正式落成 Phase 5 的 T-501。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 自然退出、固定健康启动与恢复;T-616 已完成 Catalog 快照启动投递与明确空态诊断;T-617 已完成 prepared rename+sync 的恢复修复、五 phase Recover 与双端 health flag 参数拒绝测试。下一步按路线图正式落成 Phase 5 的 T-501。T-601 仍须补真实 Windows 环境的断电/干扰注入。
## 领取任务规则
+1 -1
View File
@@ -188,7 +188,7 @@ T-613 已把该状态机的代码层耐久顺序收敛为:payload 的 CRC/长度
真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-601 在 Windows VM/真机做故障注入;T-302 的代码级链路与单元测试不替代硬件级断电验证。
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 链继续后置。
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。T-617 固定 `prepared` 的恢复依据为经 `Lstat` 验证的实际 target/backup 拓扑:仅 target 存在且 backup 不存在才删除 journal;target 缺失且 managed backup 存在必须先 restore;其余组合保留 journal/材料并 fail closed。因此首次 `app → backup` rename 已完成但目录 sync 失败时,当前调用会立即尝试该受限收敛,下一次 Recover 也不会再盲删 journal。`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 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。
+1 -1
View File
@@ -107,7 +107,7 @@ Phase 3 的 T-301 → T-302 → T-303 是安全关键依赖链,任务之间保
#### Phase 4 交叉复核整改
T-617 先关闭 T-403 自更新的 `prepared` rename 后目录 sync 失败会留下 target 缺失/backup 存在而旧 Recover 删除 journal 的恢复一致性缺陷,并补齐可无头验证的故障注入、五个 transaction phase Recover 与双端 health CLI 参数拒绝测试。Windows 文件锁、杀毒和断电真机结论仍归 T-601;可信 Catalog/下载/许可证/自更新发布源装配另行拆任务。
T-617 已关闭 T-403 自更新的 `prepared` rename 后目录 sync 失败会留下 target 缺失/backup 存在而旧 Recover 删除 journal 的恢复一致性缺陷,并补齐可无头验证的故障注入、五个 transaction phase Recover 与双端 health CLI 参数拒绝测试。Windows 文件锁、杀毒和断电真机结论仍归 T-601;可信 Catalog/下载/许可证/自更新发布源装配另行拆任务。
| ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- |
+1 -1
View File
@@ -370,7 +370,7 @@ SoftBoxUpdater.exe --pid <主程序PID> --staging <暂存目录> --target <目
这是受约束的本地激活助手,**不是**自更新下载或签名校验入口。`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/`,也不会强杀进程。
助手先等待正 PID 的旧主进程自然退出(OpenProcess/等待失败、取消或超时均不把它当成已退出),再恢复遗留 transaction 并执行 `prepared → target_backed_up → staging_activated → launched → committed`。它只允许 `app → backups/<request-id>`、`staging/<request-id> → app` 两次受限 rename,并以原子 JSON transaction 和目录同步建立崩溃恢复栅栏;`prepared` journal 不能单独决定“尚未移动”:Recover 必须检查真实的 managed target/backup。仅 target 存在且 backup 不存在才可删除 prepared journal;target 缺失且 backup 存在必须先 restore,其他组合保留 journal/材料并返回恢复失败。因而首次 rename 已完成但目录 sync 失败时,调用会先尝试受限恢复,后续 Recover 也不会丢弃旧 app 的唯一恢复证据。它不会写 `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 表示未提交或恢复材料仍需处理。
+5 -5
View File
@@ -13,13 +13,13 @@
## 当前快照
- 日期:2026-07-19
- 阶段:Phase 2 已完成(T-201~T-204)并由 T-616 补齐启动 Catalog 快照投递/空态诊断;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 受限盒子自更新事务已完成;T-617 已正式落成,正在整改 prepared rename+sync 恢复不一致与自更新测试覆盖
- 阶段:Phase 2 已完成(T-201~T-204)并由 T-616 补齐启动 Catalog 快照投递/空态诊断;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 受限盒子自更新事务及 T-617 恢复状态机/测试整改已完成
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
- T-402 更新用例:`core/application/update` 只接收外层可信的 install selection,验证已装版本/旧 entrypoint后,运行中才请求关闭确认并以 1 秒~10 分钟上限等待自然退出,随后委托已有 `InstallService` 的双重运行复查与 rollback;取消、超时、Toolhelp 检测错误和安装错误均保留稳定 code/错误链,不强杀且不改 `data/`、`licenses/`。两端 platform 对齐 `WaitForExit` 契约,Windows 固定短轮询完整路径,非 Windows 返回明确不支持。
- T-403 自更新:`core/updater` 只接受正 PID、真实 `<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 触发器。
- T-616 启动 Catalog:`core/application.CatalogBootstrap` 只消费 composition 注入的已验证、目标过滤内存快照,以深拷贝 `CatalogRefreshed`/稳定 `CatalogRejected` payload 经现有 runtime/relay 交给 UI Frame;双端 shell 在 UI goroutine 更新 `SetItems` 并明确显示 loading、未配置、加载失败、已加载空目录与筛选空结果。当前 cmd 有意注入 `UnconfiguredCatalogLoader`,故 `dist/SoftBox.exe` 不会显示 fake 软件,而会显示“Catalog 来源尚未配置”;可信 URL/公钥/缓存 composition 仍待发布配置。
- T-617 自更新整改(执行中):Phase 4 复核发现 `prepared` journal 写入后,`app → backups/<request-id>` rename 已成功但目录 sync 失败时,旧恢复逻辑会删除 journal 而不恢复 backup。T-617 必须让实际 target/backup 拓扑驱动受限恢复,并补 durability/cleanup 失败、五 phase Recover 与双端 health flag 参数拒绝测试;Windows 真机锁、杀毒和断电仍待 T-601。
- T-617 自更新整改:`prepared` journal 不再单独决定是否移动;Recover 仅在 target 存在且 managed backup 缺失时删 journal,target 缺失且 backup 存在时先 restore,其余拓扑 fail closed 并保留材料。首次 backup rename 后目录 sync 错误立即走相同受限恢复路径,恢复包装保留 `ErrRecoveryRequired` 和底层 durability 根因。覆盖 prepared/target_backed_up/staging_activated/launched/committed、transaction/rename/sync/cleanup 和 health timeout 阻塞后 Recover;双端 health flag 的缺 ID/多余参数均被拒绝。Windows 真机锁、杀毒和断电仍待 T-601。
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
@@ -32,7 +32,7 @@
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-616、T-401~T-403 已完成;T-617 正在执行 |
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-617、T-401~T-403 已完成 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
@@ -44,8 +44,8 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204` 与 `T-616`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`~`T-403`。
- 正在进行:T-617(依赖 T-403 已完成);完成、验证并提交后,再按路线图正式落成 Phase 5 的 T-501。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204` 与 `T-616`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-617`;Phase 4 的 `T-401`~`T-403`。
- 正在进行:无;下一步可按路线图正式落成 Phase 5 的 T-501。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
## 当前可运行内容
+5 -5
View File
@@ -7,7 +7,7 @@
## 总体判断
**Phase 4 的启动/更新安全设计质量高,未发现可证实的命令注入、强杀或越界移动安全缺陷;自更新恢复状态机存在一项须由 T-617 修复的 durability 后状态不一致。** 三个最敏感的点都做对了:**启动无命令注入面**(调用方给不了路径/参数/工作目录,平台层无 shell、无用户参数、绝对路径)、**更新绝不强杀**(只请求关闭 + 等自然退出)、**自更新健康确认前不删 backup**。同时 **Phase 3 审查的 O1(切换前不复查运行状态)已被 T-401 闭合**。
**Phase 4 的启动/更新安全设计质量高,未发现可证实的命令注入、强杀或越界移动安全缺陷;T-617 已修复自更新 prepared rename+sync 后的 durability 状态不一致。** 三个最敏感的点都做对了:**启动无命令注入面**(调用方给不了路径/参数/工作目录,平台层无 shell、无用户参数、绝对路径)、**更新绝不强杀**(只请求关闭 + 等自然退出)、**自更新健康确认前不删 backup**。同时 **Phase 3 审查的 O1(切换前不复查运行状态)已被 T-401 闭合**。
不过,补充全栈复核发现 T-403 的自动化故障覆盖低于任务验收所要求的范围。因此 O1 不能仅视为普通“优化项”:Windows 真机锁语义仍属于 T-601,但可在无头环境验证的失败注入、崩溃 phase 和恢复契约应作为后续整改的高优先级测试项。O2、O3 仍是准确的架构/装配边界说明。
@@ -80,7 +80,7 @@
**建议:**单列整改任务,先为 updater 的文件操作和目录同步提供最小失败注入 seam,补齐健康超时→rollback 被锁阻塞→后续 `Recover`、各 transaction phase 崩溃恢复、journal/rename/sync/cleanup 失败与 health CLI 参数拒绝的双端测试。真实 Windows 文件锁、杀毒软件和断电结果仍只由 T-601 真机/VM 验证,不以单元测试替代。
**追加状态机发现:**`prepared` journal 成功写入后,`app → backups/<request-id>` 的 `os.Rename` 可能已经完成,而 `renameDirectory` 在随后的目录 sync 返回错误。当前 `Update` 直接返回 `failBeforeActivation`;磁盘成为 target 缺失、managed backup 存在、journal 仍为 `prepared`。现有 `recoverLayout` 对 `prepared` 无条件删除 journal,因而不会把 old app 恢复到 target。该情形不是 Windows 真机锁的未知行为,而是可由既有 `DirectorySyncer` seam 在无头测试复现的恢复一致性缺陷。T-617 必须修复此拓扑判断并覆盖即时/下次恢复;若恢复仍失败,必须保留 journal/backup 与 `ErrRecoveryRequired`。
**追加状态机发现(已由 T-617 关闭):**`prepared` journal 成功写入后,`app → backups/<request-id>` 的 `os.Rename` 可能已经完成,而 `renameDirectory` 在随后的目录 sync 返回错误。旧 `Update` 直接返回,磁盘成为 target 缺失、managed backup 存在、journal 仍为 `prepared`;旧 `recoverLayout` 又无条件删除 journal。T-617 现按真实 managed target/backup 拓扑恢复,并在首次错误时立即走同一受限路径;恢复包装同时保留 `ErrRecoveryRequired` 与底层 sync/rename 原因。无头测试覆盖即时/下次恢复和恢复 fence 再失败,真实 Windows 锁仍待 T-601。
### O5 · 端到端装配依赖应拆开跟踪
@@ -100,7 +100,7 @@
Phase 4 的核心安全属性(启动无注入面、更新不强杀、自更新可回滚且健康门控、切换临界区复查闭合 Phase 3 O1、Windows 隔离 + fail-closed stub)全部到位,失败码体系完整。建议处理顺序:
1. **[最高优先级 · 恢复修复与测试]** O1/O4:T-617 先修 prepared rename+sync 后 target/backup 与 journal 不一致,再补自更新健康超时后的 Recover/锁阻塞恢复、新进程健康门禁、transaction phase 崩溃恢复与 journal/rename/sync/cleanup 失败注入测试;真机锁语义归 T-601。
1. **[已关闭 · T-617]** O1/O4:prepared rename+sync 后 target/backup 与 journal 不一致已修复;已补自更新 health timeout 后结构性阻塞再 Recover、五 transaction phase、journal/rename/sync/cleanup 失败注入和双端 health 参数拒绝测试。真实 Windows 锁语义仍归 T-601。
2. **[准确性 · 装配规划]** O2/O5:文档保持"无头 core 已完成、端到端未接通"表述;可信 Catalog/下载消费、T-502 授权和自更新发布源分别落地,不笼统等待单一任务。
3. **[后续]** O3:T-502 接入真实授权策略并补端到端授权测试。
@@ -128,7 +128,7 @@ Phase 4 的核心安全属性(启动无注入面、更新不强杀、自更新
### 采纳
- **接受 O4**,并同意其定级:这是**测试覆盖缺口,不是已证实的实现缺陷**(代码在锁阻塞时 fail closed、保留 journal/backup、不强杀)。但缺少故障注入与中断 phase 恢复测试,意味着该状态机的持久化边界行为"构造上正确"而**未被测试证明**,且后续回归无法拦截。对自更新器(失效即"盒子自己更新不动")值得列高优先级。
- **T-617 追加发现(2026-07-19):**prepared journal 后首次 rename 已完成但 directory sync 失败时,实际 target/backup 拓扑与 `phasePrepared` 的旧恢复假设不一致;`Recover` 不能再无条件删除该 journal。此项是由既有 `DirectorySyncer` seam 可复现的恢复正确性缺陷,与 O4 的测试覆盖缺口不同;T-617 需作最小状态机修复并以故障测试证明。它不推翻原有“无命令注入、不强杀、无越界移动”的安全裁定。
- **T-617 追加发现(2026-07-19,已关闭):**prepared journal 后首次 rename 已完成但 directory sync 失败时,实际 target/backup 拓扑与 `phasePrepared` 的旧恢复假设不一致;`Recover` 不能再无条件删除该 journal。T-617 已以最小状态机修复和 DirectorySyncer 故障测试关闭此恢复正确性缺陷;它不推翻原有“无命令注入、不强杀、无越界移动”的安全裁定。
- **接受 O5** 对原 O2 的细化:装配前置不应笼统归为"等 T-502"。可信 Catalog 发布配置、签名清单加载/缓存/目标过滤、下载完成文件受控消费可在 T-502 前独立接入;T-502 只决定授权门禁;自更新另缺可信包下载/验签/版本选择/UI 触发器。按四条分别落任务与验收。
- **接受验证段**:`go test -count=10` 对并发/恢复代码是恰当的抗 flakiness 实践;`-race` 因 `CGO_ENABLED` 禁用无法运行并如实声明"不得表述为 race detector 已通过",该诚实度正确。
@@ -146,4 +146,4 @@ Phase 4 的核心安全属性(启动无注入面、更新不强杀、自更新
3. **[后续]** O3:T-502 接入真实授权策略并补端到端授权测试。
4. **[真机]** T-601:Windows 文件锁 / 杀毒软件 / 断电故障注入,不由单元测试替代。
> 裁定:Codex 本轮补充(O4 自查、O5 细化、验证段)全部成立;原审核在测试覆盖维度不如本轮严谨,予以采纳。随后发现的 prepared rename+sync 恢复不一致由 T-617 进行最小定向修复;Phase 4 安全总结论(无注入面、不强杀、健康门控、Phase 3 O1 已闭合)仍成立,不需要回滚既有 Phase 4 实现或扩大范围返工。
> 裁定:Codex 本轮补充(O4 自查、O5 细化、验证段)全部成立;原审核在测试覆盖维度不如本轮严谨,予以采纳。随后发现的 prepared rename+sync 恢复不一致已由 T-617 最小定向修复;Phase 4 安全总结论(无注入面、不强杀、健康门控、Phase 3 O1 已闭合)仍成立,不需要回滚既有 Phase 4 实现或扩大范围返工。
+6 -3
View File
@@ -3,12 +3,12 @@ id: T-617
title: 自更新事务恢复与健康门禁加固
phase: 4
deps: [T-403]
status: TODO
status: DONE
created: 2026-07-19
issue: null
context_ref: null
context_ref: ea1d6c83742eb882f28a9a4dbe18382aedb15e78
claim_branch: null
work_branch: null
work_branch: agent/codex/T-617
write_paths:
- docs/tasks/T-617.md
- core/updater/updater.go
@@ -60,3 +60,6 @@ write_paths:
## 执行记录
- 2026-07-19:正式落成。根据 Phase 4 审核 O1/O4 及补充状态机复核,冻结“prepared rename 后 sync 失败必须恢复/保留、五 phase Recover、故障错误链与双端 health flag 参数拒绝”的最小整改闭环;真实 Windows 锁与发布侧装配继续后置。
- 2026-07-19:领取任务,基于 `ea1d6c83742eb882f28a9a4dbe18382aedb15e78` 在 `agent/codex/T-617` 执行;先重跑基线,再修复 prepared durability 后的恢复状态机并补测试。
- 2026-07-19:完成。`prepared` Recover 现按经 `Lstat` 验证的 target/backup 实际拓扑收敛:target 存在且 backup 缺失才删 journal,target 缺失且 managed backup 存在先 restore,其余组合保留材料并返回 `ErrRecoveryRequired`;首次 backup rename 的目录 sync 错误会立即走同一恢复路径。rollback/restore 的恢复码与底层 rename/sync 根因均用 Go 1.20 多重 `%w` 保留。新增可计数 `DirectorySyncer` 失败测试,覆盖 prepared rename+sync 即时/下次恢复、prepared 矛盾布局/restore fence、health timeout 后 rollback 阻塞再 Recover、五个持久 phase、transaction 写入/同步和 committed cleanup sync;双端 `cmd/softbox` 新增内部 health flag 缺 ID/多余参数拒绝测试。未伪造真实 Windows EXE 锁、杀毒或断电结论。
- 2026-07-19:验证通过:`./init.ps1`、`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./updater`、modern/Win7 `go test -count=1 ./...`、双端 Windows amd64 `softbox`/`softboxupdater` 构建、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与 `git diff --check`。