diff --git a/core/installer/layout.go b/core/installer/layout.go new file mode 100644 index 0000000..e0918f6 --- /dev/null +++ b/core/installer/layout.go @@ -0,0 +1,110 @@ +package installer + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +var ( + ErrUnsafeInstallLayout = errors.New("unsafe install directory layout") + ErrStagingMissing = errors.New("staging directory is missing") + ErrBackupExists = errors.New("backup directory already exists") +) + +type appLayout struct { + root string + current string + staging string + backup string + transaction string + transactionBackup string +} + +type directoryState struct { + current bool + staging bool + backup bool +} + +func inspectAppLayout(root string) (appLayout, error) { + if root == "" { + return appLayout{}, fmt.Errorf("%w: empty root", ErrUnsafeInstallLayout) + } + absolute, err := filepath.Abs(root) + if err != nil { + return appLayout{}, fmt.Errorf("%w: %v", ErrUnsafeInstallLayout, err) + } + info, err := os.Lstat(absolute) + if err != nil { + return appLayout{}, fmt.Errorf("%w: root: %v", ErrUnsafeInstallLayout, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return appLayout{}, fmt.Errorf("%w: root is not a real directory", ErrUnsafeInstallLayout) + } + transaction, transactionBackup := transactionPaths(absolute) + return appLayout{ + root: absolute, + current: filepath.Join(absolute, "current"), + staging: filepath.Join(absolute, "staging"), + backup: filepath.Join(absolute, "backup"), + transaction: transaction, + transactionBackup: transactionBackup, + }, nil +} + +func inspectDirectories(layout appLayout) (directoryState, error) { + current, err := inspectManagedDirectory(layout.current) + if err != nil { + return directoryState{}, err + } + staging, err := inspectManagedDirectory(layout.staging) + if err != nil { + return directoryState{}, err + } + backup, err := inspectManagedDirectory(layout.backup) + if err != nil { + return directoryState{}, err + } + return directoryState{ + current: current, + staging: staging, + backup: backup, + }, nil +} + +func inspectManagedDirectory(path 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", ErrUnsafeInstallLayout, path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, fmt.Errorf("%w: %s is not a real directory", ErrUnsafeInstallLayout, path) + } + return true, nil +} + +func removeManagedDirectory(layout appLayout, target string) error { + if filepath.Dir(target) != layout.root { + return fmt.Errorf("%w: refuse removal outside app root", ErrUnsafeInstallLayout) + } + base := filepath.Base(target) + if base != "current" && base != "staging" && base != "backup" { + return fmt.Errorf("%w: refuse removal of %s", ErrUnsafeInstallLayout, base) + } + exists, err := inspectManagedDirectory(target) + if err != nil { + return err + } + if !exists { + return nil + } + if err := os.RemoveAll(target); err != nil { + return fmt.Errorf("remove managed directory %s: %w", base, err) + } + return nil +} diff --git a/core/installer/recovery.go b/core/installer/recovery.go new file mode 100644 index 0000000..6334509 --- /dev/null +++ b/core/installer/recovery.go @@ -0,0 +1,173 @@ +package installer + +import ( + "errors" + "fmt" + "os" +) + +var ErrRecoveryInconsistent = errors.New("install recovery state is inconsistent") + +type RecoveryAction string + +const ( + RecoveryNone RecoveryAction = "none" + RecoveryAborted RecoveryAction = "aborted" + RecoveryRolledBack RecoveryAction = "rolled_back" + RecoveryCommitted RecoveryAction = "committed" +) + +type RecoveryResult struct { + Action RecoveryAction + Phase string +} + +// Recover resolves an interrupted transaction from journal and directory state. +func Recover(root string) (RecoveryResult, error) { + layout, err := inspectAppLayout(root) + if err != nil { + return RecoveryResult{}, err + } + record, exists, err := loadTransaction(layout) + if err != nil { + return RecoveryResult{}, err + } + state, err := inspectDirectories(layout) + if err != nil { + return RecoveryResult{}, err + } + if !exists { + if state.backup { + return RecoveryResult{}, fmt.Errorf( + "%w: backup exists without transaction", + ErrRecoveryInconsistent, + ) + } + return RecoveryResult{Action: RecoveryNone}, nil + } + + result := RecoveryResult{Phase: string(record.Phase)} + switch record.Phase { + case phaseCommitted: + if !state.current || state.staging { + return RecoveryResult{}, fmt.Errorf( + "%w: committed current=%t staging=%t", + ErrRecoveryInconsistent, + state.current, + state.staging, + ) + } + if err := removeManagedDirectory(layout, layout.backup); err != nil { + return RecoveryResult{}, err + } + if err := removeTransaction(layout); err != nil { + return RecoveryResult{}, err + } + result.Action = RecoveryCommitted + return result, nil + case phaseRollbackRequired: + return recoverRollbackRequired(layout, record, state, result) + case phasePrepared, phaseCurrentBackedUp, phaseStagingActivated: + return recoverUncommitted(layout, record, state, result) + default: + return RecoveryResult{}, fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase) + } +} + +func recoverUncommitted( + layout appLayout, + record transactionRecord, + state directoryState, + result RecoveryResult, +) (RecoveryResult, error) { + if record.HadCurrent { + if state.backup { + if state.current && state.staging { + return RecoveryResult{}, fmt.Errorf( + "%w: current, staging and backup all exist", + ErrRecoveryInconsistent, + ) + } + if state.current { + if err := os.Rename(layout.current, layout.staging); err != nil { + return RecoveryResult{}, fmt.Errorf("move unverified current aside: %w", err) + } + } + if err := os.Rename(layout.backup, layout.current); err != nil { + return RecoveryResult{}, fmt.Errorf("restore backup during recovery: %w", err) + } + if err := removeManagedDirectory(layout, layout.staging); err != nil { + return RecoveryResult{}, err + } + if err := removeTransaction(layout); 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 { + return RecoveryResult{}, err + } + if err := removeTransaction(layout); err != nil { + return RecoveryResult{}, err + } + result.Action = RecoveryAborted + return result, nil + } + return RecoveryResult{}, fmt.Errorf( + "%w: old current has no recoverable backup", + ErrRecoveryInconsistent, + ) + } + + if state.backup || (state.current && state.staging) { + return RecoveryResult{}, fmt.Errorf( + "%w: initial install has conflicting directories", + ErrRecoveryInconsistent, + ) + } + if err := removeManagedDirectory(layout, layout.current); err != nil { + return RecoveryResult{}, err + } + if err := removeManagedDirectory(layout, layout.staging); err != nil { + return RecoveryResult{}, err + } + if err := removeTransaction(layout); err != nil { + return RecoveryResult{}, err + } + result.Action = RecoveryAborted + return result, nil +} + +func recoverRollbackRequired( + layout appLayout, + record transactionRecord, + state directoryState, + result RecoveryResult, +) (RecoveryResult, error) { + if record.HadCurrent && !state.backup { + if !state.current { + return RecoveryResult{}, fmt.Errorf( + "%w: rollback lost current and backup", + ErrRecoveryInconsistent, + ) + } + if err := removeManagedDirectory(layout, layout.staging); err != nil { + return RecoveryResult{}, err + } + if err := removeTransaction(layout); err != nil { + return RecoveryResult{}, err + } + result.Action = RecoveryRolledBack + return result, nil + } + if err := rollbackActivated(layout, record.HadCurrent); err != nil { + return RecoveryResult{}, err + } + if err := removeTransaction(layout); err != nil { + return RecoveryResult{}, err + } + result.Action = RecoveryRolledBack + return result, nil +} diff --git a/core/installer/switcher.go b/core/installer/switcher.go new file mode 100644 index 0000000..d32f9e6 --- /dev/null +++ b/core/installer/switcher.go @@ -0,0 +1,196 @@ +package installer + +import ( + "errors" + "fmt" + "os" +) + +var ( + ErrHealthCheckRequired = errors.New("health check is required") + ErrHealthCheckFailed = errors.New("installed version failed health check") + ErrRollbackFailed = errors.New("install rollback failed") +) + +type HealthCheck func(currentPath string) error + +type switchStep string + +const ( + stepPrepared switchStep = "prepared" + stepCurrentRenamed switchStep = "current_renamed" + stepCurrentBackedUp switchStep = "current_backed_up" + stepStagingRenamed switchStep = "staging_renamed" + stepStagingActivated switchStep = "staging_activated" + stepRollbackRequired switchStep = "rollback_required" + stepCommitted switchStep = "committed" +) + +// RollbackError reports both the health failure and rollback failure. +type RollbackError struct { + Health error + Rollback error +} + +func (err *RollbackError) Error() string { + return fmt.Sprintf("%s: health=%v; rollback=%v", ErrRollbackFailed, err.Health, err.Rollback) +} + +func (err *RollbackError) Unwrap() error { + return ErrRollbackFailed +} + +// Switcher activates a verified staging directory and runs an injected check. +type Switcher struct { + health HealthCheck + afterStep func(switchStep) error +} + +func NewSwitcher(health HealthCheck) *Switcher { + return &Switcher{health: health} +} + +func (switcher *Switcher) Switch(root string) error { + if switcher.health == nil { + return ErrHealthCheckRequired + } + layout, err := inspectAppLayout(root) + if err != nil { + return err + } + if _, exists, err := loadTransaction(layout); err != nil { + return err + } else if exists { + return ErrRecoveryRequired + } + state, err := inspectDirectories(layout) + if err != nil { + return err + } + if !state.staging { + return ErrStagingMissing + } + if state.backup { + return ErrBackupExists + } + + record := newTransaction(phasePrepared, state.current) + if err := writeTransaction(layout, record); err != nil { + return err + } + if err := switcher.runStep(stepPrepared); err != nil { + return err + } + + if state.current { + if err := os.Rename(layout.current, layout.backup); err != nil { + return fmt.Errorf("backup current directory: %w", err) + } + if err := switcher.runStep(stepCurrentRenamed); err != nil { + return err + } + } + record.Phase = phaseCurrentBackedUp + if err := writeTransaction(layout, record); err != nil { + return err + } + if err := switcher.runStep(stepCurrentBackedUp); err != nil { + return err + } + + if err := os.Rename(layout.staging, layout.current); 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 { + return err + } + if err := switcher.runStep(stepStagingActivated); err != nil { + return err + } + + healthErr := switcher.health(layout.current) + if healthErr != nil { + record.Phase = phaseRollbackRequired + if err := writeTransaction(layout, record); 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 { + return &RollbackError{Health: healthErr, Rollback: err} + } + if err := removeTransaction(layout); 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 { + 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 := removeTransaction(layout); err != nil { + return fmt.Errorf("%w: %v", ErrRecoveryRequired, err) + } + return nil +} + +func (switcher *Switcher) runStep(step switchStep) error { + if switcher.afterStep == nil { + return nil + } + return switcher.afterStep(step) +} + +func rollbackActivated(layout appLayout, hadCurrent bool) error { + state, err := inspectDirectories(layout) + if err != nil { + return err + } + if hadCurrent { + if !state.backup { + return fmt.Errorf("%w: previous current backup is missing", ErrRollbackFailed) + } + if state.current { + if state.staging { + return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed) + } + if err := os.Rename(layout.current, layout.staging); err != nil { + return fmt.Errorf("move failed current aside: %w", err) + } + } + if err := os.Rename(layout.backup, layout.current); err != nil { + if _, statErr := os.Stat(layout.staging); statErr == nil { + _ = os.Rename(layout.staging, layout.current) + } + return fmt.Errorf("restore previous current: %w", err) + } + return removeManagedDirectory(layout, layout.staging) + } + + if state.backup { + return fmt.Errorf("%w: unexpected backup without previous current", ErrRollbackFailed) + } + if state.current { + if state.staging { + return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed) + } + if err := os.Rename(layout.current, layout.staging); err != nil { + return fmt.Errorf("move failed initial install aside: %w", err) + } + } + return removeManagedDirectory(layout, layout.staging) +} diff --git a/core/installer/switcher_test.go b/core/installer/switcher_test.go new file mode 100644 index 0000000..7930f7f --- /dev/null +++ b/core/installer/switcher_test.go @@ -0,0 +1,295 @@ +package installer + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +var errSimulatedCrash = errors.New("simulated crash") + +func TestSwitcherCommitsHealthyUpdate(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + switcher := NewSwitcher(func(currentPath string) error { + assertVersion(t, currentPath, "new") + return nil + }) + + if err := switcher.Switch(root); err != nil { + t.Fatalf("Switch() error = %v", err) + } + assertVersion(t, filepath.Join(root, "current"), "new") + assertMissing(t, filepath.Join(root, "staging")) + assertMissing(t, filepath.Join(root, "backup")) + assertMissing(t, filepath.Join(root, transactionFileName)) +} + +func TestSwitcherRollsBackFailedUpdate(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + healthFailure := errors.New("new version did not start") + switcher := NewSwitcher(func(string) error { + return healthFailure + }) + + err := switcher.Switch(root) + if !errors.Is(err, ErrHealthCheckFailed) { + t.Fatalf("Switch() error = %v, want %v", err, ErrHealthCheckFailed) + } + 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 TestSwitcherRemovesFailedInitialInstall(t *testing.T) { + root := makeInstallRoot(t, "", "new") + switcher := NewSwitcher(func(string) error { + return errors.New("health failed") + }) + + err := switcher.Switch(root) + if !errors.Is(err, ErrHealthCheckFailed) { + t.Fatalf("Switch() error = %v, want %v", err, ErrHealthCheckFailed) + } + assertMissing(t, filepath.Join(root, "current")) + assertMissing(t, filepath.Join(root, "staging")) + assertMissing(t, filepath.Join(root, transactionFileName)) +} + +func TestRecoverInterruptedSwitch(t *testing.T) { + tests := []struct { + name string + crashStep switchStep + wantVersion string + wantAction RecoveryAction + }{ + { + name: "after current rename before phase update", + crashStep: stepCurrentRenamed, + wantVersion: "old", + wantAction: RecoveryRolledBack, + }, + { + name: "after staging rename before phase update", + crashStep: stepStagingRenamed, + wantVersion: "old", + wantAction: RecoveryRolledBack, + }, + { + name: "after committed journal before cleanup", + crashStep: stepCommitted, + wantVersion: "new", + wantAction: RecoveryCommitted, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + switcher := NewSwitcher(func(string) error { return nil }) + switcher.afterStep = func(step switchStep) error { + if step == test.crashStep { + return errSimulatedCrash + } + return nil + } + + err := switcher.Switch(root) + if !errors.Is(err, errSimulatedCrash) { + t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash) + } + + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != test.wantAction { + t.Fatalf("Action = %q, want %q", result.Action, test.wantAction) + } + assertVersion(t, filepath.Join(root, "current"), test.wantVersion) + assertMissing(t, filepath.Join(root, "staging")) + assertMissing(t, filepath.Join(root, "backup")) + assertMissing(t, filepath.Join(root, transactionFileName)) + }) + } +} + +func TestRecoverReadsTransactionBackup(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) + } + if err := os.Rename( + filepath.Join(root, transactionFileName), + filepath.Join(root, transactionBackupFileName), + ); err != nil { + t.Fatalf("move transaction to backup: %v", err) + } + + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != RecoveryRolledBack { + t.Fatalf("Action = %q, want %q", result.Action, RecoveryRolledBack) + } + assertVersion(t, filepath.Join(root, "current"), "old") + assertMissing(t, filepath.Join(root, transactionBackupFileName)) +} + +func TestRecoverAbortsInterruptedInitialInstall(t *testing.T) { + root := makeInstallRoot(t, "", "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) + } + + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != RecoveryAborted { + t.Fatalf("Action = %q, want %q", result.Action, RecoveryAborted) + } + assertMissing(t, filepath.Join(root, "current")) + assertMissing(t, filepath.Join(root, "staging")) + assertMissing(t, filepath.Join(root, transactionFileName)) +} + +func TestRecoverCompletesInterruptedRollback(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + switcher := NewSwitcher(func(string) error { + return errors.New("health failed") + }) + switcher.afterStep = func(step switchStep) error { + if step == stepRollbackRequired { + return errSimulatedCrash + } + return nil + } + if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) { + t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash) + } + + result, err := Recover(root) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.Action != RecoveryRolledBack { + t.Fatalf("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")) +} + +func TestSwitcherRejectsUnsafeStartingState(t *testing.T) { + t.Run("missing staging", func(t *testing.T) { + root := makeInstallRoot(t, "old", "") + err := NewSwitcher(func(string) error { return nil }).Switch(root) + if !errors.Is(err, ErrStagingMissing) { + t.Fatalf("Switch() error = %v, want %v", err, ErrStagingMissing) + } + }) + + t.Run("existing backup", func(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + writeVersion(t, filepath.Join(root, "backup"), "stale") + err := NewSwitcher(func(string) error { return nil }).Switch(root) + if !errors.Is(err, ErrBackupExists) { + t.Fatalf("Switch() error = %v, want %v", err, ErrBackupExists) + } + }) + + t.Run("staging symlink", func(t *testing.T) { + root := t.TempDir() + target := filepath.Join(t.TempDir(), "target") + writeVersion(t, target, "new") + if err := os.Symlink(target, filepath.Join(root, "staging")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + err := NewSwitcher(func(string) error { return nil }).Switch(root) + if !errors.Is(err, ErrUnsafeInstallLayout) { + t.Fatalf("Switch() error = %v, want %v", err, ErrUnsafeInstallLayout) + } + }) + + t.Run("transaction symlink", func(t *testing.T) { + root := makeInstallRoot(t, "old", "new") + target := filepath.Join(t.TempDir(), "transaction.json") + if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil { + t.Fatalf("write symlink target: %v", err) + } + if err := os.Symlink(target, filepath.Join(root, transactionFileName)); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + err := NewSwitcher(func(string) error { return nil }).Switch(root) + if !errors.Is(err, ErrUnsafeInstallLayout) { + t.Fatalf("Switch() error = %v, want %v", err, ErrUnsafeInstallLayout) + } + }) +} + +func TestRecoverRejectsOrphanBackup(t *testing.T) { + root := makeInstallRoot(t, "old", "") + writeVersion(t, filepath.Join(root, "backup"), "orphan") + + _, err := Recover(root) + if !errors.Is(err, ErrRecoveryInconsistent) { + t.Fatalf("Recover() error = %v, want %v", err, ErrRecoveryInconsistent) + } +} + +func makeInstallRoot(t *testing.T, currentVersion, stagingVersion string) string { + t.Helper() + root := t.TempDir() + if currentVersion != "" { + writeVersion(t, filepath.Join(root, "current"), currentVersion) + } + if stagingVersion != "" { + writeVersion(t, filepath.Join(root, "staging"), stagingVersion) + } + return root +} + +func writeVersion(t *testing.T, directory, version string) { + t.Helper() + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatalf("create version directory: %v", err) + } + if err := os.WriteFile(filepath.Join(directory, "version.txt"), []byte(version), 0o600); err != nil { + t.Fatalf("write version: %v", err) + } +} + +func assertVersion(t *testing.T, directory, want string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(directory, "version.txt")) + if err != nil { + t.Fatalf("read version from %s: %v", directory, err) + } + if string(data) != want { + t.Fatalf("version in %s = %q, want %q", directory, data, want) + } +} + +func assertMissing(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("%s should be missing, stat error = %v", path, err) + } +} diff --git a/core/installer/transaction.go b/core/installer/transaction.go new file mode 100644 index 0000000..de3c600 --- /dev/null +++ b/core/installer/transaction.go @@ -0,0 +1,209 @@ +package installer + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +const ( + transactionFileName = "install-transaction.json" + transactionBackupFileName = "install-transaction.json.backup" + transactionSchemaVersion = 1 +) + +type transactionPhase string + +const ( + phasePrepared transactionPhase = "prepared" + phaseCurrentBackedUp transactionPhase = "current_backed_up" + phaseStagingActivated transactionPhase = "staging_activated" + phaseRollbackRequired transactionPhase = "rollback_required" + phaseCommitted transactionPhase = "committed" +) + +var ( + ErrTransactionCorrupt = errors.New("install transaction is corrupt") + ErrRecoveryRequired = errors.New("install recovery is required") +) + +type transactionRecord struct { + SchemaVersion int `json:"schema_version"` + Phase transactionPhase `json:"phase"` + HadCurrent bool `json:"had_current"` +} + +func newTransaction(phase transactionPhase, hadCurrent bool) transactionRecord { + return transactionRecord{ + SchemaVersion: transactionSchemaVersion, + Phase: phase, + HadCurrent: hadCurrent, + } +} + +func (record transactionRecord) validate() error { + if record.SchemaVersion != transactionSchemaVersion { + return fmt.Errorf( + "%w: schema_version=%d", + ErrTransactionCorrupt, + record.SchemaVersion, + ) + } + switch record.Phase { + case phasePrepared, + phaseCurrentBackedUp, + phaseStagingActivated, + phaseRollbackRequired, + phaseCommitted: + return nil + default: + return fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase) + } +} + +func writeTransaction(layout appLayout, record transactionRecord) error { + if err := record.validate(); err != nil { + return err + } + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("encode install transaction: %w", err) + } + data = append(data, '\n') + if err := replaceFileWithBackup( + layout.root, + layout.transaction, + layout.transactionBackup, + data, + ); err != nil { + return fmt.Errorf("write install transaction: %w", err) + } + return nil +} + +func loadTransaction(layout appLayout) (transactionRecord, bool, error) { + data, err := readTransactionFile(layout.transaction) + if os.IsNotExist(err) { + data, err = readTransactionFile(layout.transactionBackup) + } + if os.IsNotExist(err) { + return transactionRecord{}, false, nil + } + if err != nil { + return transactionRecord{}, false, fmt.Errorf("read install transaction: %w", err) + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var record transactionRecord + if err := decoder.Decode(&record); err != nil { + return transactionRecord{}, false, fmt.Errorf("%w: %v", ErrTransactionCorrupt, err) + } + if err := ensureJSONEOF(decoder); err != nil { + return transactionRecord{}, false, err + } + if err := record.validate(); err != nil { + return transactionRecord{}, false, err + } + return record, true, nil +} + +func readTransactionFile(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("%w: transaction is not a regular file", ErrUnsafeInstallLayout) + } + return os.ReadFile(path) +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("%w: trailing JSON value", ErrTransactionCorrupt) + } + return fmt.Errorf("%w: trailing data: %v", ErrTransactionCorrupt, err) + } + return nil +} + +func removeTransaction(layout appLayout) error { + if err := os.Remove(layout.transaction); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove install transaction: %w", err) + } + if err := os.Remove(layout.transactionBackup); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove install transaction backup: %w", err) + } + return nil +} + +func replaceFileWithBackup( + directory string, + target string, + backup string, + data []byte, +) error { + temporary, err := os.CreateTemp(directory, ".install-transaction-*.tmp") + 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 + } + + movedTarget := false + if info, err := os.Lstat(target); err == nil { + 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) { + return err + } + if err := os.Rename(target, backup); err != nil { + return err + } + movedTarget = true + } else if !os.IsNotExist(err) { + return err + } + + if err := os.Rename(temporaryPath, target); err != nil { + if movedTarget { + _ = os.Rename(backup, target) + } + return err + } + if movedTarget { + if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func transactionPaths(root string) (string, string) { + return filepath.Join(root, transactionFileName), + filepath.Join(root, transactionBackupFileName) +} diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index fc437f4..3a7586e 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -45,13 +45,13 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目处于:**M1 已完成(Phase 0 工程骨架、双 Gio 窗口与双目标编译闸门已建立),准备进入 Phase 1 高风险原型**。 +当前项目处于:**M2 已完成(清单验签、ZIP 安全解压、原子切换/回滚三项高风险原型已验证),准备进入 Phase 2 清单与软件列表**。 优先路径: 1. 已完成 Phase 0:monorepo 骨架 + 双目标编译 + 空 Gio 窗口。 -2. 下一步 Phase 1:三大高风险原型(清单验签、ZIP 安全解压、原子切换回滚)。 -3. Phase 2-3:清单/列表 → 下载/安装。 +2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 +3. 下一步 Phase 2-3:清单/列表 → 下载/安装。 4. Phase 4-5:启动/更新/自更新 → 授权。 5. Phase 6:Win7 加固与双通道发布。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index fd65ec5..8b37135 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -149,6 +149,10 @@ soft_quay/ Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、路径、类型、重复项、entrypoint 与资源上限,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。 +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/真机做故障注入,当前结论不替代硬件级断电验证。 + 盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。 授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。 diff --git a/docs/api.md b/docs/api.md index 9182570..60f285e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -131,6 +131,20 @@ T-102 Phase 1 原型进一步固定: 记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps//`。 +### 2.5 安装切换事务(Phase 1 原型) + +`apps//install-transaction.json` 用于断电恢复: + +```json +{ + "schema_version": 1, + "phase": "staging_activated", + "had_current": true +} +``` + +phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollback_required`、`committed`。日志使用临时文件 + 同目录 backup 原子替换;恢复时同时检查日志与 current/staging/backup 实际状态。未完成健康检查的 current 不视为可信:有旧版时恢复 backup,首次安装则撤销 current。 + ## 3. 许可证(服务端签发 → 本地离线验证) ```json diff --git a/docs/current-state.md b/docs/current-state.md index d0074ba..0225620 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,24 +13,24 @@ ## 当前快照 - 日期:2026-07-16 -- 阶段:M2 进行中(Phase 1 的 T-101 清单验签与 T-102 ZIP 安全解压原型已完成) +- 阶段:M2 已完成(清单验签、ZIP 安全解压、staging/current/backup 切换与崩溃恢复原型全部验证) - 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;modern Gio v0.10.1 与 win7 Gio v0.6.0 已实际接入 -- 生产代码:core 已有状态/事件、Catalog 验签/缓存原型与 ZIP 两阶段安全解压器;modern/win7 均可打开最小 AppShell -- 测试:core 覆盖 Catalog 攻击/缓存与 ZIP 路径、类型、资源上限、entrypoint、CRC 失败清理;两个 app 覆盖 AppShell 与平台 stub +- 生产代码:core 已有状态/事件、Catalog 验签/缓存、ZIP 安全解压和安装事务切换/恢复原型;modern/win7 均可打开最小 AppShell +- 测试:core 覆盖 Catalog、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub - 数据:`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵 - 标准启动路径:`./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-103 +- 当前 blocker:无;下一步按路线图落成并领取 T-201 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0 四项与 T-101/T-102 已完成;T-103 待按路线图落成 | +| `docs/tasks/` | 已有 | Phase 0 与 Phase 1 的任务均已完成;T-201 待按路线图落成 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | -| `core/` | 已建 | Go 1.20 兼容;已有状态/事件、Catalog 验签/缓存和 ZIP 安全解压原型 | +| `core/` | 已建 | Go 1.20 兼容;已有状态/事件与 Phase 1 三项安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell | | `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell | | `schemas/` | 待建 | 协议 JSON Schema(T-201) | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`。 - 正在进行:无。 -- 下一个可领取任务:按路线图落成并领取 `T-103 staging/backup 原子切换与回滚原型`。 +- 下一个可领取任务:按路线图落成并领取 `T-201 catalog 模块正式接入`。 ## 当前可运行内容 diff --git a/docs/tasks/T-103.md b/docs/tasks/T-103.md new file mode 100644 index 0000000..8dfdccc --- /dev/null +++ b/docs/tasks/T-103.md @@ -0,0 +1,63 @@ +--- +id: T-103 +title: staging/current/backup 原子切换与崩溃恢复原型 +phase: 1 +deps: [T-102] +status: DONE +created: 2026-07-16 +issue: null +context_ref: 0ffeff63e138fcb800f0eee0a8a1d4167f20df09 +claim_branch: null +work_branch: agent/codex/T-103 +write_paths: + - docs/tasks/T-103.md + - core/installer/ + - docs/api.md + - docs/04-architecture.md + - docs/00-ai-start-here.md + - docs/current-state.md +--- + +## 问题 / 背景 + +安全解压只产生 staging;要让更新可用,还必须在 Windows 文件系统上把 staging 切换为 current,并保证任一步骤崩溃或健康检查失败后都能从磁盘恢复已知安全版本。仅靠内存状态无法覆盖断电/进程退出。 + +## 方案 + +1. 在 `core/installer` 建立固定目录布局与持久化事务日志 `install-transaction.json`。 +2. 切换阶段:`prepared → current_backed_up → staging_activated → committed`;健康失败进入 `rollback_required`。 +3. 每次阶段更新使用同目录临时文件 + journal backup 原子替换;目录切换只使用同一 app 根目录内的 rename。 +4. 成功健康检查后先写 committed,再清理 backup/journal;失败时把新 current 移出、恢复 backup,失败产物不保留为可启动 current。 +5. `Recover` 读取主 journal 或中断遗留 journal backup,结合 current/staging/backup 实际存在状态,安全完成回滚/撤销/提交清理。 +6. 测试通过内部 failpoint 在关键 rename/phase 后模拟进程崩溃,再用新的 Switcher 实例从磁盘恢复。 + +## 验收要点 + +- 有旧 current 的成功更新:新版本成为 current,backup/staging/journal 清理。 +- 健康检查失败:旧 current 恢复且返回稳定失败;无旧版本时不留下 current。 +- 在备份后、激活后、committed 后模拟崩溃,新实例 Recover 均得到确定结果。 +- journal 主文件缺失但 backup 存在时仍可恢复。 +- 不一致/路径为 symlink/缺 staging/已有未处理 backup 时拒绝,不猜测或删除目录外内容。 +- Go 1.20 core vet/test 与完整双目标闸门通过。 + +## 边界(不改什么) + +- 不实现进程退出检测、真实 EXE 健康协议、installed-app.json(T-202/T-302)。 +- 不跨卷移动目录;调用者必须把 staging/current/backup 放在同一 app 根目录。 +- 不清理 `data/`、`licenses/` 或 app 根目录外路径。 +- 不保留多个历史 backup;多版本备份策略留后续任务。 + +## 协作约束 + +未启用 Gitea;本任务在 `agent/codex/T-103` 分支串行执行。恢复策略以“未健康检查的新版本不成为可信 current”为最高原则。 + +## 执行记录 + +- 2026-07-16:在 `core/installer` 建立固定 app layout、安装事务日志、Switcher 与 Recover;所有 rename/remove 只允许 root 下的 current/staging/backup。 +- 2026-07-16:事务阶段落地为 `prepared → current_backed_up → staging_activated → committed`,健康失败进入 `rollback_required`;journal 使用临时文件、fsync、主文件/backup 替换。 +- 2026-07-16:健康成功先写 committed 再清理旧 backup;健康失败把新 current 移出并恢复旧版,首次安装失败则不留下 current。 +- 2026-07-16:Recover 使用新实例仅凭 journal(或 journal backup)与目录现实恢复;未健康检查的新版本默认回滚/撤销,committed 状态只做清理不降级。 +- 定向测试通过:成功更新、健康失败回滚、首次安装失败、current rename 后崩溃、staging rename 后崩溃、committed 后崩溃、rollback_required 后崩溃、journal backup、orphan backup、缺 staging、已有 backup、目录/journal symlink。 +- 2026-07-16:测试模拟的是进程在持久化步骤之间退出;真实断电下目录项落盘顺序与文件锁行为仍需 T-302/T-601 在 Windows VM/真机故障注入验证,不把本原型等同于硬件级断电证明。 +- 验证通过:Go 1.20.14 core vet/test。 +- 验证通过:`./scripts/verify_phase0.ps1`,包含 modern/win7 双目标构建、版本/边界与治理检查。