From 20596a7de424979d8bd959c89bbb9428c1086526 Mon Sep 17 00:00:00 2001 From: ila Date: Sat, 18 Jul 2026 16:28:49 +0800 Subject: [PATCH] Fence installation transaction durability (T-613) --- core/installer/durability.go | 99 +++++++ core/installer/durability_other.go | 23 ++ core/installer/durability_test.go | 334 ++++++++++++++++++++++ core/installer/durability_windows.go | 35 +++ core/installer/durability_windows_test.go | 11 + core/installer/extractor.go | 24 +- core/installer/layout.go | 10 +- core/installer/recovery.go | 54 ++-- core/installer/switcher.go | 84 ++++-- core/installer/transaction.go | 43 ++- docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 4 +- docs/05-coding-rules.md | 1 + docs/06-tasks.md | 2 +- docs/api.md | 4 + docs/current-state.md | 14 +- docs/review/phase1-security-review.md | 9 +- docs/tasks/T-613.md | 6 +- 18 files changed, 697 insertions(+), 64 deletions(-) create mode 100644 core/installer/durability.go create mode 100644 core/installer/durability_other.go create mode 100644 core/installer/durability_test.go create mode 100644 core/installer/durability_windows.go create mode 100644 core/installer/durability_windows_test.go diff --git a/core/installer/durability.go b/core/installer/durability.go new file mode 100644 index 0000000..06c311c --- /dev/null +++ b/core/installer/durability.go @@ -0,0 +1,99 @@ +package installer + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" +) + +var ErrDurability = errors.New("install durability fence failed") + +type durabilityFence interface { + syncFile(file *os.File) error + syncDirectory(path string) error +} + +type filesystemDurability struct{} + +func (filesystemDurability) syncFile(file *os.File) error { + return file.Sync() +} + +func (filesystemDurability) syncDirectory(path string) error { + return syncDirectoryPath(path) +} + +func defaultDurability() durabilityFence { + return filesystemDurability{} +} + +func effectiveDurability(fence durabilityFence) durabilityFence { + if fence == nil { + return defaultDurability() + } + return fence +} + +func syncFileWithFence(fence durabilityFence, file *os.File, description string) error { + if err := effectiveDurability(fence).syncFile(file); err != nil { + return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err) + } + return nil +} + +func syncDirectoryWithFence(fence durabilityFence, path, description string) error { + if err := effectiveDurability(fence).syncDirectory(path); err != nil { + return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err) + } + return nil +} + +func syncStagingTree(fence durabilityFence, root string) error { + directories := make([]string, 0) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("%w: staging tree contains a symbolic link", ErrUnsafeInstallLayout) + } + if entry.IsDir() { + directories = append(directories, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("%w: walk staging tree: %w", ErrDurability, err) + } + sort.Slice(directories, func(left, right int) bool { + return len(directories[left]) > len(directories[right]) + }) + for _, directory := range directories { + if err := syncDirectoryWithFence(fence, directory, "staging directory"); err != nil { + return err + } + } + parent := filepath.Dir(root) + if parent != root { + if err := syncDirectoryWithFence(fence, parent, "staging parent directory"); err != nil { + return err + } + } + return nil +} + +func renameManagedDirectory( + layout appLayout, + source string, + target string, + fence durabilityFence, + description string, +) error { + if err := os.Rename(source, target); err != nil { + return err + } + return syncDirectoryWithFence(fence, layout.root, description) +} diff --git a/core/installer/durability_other.go b/core/installer/durability_other.go new file mode 100644 index 0000000..0d5d960 --- /dev/null +++ b/core/installer/durability_other.go @@ -0,0 +1,23 @@ +//go:build !windows + +package installer + +import ( + "fmt" + "os" +) + +func syncDirectoryPath(path string) error { + directory, err := os.Open(path) + if err != nil { + return fmt.Errorf("open directory: %w", err) + } + if err := directory.Sync(); err != nil { + _ = directory.Close() + return fmt.Errorf("sync directory: %w", err) + } + if err := directory.Close(); err != nil { + return fmt.Errorf("close directory: %w", err) + } + return nil +} diff --git a/core/installer/durability_test.go b/core/installer/durability_test.go new file mode 100644 index 0000000..9301e83 --- /dev/null +++ b/core/installer/durability_test.go @@ -0,0 +1,334 @@ +package installer + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +type durabilityEvent struct { + kind string + path string +} + +type recordingDurabilityFence struct { + events []durabilityEvent + fail func(durabilityEvent) error + failNextDirectory bool +} + +func (fence *recordingDurabilityFence) syncFile(file *os.File) error { + return fence.record(durabilityEvent{kind: "file", path: file.Name()}) +} + +func (fence *recordingDurabilityFence) syncDirectory(path string) error { + event := durabilityEvent{kind: "directory", path: path} + if fence.failNextDirectory { + fence.failNextDirectory = false + fence.events = append(fence.events, event) + return errors.New("injected directory fence failure") + } + return fence.record(event) +} + +func (fence *recordingDurabilityFence) record(event durabilityEvent) error { + fence.events = append(fence.events, event) + if fence.fail != nil { + return fence.fail(event) + } + return nil +} + +func TestExtractorDurabilityFencesPayloadThenStagingTree(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{"entrypoint":"bin/nested/App.exe"}`)}, + {name: "payload/bin/", mode: os.ModeDir | 0o755}, + {name: "payload/bin/nested/", mode: os.ModeDir | 0o755}, + {name: "payload/bin/nested/App.exe", body: []byte("executable"), mode: 0o755}, + {name: "payload/readme.txt", body: []byte("readme")}, + }) + destination := filepath.Join(t.TempDir(), "staging") + fence := &recordingDurabilityFence{} + extractor := mustExtractor(t, testLimits()) + extractor.durability = fence + + if _, err := extractor.ExtractFile( + archivePath, + destination, + "bin/nested/App.exe", + archiveSize(t, archivePath), + ); err != nil { + t.Fatalf("ExtractFile() error = %v", err) + } + + want := []durabilityEvent{ + {kind: "file", path: filepath.Join(destination, "bin", "nested", "App.exe")}, + {kind: "file", path: filepath.Join(destination, "readme.txt")}, + {kind: "directory", path: filepath.Join(destination, "bin", "nested")}, + {kind: "directory", path: filepath.Join(destination, "bin")}, + {kind: "directory", path: destination}, + {kind: "directory", path: filepath.Dir(destination)}, + } + assertDurabilityEvents(t, fence.events, want) +} + +func TestExtractorDurabilityFailuresRemoveStaging(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{"entrypoint":"App.exe"}`)}, + {name: "payload/App.exe", body: []byte("executable"), mode: 0o755}, + }) + + for _, test := range []struct { + name string + fail func(durabilityEvent) error + }{ + { + name: "payload sync", + fail: func(event durabilityEvent) error { + if event.kind == "file" { + return errors.New("injected payload sync failure") + } + return nil + }, + }, + { + name: "staging tree sync", + fail: func(event durabilityEvent) error { + if event.kind == "directory" { + return errors.New("injected staging tree sync failure") + } + return nil + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + destination := filepath.Join(t.TempDir(), "staging") + fence := &recordingDurabilityFence{fail: test.fail} + extractor := mustExtractor(t, testLimits()) + extractor.durability = fence + + _, err := extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) + if !errors.Is(err, ErrDurability) { + t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDurability) + } + assertMissing(t, destination) + }) + } +} + +func TestWriteTransactionFailsWhenJournalRootFenceFails(t *testing.T) { + root := t.TempDir() + layout, err := inspectAppLayout(root) + if err != nil { + t.Fatalf("inspectAppLayout() error = %v", err) + } + fence := &recordingDurabilityFence{fail: func(event durabilityEvent) error { + if event.kind == "directory" { + return errors.New("injected journal root fence failure") + } + return nil + }} + + err = writeTransactionWithFence(layout, newTransaction(phasePrepared, true), fence) + if !errors.Is(err, ErrDurability) { + t.Fatalf("writeTransactionWithFence() error = %v, want %v", err, ErrDurability) + } + if len(fence.events) != 2 || fence.events[0].kind != "file" || + fence.events[1] != (durabilityEvent{kind: "directory", path: root}) { + t.Fatalf("journal fences = %#v, want temporary file then app-root directory", fence.events) + } + if _, exists, err := loadTransaction(layout); err != nil || !exists { + t.Fatalf("loadTransaction() exists=%t error=%v, want prepared journal retained for recovery", exists, err) + } +} + +func TestSwitcherFencesEachPhaseBeforeAfterStep(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + fence := &recordingDurabilityFence{} + switcher := NewSwitcher(func(currentPath string) error { + assertVersion(t, currentPath, "new") + return nil + }) + switcher.durability = fence + switcher.afterStep = func(step switchStep) error { + if len(fence.events) == 0 { + return fmt.Errorf("%s ran without a durability fence", step) + } + last := fence.events[len(fence.events)-1] + if last != (durabilityEvent{kind: "directory", path: root}) { + return fmt.Errorf("%s ran after %#v, want app-root directory fence", step, last) + } + return nil + } + + if err := switcher.Switch(root); err != nil { + t.Fatalf("Switch() error = %v", err) + } + if !hasJournalFileFence(fence.events) { + t.Fatalf("fences = %#v, want temporary journal file sync", fence.events) + } + if countDirectoryFences(fence.events, root) < 10 { + t.Fatalf("app-root directory fences = %d, want at least 10", countDirectoryFences(fence.events, root)) + } +} + +func TestSwitcherRenameFenceFailureLeavesRecoverablePreparedJournal(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + fence := &recordingDurabilityFence{} + switcher := NewSwitcher(func(string) error { return nil }) + switcher.durability = fence + switcher.afterStep = func(step switchStep) error { + if step == stepPrepared { + fence.failNextDirectory = true + } + return nil + } + + err := switcher.Switch(root) + if !errors.Is(err, ErrDurability) { + t.Fatalf("Switch() error = %v, want %v", err, ErrDurability) + } + layout, layoutErr := inspectAppLayout(root) + if layoutErr != nil { + t.Fatalf("inspectAppLayout() error = %v", layoutErr) + } + record, exists, loadErr := loadTransaction(layout) + if loadErr != nil || !exists || record.Phase != phasePrepared { + t.Fatalf("transaction = %#v exists=%t error=%v, want prepared journal", record, exists, loadErr) + } + + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != RecoveryRolledBack { + t.Fatalf("Recovery action = %q, want %q", result.Action, RecoveryRolledBack) + } + assertVersion(t, filepath.Join(root, "current"), "old") + assertMissing(t, filepath.Join(root, "staging")) + assertMissing(t, filepath.Join(root, "backup")) + assertMissing(t, filepath.Join(root, transactionFileName)) +} + +func TestRollbackAndRecoveryFenceFailuresRemainRecoverable(t *testing.T) { + t.Run("rollback", func(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + fence := &recordingDurabilityFence{} + switcher := NewSwitcher(func(string) error { return errors.New("health failed") }) + switcher.durability = fence + switcher.afterStep = func(step switchStep) error { + if step == stepRollbackRequired { + fence.failNextDirectory = true + } + return nil + } + + err := switcher.Switch(root) + var rollbackErr *RollbackError + if !errors.As(err, &rollbackErr) || !errors.Is(rollbackErr.Rollback, ErrDurability) { + t.Fatalf("Switch() error = %v, want rollback durability failure", err) + } + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != RecoveryRolledBack { + t.Fatalf("Recovery action = %q, want %q", result.Action, RecoveryRolledBack) + } + assertVersion(t, filepath.Join(root, "current"), "old") + }) + + t.Run("recovery", func(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + switcher := NewSwitcher(func(string) error { return nil }) + switcher.afterStep = func(step switchStep) error { + if step == stepStagingRenamed { + return errSimulatedCrash + } + return nil + } + if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) { + t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash) + } + + fence := &recordingDurabilityFence{failNextDirectory: true} + if _, err := recoverWithFence(root, fence); !errors.Is(err, ErrDurability) { + t.Fatalf("recoverWithFence() error = %v, want %v", err, ErrDurability) + } + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != RecoveryRolledBack { + t.Fatalf("Recovery action = %q, want %q", result.Action, RecoveryRolledBack) + } + assertVersion(t, filepath.Join(root, "current"), "old") + }) +} + +func TestCommittedCleanupFenceFailureRemainsRecoverable(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + fence := &recordingDurabilityFence{} + switcher := NewSwitcher(func(string) error { return nil }) + switcher.durability = fence + switcher.afterStep = func(step switchStep) error { + if step == stepCommitted { + fence.failNextDirectory = true + } + return nil + } + + err := switcher.Switch(root) + if !errors.Is(err, ErrRecoveryRequired) || !errors.Is(err, ErrDurability) { + t.Fatalf("Switch() error = %v, want recovery-required durability failure", err) + } + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != RecoveryCommitted { + t.Fatalf("Recovery action = %q, want %q", result.Action, RecoveryCommitted) + } + assertVersion(t, filepath.Join(root, "current"), "new") + assertMissing(t, filepath.Join(root, "backup")) + assertMissing(t, filepath.Join(root, transactionFileName)) +} + +func assertDurabilityEvents(t *testing.T, got, want []durabilityEvent) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("durability events = %#v, want %#v", got, want) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("durability event %d = %#v, want %#v", index, got[index], want[index]) + } + } +} + +func hasJournalFileFence(events []durabilityEvent) bool { + for _, event := range events { + if event.kind == "file" && strings.HasPrefix(filepath.Base(event.path), ".install-transaction-") { + return true + } + } + return false +} + +func countDirectoryFences(events []durabilityEvent, path string) int { + count := 0 + for _, event := range events { + if event == (durabilityEvent{kind: "directory", path: path}) { + count++ + } + } + return count +} diff --git a/core/installer/durability_windows.go b/core/installer/durability_windows.go new file mode 100644 index 0000000..5f29e8c --- /dev/null +++ b/core/installer/durability_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package installer + +import ( + "fmt" + "syscall" +) + +func syncDirectoryPath(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 +} diff --git a/core/installer/durability_windows_test.go b/core/installer/durability_windows_test.go new file mode 100644 index 0000000..07443db --- /dev/null +++ b/core/installer/durability_windows_test.go @@ -0,0 +1,11 @@ +//go:build windows + +package installer + +import "testing" + +func TestSyncDirectoryPath(t *testing.T) { + if err := syncDirectoryPath(t.TempDir()); err != nil { + t.Fatalf("syncDirectoryPath() error = %v", err) + } +} diff --git a/core/installer/extractor.go b/core/installer/extractor.go index 6b29102..e3112cb 100644 --- a/core/installer/extractor.go +++ b/core/installer/extractor.go @@ -35,7 +35,8 @@ var ( // Extractor writes only payload/ contents from a pre-verified package ZIP. type Extractor struct { - limits Limits + limits Limits + durability durabilityFence } type ExtractResult struct { @@ -56,7 +57,7 @@ func NewExtractor(limits Limits) (Extractor, error) { if err := limits.validate(); err != nil { return Extractor{}, err } - return Extractor{limits: limits}, nil + return Extractor{limits: limits, durability: defaultDurability()}, nil } // ExtractFile requires expectedPackageSize from the verified Catalog package. @@ -88,6 +89,7 @@ func (extractor Extractor) extract( if err := extractor.limits.validate(); err != nil { return ExtractResult{}, err } + fence := effectiveDurability(extractor.durability) normalizedEntrypoint, err := normalizeEntrypoint(entrypoint) if err != nil { return ExtractResult{}, err @@ -160,21 +162,21 @@ func (extractor Extractor) extract( readLimit++ } copied, copyErr := io.Copy(output, io.LimitReader(source, readLimit)) - closeOutputErr := output.Close() closeSourceErr := source.Close() if copyErr != nil { + _ = output.Close() return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr) } - if closeOutputErr != nil { - return ExtractResult{}, fmt.Errorf("close staging file: %w", closeOutputErr) - } if closeSourceErr != nil { + _ = output.Close() return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr) } if copied > remaining { + _ = output.Close() return ExtractResult{}, ErrExpandedTooLarge } if uint64(copied) != entry.file.UncompressedSize64 { + _ = output.Close() return ExtractResult{}, fmt.Errorf( "%w: %s expanded to %d bytes, header declares %d", ErrArchiveCorrupt, @@ -183,9 +185,19 @@ func (extractor Extractor) extract( entry.file.UncompressedSize64, ) } + if err := syncFileWithFence(fence, output, "staging payload"); err != nil { + _ = output.Close() + return ExtractResult{}, err + } + if err := output.Close(); err != nil { + return ExtractResult{}, fmt.Errorf("close staging file: %w", err) + } written += copied result.Files++ } + if err := syncStagingTree(fence, destinationRoot); err != nil { + return ExtractResult{}, err + } result.Bytes = written result.EntrypointPath = entrypointPath diff --git a/core/installer/layout.go b/core/installer/layout.go index e0918f6..9e6f414 100644 --- a/core/installer/layout.go +++ b/core/installer/layout.go @@ -89,6 +89,14 @@ func inspectManagedDirectory(path string) (bool, error) { } func removeManagedDirectory(layout appLayout, target string) error { + return removeManagedDirectoryWithFence(layout, target, defaultDurability()) +} + +func removeManagedDirectoryWithFence( + layout appLayout, + target string, + fence durabilityFence, +) error { if filepath.Dir(target) != layout.root { return fmt.Errorf("%w: refuse removal outside app root", ErrUnsafeInstallLayout) } @@ -106,5 +114,5 @@ func removeManagedDirectory(layout appLayout, target string) error { if err := os.RemoveAll(target); err != nil { return fmt.Errorf("remove managed directory %s: %w", base, err) } - return nil + return syncDirectoryWithFence(fence, layout.root, "app root after directory removal") } diff --git a/core/installer/recovery.go b/core/installer/recovery.go index 6334509..7bfcd58 100644 --- a/core/installer/recovery.go +++ b/core/installer/recovery.go @@ -3,7 +3,6 @@ package installer import ( "errors" "fmt" - "os" ) var ErrRecoveryInconsistent = errors.New("install recovery state is inconsistent") @@ -24,6 +23,11 @@ type RecoveryResult struct { // Recover resolves an interrupted transaction from journal and directory state. func Recover(root string) (RecoveryResult, error) { + return recoverWithFence(root, defaultDurability()) +} + +func recoverWithFence(root string, fence durabilityFence) (RecoveryResult, error) { + fence = effectiveDurability(fence) layout, err := inspectAppLayout(root) if err != nil { return RecoveryResult{}, err @@ -57,18 +61,18 @@ func Recover(root string) (RecoveryResult, error) { state.staging, ) } - if err := removeManagedDirectory(layout, layout.backup); err != nil { + if err := removeManagedDirectoryWithFence(layout, layout.backup, fence); err != nil { return RecoveryResult{}, err } - if err := removeTransaction(layout); err != nil { + if err := removeTransactionWithFence(layout, fence); err != nil { return RecoveryResult{}, err } result.Action = RecoveryCommitted return result, nil case phaseRollbackRequired: - return recoverRollbackRequired(layout, record, state, result) + return recoverRollbackRequired(layout, record, state, result, fence) case phasePrepared, phaseCurrentBackedUp, phaseStagingActivated: - return recoverUncommitted(layout, record, state, result) + return recoverUncommitted(layout, record, state, result, fence) default: return RecoveryResult{}, fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase) } @@ -79,6 +83,7 @@ func recoverUncommitted( record transactionRecord, state directoryState, result RecoveryResult, + fence durabilityFence, ) (RecoveryResult, error) { if record.HadCurrent { if state.backup { @@ -89,27 +94,39 @@ func recoverUncommitted( ) } if state.current { - if err := os.Rename(layout.current, layout.staging); err != nil { + if err := renameManagedDirectory( + layout, + layout.current, + layout.staging, + fence, + "app root after recovery staging rename", + ); err != nil { return RecoveryResult{}, fmt.Errorf("move unverified current aside: %w", err) } } - if err := os.Rename(layout.backup, layout.current); err != nil { + if err := renameManagedDirectory( + layout, + layout.backup, + layout.current, + fence, + "app root after recovery current restore", + ); err != nil { return RecoveryResult{}, fmt.Errorf("restore backup during recovery: %w", err) } - if err := removeManagedDirectory(layout, layout.staging); err != nil { + if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil { return RecoveryResult{}, err } - if err := removeTransaction(layout); err != nil { + if err := removeTransactionWithFence(layout, fence); err != nil { return RecoveryResult{}, err } result.Action = RecoveryRolledBack return result, nil } if record.Phase == phasePrepared && state.current && state.staging { - if err := removeManagedDirectory(layout, layout.staging); err != nil { + if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil { return RecoveryResult{}, err } - if err := removeTransaction(layout); err != nil { + if err := removeTransactionWithFence(layout, fence); err != nil { return RecoveryResult{}, err } result.Action = RecoveryAborted @@ -127,13 +144,13 @@ func recoverUncommitted( ErrRecoveryInconsistent, ) } - if err := removeManagedDirectory(layout, layout.current); err != nil { + if err := removeManagedDirectoryWithFence(layout, layout.current, fence); err != nil { return RecoveryResult{}, err } - if err := removeManagedDirectory(layout, layout.staging); err != nil { + if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil { return RecoveryResult{}, err } - if err := removeTransaction(layout); err != nil { + if err := removeTransactionWithFence(layout, fence); err != nil { return RecoveryResult{}, err } result.Action = RecoveryAborted @@ -145,6 +162,7 @@ func recoverRollbackRequired( record transactionRecord, state directoryState, result RecoveryResult, + fence durabilityFence, ) (RecoveryResult, error) { if record.HadCurrent && !state.backup { if !state.current { @@ -153,19 +171,19 @@ func recoverRollbackRequired( ErrRecoveryInconsistent, ) } - if err := removeManagedDirectory(layout, layout.staging); err != nil { + if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil { return RecoveryResult{}, err } - if err := removeTransaction(layout); err != nil { + if err := removeTransactionWithFence(layout, fence); err != nil { return RecoveryResult{}, err } result.Action = RecoveryRolledBack return result, nil } - if err := rollbackActivated(layout, record.HadCurrent); err != nil { + if err := rollbackActivated(layout, record.HadCurrent, fence); err != nil { return RecoveryResult{}, err } - if err := removeTransaction(layout); err != nil { + if err := removeTransactionWithFence(layout, fence); err != nil { return RecoveryResult{}, err } result.Action = RecoveryRolledBack diff --git a/core/installer/switcher.go b/core/installer/switcher.go index d32f9e6..2a11c90 100644 --- a/core/installer/switcher.go +++ b/core/installer/switcher.go @@ -42,18 +42,20 @@ func (err *RollbackError) Unwrap() error { // Switcher activates a verified staging directory and runs an injected check. type Switcher struct { - health HealthCheck - afterStep func(switchStep) error + health HealthCheck + afterStep func(switchStep) error + durability durabilityFence } func NewSwitcher(health HealthCheck) *Switcher { - return &Switcher{health: health} + return &Switcher{health: health, durability: defaultDurability()} } func (switcher *Switcher) Switch(root string) error { if switcher.health == nil { return ErrHealthCheckRequired } + fence := effectiveDurability(switcher.durability) layout, err := inspectAppLayout(root) if err != nil { return err @@ -75,7 +77,7 @@ func (switcher *Switcher) Switch(root string) error { } record := newTransaction(phasePrepared, state.current) - if err := writeTransaction(layout, record); err != nil { + if err := writeTransactionWithFence(layout, record, fence); err != nil { return err } if err := switcher.runStep(stepPrepared); err != nil { @@ -83,7 +85,13 @@ func (switcher *Switcher) Switch(root string) error { } if state.current { - if err := os.Rename(layout.current, layout.backup); err != nil { + if err := renameManagedDirectory( + layout, + layout.current, + layout.backup, + fence, + "app root after current backup rename", + ); err != nil { return fmt.Errorf("backup current directory: %w", err) } if err := switcher.runStep(stepCurrentRenamed); err != nil { @@ -91,21 +99,27 @@ func (switcher *Switcher) Switch(root string) error { } } record.Phase = phaseCurrentBackedUp - if err := writeTransaction(layout, record); err != nil { + if err := writeTransactionWithFence(layout, record, fence); err != nil { return err } if err := switcher.runStep(stepCurrentBackedUp); err != nil { return err } - if err := os.Rename(layout.staging, layout.current); err != nil { + if err := renameManagedDirectory( + layout, + layout.staging, + layout.current, + fence, + "app root after staging activation rename", + ); err != nil { return fmt.Errorf("activate staging directory: %w", err) } if err := switcher.runStep(stepStagingRenamed); err != nil { return err } record.Phase = phaseStagingActivated - if err := writeTransaction(layout, record); err != nil { + if err := writeTransactionWithFence(layout, record, fence); err != nil { return err } if err := switcher.runStep(stepStagingActivated); err != nil { @@ -115,35 +129,35 @@ func (switcher *Switcher) Switch(root string) error { healthErr := switcher.health(layout.current) if healthErr != nil { record.Phase = phaseRollbackRequired - if err := writeTransaction(layout, record); err != nil { + if err := writeTransactionWithFence(layout, record, fence); err != nil { return &RollbackError{Health: healthErr, Rollback: err} } if err := switcher.runStep(stepRollbackRequired); err != nil { return err } - if err := rollbackActivated(layout, record.HadCurrent); err != nil { + if err := rollbackActivated(layout, record.HadCurrent, fence); err != nil { return &RollbackError{Health: healthErr, Rollback: err} } - if err := removeTransaction(layout); err != nil { + if err := removeTransactionWithFence(layout, fence); err != nil { return &RollbackError{Health: healthErr, Rollback: err} } return fmt.Errorf("%w: %v", ErrHealthCheckFailed, healthErr) } record.Phase = phaseCommitted - if err := writeTransaction(layout, record); err != nil { + if err := writeTransactionWithFence(layout, record, fence); err != nil { return err } if err := switcher.runStep(stepCommitted); err != nil { return err } if record.HadCurrent { - if err := removeManagedDirectory(layout, layout.backup); err != nil { - return fmt.Errorf("%w: cleanup committed backup: %v", ErrRecoveryRequired, err) + if err := removeManagedDirectoryWithFence(layout, layout.backup, fence); err != nil { + return fmt.Errorf("%w: cleanup committed backup: %w", ErrRecoveryRequired, err) } } - if err := removeTransaction(layout); err != nil { - return fmt.Errorf("%w: %v", ErrRecoveryRequired, err) + if err := removeTransactionWithFence(layout, fence); err != nil { + return fmt.Errorf("%w: %w", ErrRecoveryRequired, err) } return nil } @@ -155,7 +169,7 @@ func (switcher *Switcher) runStep(step switchStep) error { return switcher.afterStep(step) } -func rollbackActivated(layout appLayout, hadCurrent bool) error { +func rollbackActivated(layout appLayout, hadCurrent bool, fence durabilityFence) error { state, err := inspectDirectories(layout) if err != nil { return err @@ -168,17 +182,35 @@ func rollbackActivated(layout appLayout, hadCurrent bool) error { if state.staging { return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed) } - if err := os.Rename(layout.current, layout.staging); err != nil { + if err := renameManagedDirectory( + layout, + layout.current, + layout.staging, + fence, + "app root after rollback staging rename", + ); err != nil { return fmt.Errorf("move failed current aside: %w", err) } } - if err := os.Rename(layout.backup, layout.current); err != nil { + if err := renameManagedDirectory( + layout, + layout.backup, + layout.current, + fence, + "app root after rollback current restore", + ); err != nil { if _, statErr := os.Stat(layout.staging); statErr == nil { - _ = os.Rename(layout.staging, layout.current) + _ = renameManagedDirectory( + layout, + layout.staging, + layout.current, + fence, + "app root after rollback restore", + ) } return fmt.Errorf("restore previous current: %w", err) } - return removeManagedDirectory(layout, layout.staging) + return removeManagedDirectoryWithFence(layout, layout.staging, fence) } if state.backup { @@ -188,9 +220,15 @@ func rollbackActivated(layout appLayout, hadCurrent bool) error { if state.staging { return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed) } - if err := os.Rename(layout.current, layout.staging); err != nil { + if err := renameManagedDirectory( + layout, + layout.current, + layout.staging, + fence, + "app root after initial rollback rename", + ); err != nil { return fmt.Errorf("move failed initial install aside: %w", err) } } - return removeManagedDirectory(layout, layout.staging) + return removeManagedDirectoryWithFence(layout, layout.staging, fence) } diff --git a/core/installer/transaction.go b/core/installer/transaction.go index de3c600..8f6f94a 100644 --- a/core/installer/transaction.go +++ b/core/installer/transaction.go @@ -66,6 +66,14 @@ func (record transactionRecord) validate() error { } func writeTransaction(layout appLayout, record transactionRecord) error { + return writeTransactionWithFence(layout, record, defaultDurability()) +} + +func writeTransactionWithFence( + layout appLayout, + record transactionRecord, + fence durabilityFence, +) error { if err := record.validate(); err != nil { return err } @@ -79,6 +87,7 @@ func writeTransaction(layout appLayout, record transactionRecord) error { layout.transaction, layout.transactionBackup, data, + fence, ); err != nil { return fmt.Errorf("write install transaction: %w", err) } @@ -135,11 +144,23 @@ func ensureJSONEOF(decoder *json.Decoder) error { } func removeTransaction(layout appLayout) error { + return removeTransactionWithFence(layout, defaultDurability()) +} + +func removeTransactionWithFence(layout appLayout, fence durabilityFence) error { + removed := false if err := os.Remove(layout.transaction); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove install transaction: %w", err) + } else if err == nil { + removed = true } if err := os.Remove(layout.transactionBackup); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove install transaction backup: %w", err) + } else if err == nil { + removed = true + } + if removed { + return syncDirectoryWithFence(fence, layout.root, "app root after transaction removal") } return nil } @@ -149,6 +170,7 @@ func replaceFileWithBackup( target string, backup string, data []byte, + fence durabilityFence, ) error { temporary, err := os.CreateTemp(directory, ".install-transaction-*.tmp") if err != nil { @@ -165,7 +187,7 @@ func replaceFileWithBackup( temporary.Close() return err } - if err := temporary.Sync(); err != nil { + if err := syncFileWithFence(fence, temporary, "temporary install transaction"); err != nil { temporary.Close() return err } @@ -178,12 +200,19 @@ func replaceFileWithBackup( if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return fmt.Errorf("%w: transaction target is not a regular file", ErrUnsafeInstallLayout) } - if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { + if err := os.Remove(backup); err != nil { + if !os.IsNotExist(err) { + return err + } + } else if err := syncDirectoryWithFence(fence, directory, "app root after stale transaction backup removal"); err != nil { return err } if err := os.Rename(target, backup); err != nil { return err } + if err := syncDirectoryWithFence(fence, directory, "app root after transaction backup rename"); err != nil { + return err + } movedTarget = true } else if !os.IsNotExist(err) { return err @@ -191,14 +220,22 @@ func replaceFileWithBackup( if err := os.Rename(temporaryPath, target); err != nil { if movedTarget { - _ = os.Rename(backup, target) + if restoreErr := os.Rename(backup, target); restoreErr == nil { + _ = syncDirectoryWithFence(fence, directory, "app root after transaction restore") + } } return err } + if err := syncDirectoryWithFence(fence, directory, "app root after transaction replace"); err != nil { + return err + } if movedTarget { if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { return err } + if err := syncDirectoryWithFence(fence, directory, "app root after transaction backup removal"); err != nil { + return err + } } return nil } diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index d651b40..5936923 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-612`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook 以及 ZIP 中央目录/EOCD(含 ZIP64)预扫描均已关闭;`T-613` 已落成为安装文件、目录与 journal 耐久顺序任务并是当前唯一待领取项,T-302 暂后置。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-613`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描以及安装文件/目录/journal 的代码层耐久顺序均已关闭;物理断电、文件锁与杀毒软件干扰验证仍后置到 T-302/T-601,T-302 暂后置。 优先路径: @@ -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-612:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引和 ZIP 中央目录/EOCD 预扫描;下一步执行 T-613 的 Phase 1 文件/目录/journal 耐久整改,再继续 T-302/T-303 与 Phase 4-6。 +5. 已完成 T-606~T-613:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描和安装耐久顺序;下一步先落成并执行独立签名向量任务,再继续 T-302/T-303 与 Phase 4-6。T-302/T-601 仍须补真实 Windows 环境的断电/干扰注入。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 46ed2ab..d6cb53b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -183,7 +183,9 @@ Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。 -该原型已覆盖进程在关键持久化步骤之间退出的恢复;真实断电时的目录项落盘顺序、杀毒软件/文件锁干扰仍需 T-302/T-601 在 Windows VM/真机做故障注入,当前结论不替代硬件级断电验证。 +T-613 已把该状态机的代码层耐久顺序收敛为:payload 的 CRC/长度检查后 `Sync`/`Close` → staging 子目录到根及其父目录同步 → prepared journal 的临时文件 `Sync`/`Close` 与 root 栅栏 → 每次目录 rename 的 root 栅栏与下一 phase journal → committed journal 的 root 栅栏 → backup/journal 清理的 root 栅栏。所有栅栏通过 installer 内部接口复用;非 Windows 使用目录 `File.Sync`,Windows 用 Win7 已有的 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` 读写目录句柄和 `FlushFileBuffers`,失败一律 fail closed。测试可注入失败并验证状态机保留可恢复 journal,但这仍不是物理掉电证明。 + +真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-302/T-601 在 Windows VM/真机做故障注入,当前结论不替代硬件级断电验证。 盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 1ab6601..ff9cbe4 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -38,6 +38,7 @@ - 任意 `zip.Reader` 构造前,必须以同一普通文件句柄核对已验签 Catalog 的 exact package `size` 与完成下载实际长度,并有界预扫描 EOCD/ZIP64:原始包、中央目录字节数和声明条目数超过硬上限,跨盘/截断/边界不一致元数据一律拒绝;不得先扫描路径后按该路径重新打开,不得仅依赖 `len(archive.File)`。 - ZIP 解压必须保留文件数、展开体积和压缩比硬上限。 - 安装/更新只走 `staging → current → backup` 原子流程;任何写 `current/` 的捷径都不允许。 +- 安装 payload 在 CRC/长度检查后必须 `Sync` 再 Close;staging 目录按子→根及其父目录建立栅栏。journal 临时文件内容、journal/backup rename 或删除、受控目录 rename 或删除后必须同步 app root,成功 phase 不得越过失败栅栏。Windows 目录栅栏使用 Win7 可用的 `FILE_FLAG_BACKUP_SEMANTICS` + `FlushFileBuffers`,不支持或失败必须 fail closed;单元测试不等同于物理断电证明。 - 程序更新不得触碰 `data/` 与 `licenses/`。 - 不强杀用户进程;更新前等待正常退出,超时取消。 - 私钥、真实注册码、真实机器标识不进代码、测试数据和文档;`testdata/` 只放假数据和专用测试密钥对。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 8d8f8a7..2d5f1f5 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -42,7 +42,7 @@ #### Phase 1 交叉审核加固 -Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605 与 T-612 已关闭;T-613 处理文件、目录和 journal 的断电耐久顺序。签名向量任务在前一整改完成并提交后再正式编号,T-302 继续后置。 +Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605、T-612 与 T-613 已关闭;T-613 建立文件、目录和 journal 的代码层耐久顺序,物理断电验证仍后置到 T-302/T-601。签名向量任务在前一整改完成并提交后再正式编号,T-302 继续后置。 | ID | 任务 | 依赖 | 验收要点 | | --- | --- | --- | --- | diff --git a/docs/api.md b/docs/api.md index 3a87835..ce22a90 100644 --- a/docs/api.md +++ b/docs/api.md @@ -212,6 +212,10 @@ T-102 Phase 1 原型进一步固定: phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollback_required`、`committed`。日志使用临时文件 + 同目录 backup 原子替换;恢复时同时检查日志与 current/staging/backup 实际状态。未完成健康检查的 current 不视为可信:有旧版时恢复 backup,首次安装则撤销 current。 +T-613 为该原型建立了 fail-closed 的耐久顺序:每个 payload 先完成 CRC/长度检查、`Sync`、`Close`;staging 目录按子目录→staging 根→父目录同步。每次 journal 写入均为临时文件内容 `Sync`/`Close` 后 rename,并在 transaction/backup rename、删除后同步 app root。`current → backup`、`staging → current`、rollback/recovery rename 和受控目录删除也必须先同步 app root,才可写下一 phase 或触发测试步骤;只有 `committed` journal 完成该栅栏后才清理 backup 和 journal。非 Windows 打开目录后 `File.Sync`;Windows 以 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` 打开读写目录句柄并调用 `FlushFileBuffers`,任一打开、flush 或 close 失败均返回安装耐久错误,不得静默降级。 + +这些栅栏与注入失败测试只证明代码层面的调用顺序和 fail-closed 行为,不证明断电后硬件/驱动缓存、网络文件系统、文件锁或杀毒软件的物理表现。T-302/T-601 仍须在目标 Windows VM/真机执行断电与干扰故障注入。 + ### 2.6 下载任务元数据 download-task.json(本地) 每个任务以稳定 `request_id` 为主键,元数据位于 `downloads/tasks/.json`,字节文件位于 `downloads/files/.part|.download`。本地路径只由客户端从 request_id 派生,不接受 URL 或 Content-Disposition 提供的文件名。 diff --git a/docs/current-state.md b/docs/current-state.md index 61f5bd9..b903176 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,22 +13,22 @@ ## 当前快照 - 日期:2026-07-18 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-612 已完成,T-613 已落成为唯一 TODO,T-302 继续暂后置 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-613 已完成,T-302 继续暂后置 - 技术栈:根 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 安全相对路径策略、安全 ZIP 解压/回滚原型(Extractor 在同一普通文件句柄上核对 expected Catalog size 后有界预扫 EOCD/ZIP64、中央目录与条目数)、发布稳定只读 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、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型(Extractor 在同一普通文件句柄上核对 expected Catalog size 后有界预扫 EOCD/ZIP64、中央目录与条目数,每个 payload Sync/Close 后同步 staging tree 及父目录;transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 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、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;T-612 已闭合 ZIP reader 前的 Catalog size/完成文件长度/中央目录 EOCD(含 ZIP64)预扫描边界。T-613 已落成,将为 payload、staging tree、journal 与目录 rename/remove 建立耐久顺序和 Windows Flush 栅栏;T-302 继续后置到该阻断项关闭 +- 当前 blocker:无;T-613 已建立 payload、staging tree、journal 与目录 rename/remove 的 fail-closed 耐久顺序,并由 Windows 原生目录 `FlushFileBuffers` 用例验证。物理断电、文件锁/杀毒软件干扰仍需 T-302/T-601 的目标 Windows VM/真机故障注入;T-302 继续后置到签名向量整改完成 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-612 已完成;T-613 是当前唯一 TODO 的 Phase 1 文件/目录/journal 耐久整改,T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-613 已完成;下一项先落成 Phase 1 签名向量整改,T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-612`。 +- 已完成: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-604`~`T-613`。 - 正在进行:无。 -- 下一个可领取任务:`T-613` — 为 payload、staging tree、journal 与目录 rename/remove 建立可测试的耐久顺序和 Windows Flush 栅栏。 +- 下一个可领取任务:暂无;应先按 Phase 1 审核定稿顺序落成独立 canonicalization/签名测试向量任务,再领取。T-302/T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 ## 当前可运行内容 diff --git a/docs/review/phase1-security-review.md b/docs/review/phase1-security-review.md index e11038c..13a759b 100644 --- a/docs/review/phase1-security-review.md +++ b/docs/review/phase1-security-review.md @@ -201,7 +201,7 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题 1. **[阻断 T-302 · 最高 · T-605]** 修 Windows 尾部空格/点、DOS 设备名与路径别名:建逐段 Windows 安全路径校验器(各层共用)+ 提取时 destination 包含性兜底检查;补 Win7/10/11 真实文件系统用例。关闭前不得把 Extractor 描述为"无路径穿越"。 2. **[已关闭 · T-612]** 已在构造 `zip.Reader` 前增加同句柄包大小与中央目录/EOCD(含 ZIP64)预扫描边界;Extractor 强制接收 expected Catalog `size`,核对打开文件长度并限制原始包、中央目录与声明条目数,不只依赖 `len(archive.File)`。T-302 仍须把已验签 Catalog、完成下载文件和 SHA-256 编排为真实安装调用链。 -3. **[发布前必做]** 完整断电耐久策略:payload 文件 Sync → staging 目录元数据 → journal 顺序点 → 每次 rename 后父目录顺序点 → committed 耐久后才删 backup/journal;评估 Windows 目录句柄 `FlushFileBuffers`/写穿方案;T-302/T-601 用 VM/真机断电注入验证。 +3. **[代码顺序已关闭 · T-613;发布前环境验证仍必做]** payload 文件 Sync/Close → staging 子目录、根与父目录元数据栅栏 → journal 临时文件 Sync/Close 与 root 栅栏 → 每次 rename 后 root 栅栏 → committed 耐久后才删 backup/journal。Windows 已使用 Win7 可用的目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`,失败 fail closed;fake 覆盖 payload/tree/journal/rename/rollback/recovery/cleanup 失败,且原生 Windows 用例验证目录 fence 可执行。T-302/T-601 仍必须在 VM/真机进行真实断电、文件锁/杀毒干扰注入,不能以单元测试替代。 4. **[协议冻结前]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。 5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。 6. **[实现时重点审]** T-402/T-403 真实健康检查:防"进程短暂启动即判健康"、错误工作目录/错误二进制被探活。 @@ -215,3 +215,10 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题 - 新的 EOCD 预扫描只读取最多 65,557 字节尾部和固定 ZIP64 locator/end 记录,拒绝跨盘、截断、offset/size 溢出或不一致结构,在标准库解析中央目录前限制 64 MiB 中央目录及 10,000 个声明条目。现有完整 `preflight` 保留为第二道 ZIP 语义和展开数据防线。 - installer 回归覆盖 size 不一致、原始包超限、经典 EOCD 条目/目录伪造、跨盘/截断、ZIP64 有效和损坏 locator/end、非普通输入与 staging 未创建;`GOWORK=off go vet ./installer` 和 `go test -count=10 ./installer` 均通过。 - 关闭的是 Extractor 边界,不是 T-302 生产安装整合或真实下载信任链;下一项断电耐久性整改仍独立阻断 T-302。 + +### T-613 完成记录(2026-07-18) + +- `Extractor` 在每个 payload 的 CRC/长度检查后同步并关闭文件,随后从 staging 子目录到根、再到 staging 父目录执行栅栏;任一失败删除本次 staging,不返回可安装结果。 +- transaction、Switcher、rollback、Recovery 和受控删除统一经内部耐久接口:journal 临时文件先同步内容,所有 journal/目录 rename 或删除后同步 app root,未完成栅栏不得推进测试步骤或下一 phase。清理路径同时保留 `ErrRecoveryRequired` 与底层耐久错误,以便调用方可判定恢复状态。 +- Windows 原生目录测试已验证读写目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`;Go 1.20 标准 `syscall` 路径不引入 Win10+ API 或第三方依赖。fake 覆盖 payload/tree、journal、switch、rollback/recovery 与 committed 清理的错误传播和后续 Recover 不变式。 +- 关闭的是代码中的耐久顺序与 fail-closed 策略;T-302/T-601 的 Windows VM/真机物理断电、文件锁与杀毒软件故障注入仍为发布前环境验证,不宣称当前单元测试提供硬件级保证。 diff --git a/docs/tasks/T-613.md b/docs/tasks/T-613.md index 27d9edf..cef164d 100644 --- a/docs/tasks/T-613.md +++ b/docs/tasks/T-613.md @@ -3,7 +3,7 @@ id: T-613 title: 建立安装文件与目录事务耐久顺序 phase: 1 deps: [T-612] -status: TODO +status: DONE created: 2026-07-18 issue: null context_ref: 0f69fa3bb4d00e6c79938c0a7bc66f5e0faec09e @@ -77,3 +77,7 @@ T-103 已建立 `staging → current → backup` 的进程崩溃恢复状态机, - 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 3 项落成任务;现有全局最大任务为 T-612,因此取 T-613,依赖已完成的 T-612。 - 2026-07-18:本地代码检索确认 Extractor 在 `io.Copy` 后仅 Close payload,`replaceFileWithBackup` 只 Sync 临时 journal 内容,Switcher/Recovery 的目录 rename 与 `removeManagedDirectory` 后均没有父目录栅栏。任务因此覆盖 payload、目录树、journal、切换/恢复 rename 与清理的统一顺序,不只修单个 `Sync()`。 - 2026-07-18:Windows 策略限定为 Win7 已有的目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`,失败 fail closed;物理掉电/锁/杀毒故障注入的实测证据仍明确后置到 T-302/T-601。 +- 2026-07-18:在 `core/installer` 落地单一内部耐久接口。Extractor 现在在每个 payload 的 CRC/长度检查后 Sync、Close,并按 staging 子目录→根→父目录建立栅栏;transaction 的临时 journal 先 Sync/Close,全部 journal rename/remove 都同步 app root。Switcher、rollback、Recovery 与受控目录删除复用同一路径,每次目录可见变更完成 root 栅栏后才可进入下一 phase/afterStep。 +- 2026-07-18:Windows 目录实现使用 Go 1.20 标准 `syscall.CreateFile` 的读写目录句柄、`FILE_FLAG_BACKUP_SEMANTICS` 和 `FlushFileBuffers`,没有新增第三方或 Win10+ API;非 Windows 以打开目录的 `File.Sync` 实现。原生 Windows `TestSyncDirectoryPath` 通过,打开、flush、close 任一步失败均返回 `ErrDurability` 错误链。 +- 2026-07-18:新增 fake fence 顺序/失败测试,覆盖 payload、staging tree、journal、rename、rollback、Recovery 和 committed cleanup;失败不越过下一 phase,既有 `Recover` 仍将可恢复状态收敛。同步完成 API/架构/编码规则/审核结论,明确代码层顺序不替代 T-302/T-601 的 VM/真机物理断电、文件锁与杀毒软件故障注入。 +- 2026-07-18:验证通过:`GOWORK=off go -C core vet ./installer`、`GOWORK=off go -C core test -count=10 ./installer`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与 `git diff --check`;完整验证同时覆盖 Go 1.20.14 core、现代版与 Win7 版构建/测试。无 blocker。