mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(recovery): close rollback and undo crash windows from second review
- Keep every undo backup on disk until the per-change progress record is persisted (restore by copy, then mark, then remove), so a crash between the restore and the progress write can no longer strand an unresumable transaction; completed-but-uncleaned steps are swept on retry - Record a Windows installer failure from the update helper (which runs outside the validated install directory and must not restore binaries itself) and let Guard roll back the release unit on its next launch instead of leaving a mixed install behind a clean-exit state - Make redo names unique per change (index + nanosecond stamp) so one undo touching the same target twice keeps both redo copies - Serialize startup-state read-modify-write cycles with a cross-process file lock and refuse transitions against a live foreign owner, closing the concurrent cold-start race - Reject file update transactions with missing backup hashes and require the release unit to list the primary executable; rollback errors on any unhashed entry instead of skipping verification
This commit is contained in:
1 parent
2845bbcc30
commit
2cfaef26d6
15 files changed
+544
-40
No files matched your search
@@ -237,6 +237,14 @@ func runLaunch(args []string) int {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
// An update helper that watched the installer fail records a marker (it
|
||||
// cannot roll back itself from outside the install directory). Restore the
|
||||
// previous release unit immediately instead of waiting for a crash loop.
|
||||
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
|
||||
} else if failure != nil && result.RolledBack {
|
||||
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
|
||||
}
|
||||
tracker := repair.NewStartupTracker("")
|
||||
useSafeMode := *safeMode
|
||||
if !*safeMode && tracker.SafeModeRecommended() {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
@@ -46,6 +48,19 @@ func run(args []string) int {
|
||||
}
|
||||
if err := runInstaller(installer, installDir); err != nil {
|
||||
logger.Printf("run installer: %v", err)
|
||||
// The desktop already exited cleanly, so nothing would notice this
|
||||
// failure: record it and relaunch through Guard, which rolls the
|
||||
// release unit back on startup (the helper itself runs from the cache
|
||||
// directory, outside the validated install, and must not restore
|
||||
// binaries directly).
|
||||
if markErr := repair.MarkUpdateApplyFailed("", err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunch(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if relaunch != "" {
|
||||
|
||||
+6
-3
@@ -70,9 +70,12 @@ also replaces (Windows and Linux) — or the application bundle (macOS). The
|
||||
backups remain until the replacement build reaches `healthy` or exits cleanly.
|
||||
If the replacement enters the startup failure threshold, Guard verifies every
|
||||
backup hash and restores all recorded binaries together before launching, so a
|
||||
rollback never produces a mixed-version install. Update metadata and hashes are
|
||||
stored under the Reasonix repair state; arbitrary backup or target paths are
|
||||
rejected.
|
||||
rollback never produces a mixed-version install. When the Windows installer
|
||||
itself fails after the desktop has exited, the update helper records the
|
||||
failure and relaunches Guard, which performs the same full rollback immediately
|
||||
instead of waiting for a crash loop. Update metadata and hashes are stored
|
||||
under the Reasonix repair state; arbitrary backup or target paths are rejected,
|
||||
and unhashed backups are refused.
|
||||
|
||||
## Optional AI assistance
|
||||
|
||||
|
||||
@@ -57,8 +57,9 @@ Guard 会显示不依赖 WebView 的系统原生恢复对话框。安全模式
|
||||
替换的 Guard/启动器二进制(Windows/Linux),或整个应用 Bundle(macOS)。只有新
|
||||
版本进入 `healthy` 或干净退出后才清理备份。新版本达到启动失败阈值时,Guard 会先
|
||||
校验全部备份哈希,再把记录的所有二进制一起恢复后启动,回滚不会留下新旧混装。
|
||||
更新元数据和哈希位于 Reasonix 修复状态目录;任何任意目标路径或目录外备份都会被
|
||||
拒绝。
|
||||
Windows 安装器在桌面退出后执行失败时,更新 helper 会记录失败并重新拉起 Guard,
|
||||
Guard 启动时立即执行同样的完整回滚,而不是等待崩溃循环。更新元数据和哈希位于
|
||||
Reasonix 修复状态目录;任何任意目标路径、目录外备份或缺失哈希的备份都会被拒绝。
|
||||
|
||||
## 可选 AI 辅助
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
// UpdateApplyFailure records that an update installer failed after the desktop
|
||||
// handed off and exited. The Windows update helper cannot roll back itself —
|
||||
// it runs from the cache directory, outside the validated Guard installation —
|
||||
// so it records this marker and relaunches Guard, which performs the rollback
|
||||
// from inside the install directory on its next start.
|
||||
type UpdateApplyFailure struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ToVersion string `json:"toVersion,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RecordedAt string `json:"recordedAt"`
|
||||
}
|
||||
|
||||
func updateApplyFailurePath() string {
|
||||
root := config.MemoryUserDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(root, "repair", "update-apply-failed.json")
|
||||
}
|
||||
|
||||
// MarkUpdateApplyFailed persists the installer-failure marker. It is written
|
||||
// by the update helper after the NSIS installer exits non-zero.
|
||||
func MarkUpdateApplyFailed(toVersion, reason string) error {
|
||||
path := updateApplyFailurePath()
|
||||
if path == "" {
|
||||
return fmt.Errorf("update apply failure: Reasonix state directory is unavailable")
|
||||
}
|
||||
failure := UpdateApplyFailure{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: toVersion,
|
||||
Reason: reason,
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
b, err := json.MarshalIndent(failure, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(path, append(b, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// ReadUpdateApplyFailure reports the recorded installer failure, if any.
|
||||
func ReadUpdateApplyFailure() (*UpdateApplyFailure, bool) {
|
||||
path := updateApplyFailurePath()
|
||||
if path == "" {
|
||||
return nil, false
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var failure UpdateApplyFailure
|
||||
if json.Unmarshal(b, &failure) != nil || failure.SchemaVersion != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return &failure, true
|
||||
}
|
||||
|
||||
// ClearUpdateApplyFailure removes the marker; a missing marker is not an error.
|
||||
func ClearUpdateApplyFailure() error {
|
||||
path := updateApplyFailurePath()
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverFailedInstall rolls back the pending update when an update helper
|
||||
// recorded an installer failure, restoring the previous release unit without
|
||||
// waiting for a crash loop. The marker is cleared once the rollback succeeded
|
||||
// (or when nothing was left to roll back); on rollback errors both the marker
|
||||
// and the pending transaction are kept so the next launch retries.
|
||||
func RecoverFailedInstall() (UpdateRollbackResult, *UpdateApplyFailure, error) {
|
||||
failure, ok := ReadUpdateApplyFailure()
|
||||
if !ok {
|
||||
return UpdateRollbackResult{}, nil, nil
|
||||
}
|
||||
result, err := RollbackPendingUpdate()
|
||||
if err != nil {
|
||||
return result, failure, err
|
||||
}
|
||||
if clearErr := ClearUpdateApplyFailure(); clearErr != nil {
|
||||
return result, failure, clearErr
|
||||
}
|
||||
return result, failure, nil
|
||||
}
|
||||
@@ -79,7 +79,29 @@ func (t *StartupTracker) SafeModeRecommended() bool {
|
||||
return nextFailureCount(state, t.now(), defaultCrashWindow) >= defaultFailureLimit
|
||||
}
|
||||
|
||||
// lock serializes the tracker's cross-process read-modify-write cycles. Lock
|
||||
// failures degrade to lock-free operation: startup must never be wedged by a
|
||||
// lock problem, and the pre-lock behavior is the acceptable fallback.
|
||||
func (t *StartupTracker) lock() func() {
|
||||
if t.path == "" {
|
||||
return func() {}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(t.path), 0o700); err != nil {
|
||||
return func() {}
|
||||
}
|
||||
unlock, err := lockStartupStateFile(t.path)
|
||||
if err != nil {
|
||||
return func() {}
|
||||
}
|
||||
return unlock
|
||||
}
|
||||
|
||||
func (t *StartupTracker) Begin(version string, safeMode bool) (StartupState, error) {
|
||||
// Two cold starts can race here before the Wails single-instance lock
|
||||
// exists; the file lock makes read-check-write atomic so the loser cannot
|
||||
// clobber the winner's record (the loser exits via os.Exit soon after).
|
||||
unlock := t.lock()
|
||||
defer unlock()
|
||||
now := t.now().UTC()
|
||||
previous, err := t.Read()
|
||||
if err != nil {
|
||||
@@ -146,10 +168,19 @@ func (t *StartupTracker) MarkFailed(err error) error {
|
||||
}
|
||||
|
||||
func (t *StartupTracker) transition(phase, message string) error {
|
||||
unlock := t.lock()
|
||||
defer unlock()
|
||||
state, err := t.Read()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
// Ownership: never rewrite a record owned by another live process (a
|
||||
// duplicate REASONIX_DEV instance, or a raced cold start about to exit).
|
||||
// Records left by dead owners may transition freely — Guard's post-rollback
|
||||
// MarkClean legitimately clears a crashed desktop's state.
|
||||
if state.PID > 0 && state.PID != os.Getpid() && t.processAlive(state.PID) {
|
||||
return nil
|
||||
}
|
||||
state.SchemaVersion = startupStateVersion
|
||||
state.Phase = phase
|
||||
state.Error = message
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows && !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris
|
||||
|
||||
package repair
|
||||
|
||||
// lockStartupStateFile is a no-op on platforms without file locking; the
|
||||
// tracker then behaves as before the cross-process serialization was added.
|
||||
func lockStartupStateFile(string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package repair
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// lockStartupStateFile takes an exclusive cross-process lock guarding the
|
||||
// startup-state read-modify-write cycle. The critical section is a handful of
|
||||
// small file operations, so a blocking lock cannot stall startup meaningfully.
|
||||
func lockStartupStateFile(path string) (func(), error) {
|
||||
f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
_ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package repair
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// lockStartupStateFile takes an exclusive cross-process lock guarding the
|
||||
// startup-state read-modify-write cycle (LockFileEx releases on process exit,
|
||||
// so a crashed holder can never wedge later launches).
|
||||
func lockStartupStateFile(path string) (func(), error) {
|
||||
f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle := windows.Handle(f.Fd())
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.LockFileEx(handle, windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &overlapped); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
_ = windows.UnlockFileEx(handle, 0, 1, 0, &overlapped)
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package repair
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -133,6 +135,71 @@ func TestStartupTrackerIgnoresDuplicateLaunchesWhileHealthy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartupTrackerBeginSerializesConcurrentColdStarts pins the file-lock
|
||||
// contract: racing cold starts must serialize their read-modify-write cycles,
|
||||
// so every Begin lands one increment instead of losing updates.
|
||||
func TestStartupTrackerBeginSerializesConcurrentColdStarts(t *testing.T) {
|
||||
now := time.Date(2026, 7, 14, 10, 0, 0, 0, time.UTC)
|
||||
tracker := NewStartupTracker(filepath.Join(t.TempDir(), "startup.json"))
|
||||
tracker.now = func() time.Time { return now }
|
||||
tracker.processAlive = func(int) bool { return false }
|
||||
const launches = 8
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < launches; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := tracker.Begin("v1", false); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
state, err := tracker.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.ConsecutiveFailures != launches {
|
||||
t.Fatalf("consecutive failures = %d, want %d (lost update under concurrency)", state.ConsecutiveFailures, launches)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartupTrackerTransitionRefusesLiveForeignOwner pins the ownership rule:
|
||||
// a process must not rewrite a startup record owned by another live process,
|
||||
// while records left by dead owners stay transitionable (Guard's post-rollback
|
||||
// MarkClean).
|
||||
func TestStartupTrackerTransitionRefusesLiveForeignOwner(t *testing.T) {
|
||||
tracker := NewStartupTracker(filepath.Join(t.TempDir(), "startup.json"))
|
||||
foreign := os.Getpid() + 1
|
||||
tracker.processAlive = func(pid int) bool { return pid == foreign }
|
||||
state := StartupState{SchemaVersion: 1, Phase: "starting", PID: foreign, ConsecutiveFailures: 1}
|
||||
if err := tracker.write(state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tracker.MarkClean(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := tracker.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Phase != "starting" || got.PID != foreign {
|
||||
t.Fatalf("live foreign owner's record was rewritten: %+v", got)
|
||||
}
|
||||
// Once the owner is dead the same transition must go through.
|
||||
tracker.processAlive = func(int) bool { return false }
|
||||
if err := tracker.MarkClean(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = tracker.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Phase != "clean-exit" || got.ConsecutiveFailures != 0 {
|
||||
t.Fatalf("dead owner's record was not transitioned: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupTrackerWithoutStateDirIsDisabled(t *testing.T) {
|
||||
tracker := &StartupTracker{now: time.Now, processAlive: func(int) bool { return false }}
|
||||
if _, err := tracker.Begin("v1", false); err != nil {
|
||||
|
||||
@@ -192,11 +192,19 @@ func UndoLastRepair() (*RepairTransaction, error) {
|
||||
for i := len(tx.Changes) - 1; i >= 0; i-- {
|
||||
change := tx.Changes[i]
|
||||
if change.Undone {
|
||||
// Progress was persisted but the backup removal may have been cut
|
||||
// short by a crash; finish the cleanup the completed step owed.
|
||||
if !change.RemoveOnUndo && change.PreviousPath != "" {
|
||||
_ = os.Remove(change.PreviousPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
redo := ""
|
||||
if _, err := os.Stat(change.TargetPath); err == nil {
|
||||
redo = change.TargetPath + ".reasonix-redo-" + now.Format("20060102T150405Z")
|
||||
// Index suffix keeps redo names unique when one undo touches the
|
||||
// same target twice (e.g. quarantine + snapshot restore): a shared
|
||||
// name would silently overwrite the earlier redo copy.
|
||||
redo = fmt.Sprintf("%s.reasonix-redo-%s-%d", change.TargetPath, now.Format("20060102T150405.000000000Z"), i)
|
||||
if err := os.Rename(change.TargetPath, redo); err != nil {
|
||||
return nil, fmt.Errorf("undo repair: retain current file: %w", err)
|
||||
}
|
||||
@@ -207,24 +215,19 @@ func UndoLastRepair() (*RepairTransaction, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isRestoreBackupPath(change.PreviousPath) {
|
||||
b, err := os.ReadFile(change.PreviousPath)
|
||||
if err == nil {
|
||||
err = fileutil.AtomicWriteFile(change.TargetPath, b, 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
if redo != "" {
|
||||
_ = os.Rename(redo, change.TargetPath)
|
||||
}
|
||||
return nil, fmt.Errorf("undo repair: restore %s: %w", change.TargetPath, err)
|
||||
}
|
||||
_ = os.Remove(change.PreviousPath)
|
||||
if err := markUndone(i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
// Restore by copy and keep the backup until the progress record is on
|
||||
// disk: consuming the backup first (rename or delete) would leave an
|
||||
// unresumable transaction if the process died before markUndone, since
|
||||
// the retry's preflight requires the backup of every un-undone change.
|
||||
b, err := os.ReadFile(change.PreviousPath)
|
||||
mode := os.FileMode(0o600)
|
||||
if st, statErr := os.Stat(change.PreviousPath); statErr == nil {
|
||||
mode = st.Mode().Perm()
|
||||
}
|
||||
if err := os.Rename(change.PreviousPath, change.TargetPath); err != nil {
|
||||
if err == nil {
|
||||
err = fileutil.AtomicWriteFile(change.TargetPath, b, mode)
|
||||
}
|
||||
if err != nil {
|
||||
if redo != "" {
|
||||
_ = os.Rename(redo, change.TargetPath)
|
||||
}
|
||||
@@ -233,6 +236,7 @@ func UndoLastRepair() (*RepairTransaction, error) {
|
||||
if err := markUndone(i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Remove(change.PreviousPath)
|
||||
}
|
||||
tx.Undone = true
|
||||
tx.UndoneAt = now.Format(time.RFC3339Nano)
|
||||
@@ -241,13 +245,3 @@ func UndoLastRepair() (*RepairTransaction, error) {
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func isRestoreBackupPath(path string) bool {
|
||||
root := config.MemoryUserDir()
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
restoreRoot := filepath.Join(root, "repair", "restore-backups")
|
||||
rel, err := filepath.Rel(filepath.Clean(restoreRoot), filepath.Clean(path))
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
@@ -9,6 +9,91 @@ import (
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// TestUndoLastRepairKeepsBackupUntilProgressPersisted pins the crash-window
|
||||
// contract: a change that was fully restored but whose progress record never
|
||||
// reached disk (simulated by an unmarked change whose target already matches
|
||||
// the backup) must remain retryable, and backups are only removed after the
|
||||
// per-change progress is persisted.
|
||||
func TestUndoLastRepairKeepsBackupUntilProgressPersisted(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
windowPath := filepath.Join(home, "desktop-window.json")
|
||||
quarantine := windowPath + ".reasonix-rebuild-20260714T000000Z"
|
||||
// Simulate a crash after the restore copy but before markUndone: the
|
||||
// target already holds the restored bytes, the backup still exists, and
|
||||
// the change is not marked undone.
|
||||
for path, body := range map[string]string{
|
||||
windowPath: "old-window",
|
||||
quarantine: "old-window",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tx := newRepairTransaction(time.Now())
|
||||
tx.Changes = []RepairChange{{Scope: "derived:window", TargetPath: windowPath, PreviousPath: quarantine}}
|
||||
if err := persistRepairTransaction(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
undone, err := UndoLastRepair()
|
||||
if err != nil {
|
||||
t.Fatalf("retry after simulated crash failed: %v", err)
|
||||
}
|
||||
if !undone.Undone {
|
||||
t.Fatalf("transaction not marked undone: %+v", undone)
|
||||
}
|
||||
if got, _ := os.ReadFile(windowPath); string(got) != "old-window" {
|
||||
t.Fatalf("window state = %q", got)
|
||||
}
|
||||
if _, err := os.Stat(quarantine); !os.IsNotExist(err) {
|
||||
t.Fatalf("backup not cleaned up after completed undo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoLastRepairKeepsDistinctRedoCopiesForSharedTarget pins that one undo
|
||||
// touching the same target twice (quarantine + snapshot restore) retains a
|
||||
// separate redo copy per change instead of silently overwriting the first.
|
||||
func TestUndoLastRepairKeepsDistinctRedoCopiesForSharedTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
configPath := config.UserConfigPath()
|
||||
quarantine := configPath + ".reasonix-quarantine-20260714T000000Z"
|
||||
restoreBackup := filepath.Join(home, "repair", "restore-backups", "repair-2.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(restoreBackup), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, body := range map[string]string{
|
||||
configPath: "current",
|
||||
quarantine: "original",
|
||||
restoreBackup: "pre-restore",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tx := newRepairTransaction(time.Now())
|
||||
tx.Changes = []RepairChange{
|
||||
{Scope: "global", TargetPath: configPath, PreviousPath: quarantine},
|
||||
{Scope: "global", TargetPath: configPath, PreviousPath: restoreBackup},
|
||||
}
|
||||
if err := persistRepairTransaction(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := UndoLastRepair(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(configPath); string(got) != "original" {
|
||||
t.Fatalf("config after undo = %q", got)
|
||||
}
|
||||
redos, err := filepath.Glob(configPath + ".reasonix-redo-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(redos) != 2 {
|
||||
t.Fatalf("redo copies = %v, want one per change", redos)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoLastRepairResumesAfterPartialFailure pins the recoverable-undo
|
||||
// contract: a multi-change undo that fails partway persists per-change
|
||||
// progress, and a retry finishes the remaining changes instead of failing the
|
||||
|
||||
@@ -256,10 +256,12 @@ func RollbackPendingUpdate() (UpdateRollbackResult, error) {
|
||||
}
|
||||
// Verify every backup before touching any binary: a partial restore
|
||||
// would recreate exactly the mixed-version install rollback exists to
|
||||
// prevent.
|
||||
// prevent. A missing hash is a validation failure, not a bypass —
|
||||
// ReadPendingUpdate already rejects hashless file transactions, so
|
||||
// this guards hand-crafted callers.
|
||||
for _, f := range files {
|
||||
if f.SHA256 == "" {
|
||||
continue
|
||||
if strings.TrimSpace(f.SHA256) == "" {
|
||||
return result, fmt.Errorf("rollback update: backup hash missing for %s", filepath.Base(f.TargetPath))
|
||||
}
|
||||
got, hashErr := hashFile(f.BackupPath)
|
||||
if hashErr != nil || !strings.EqualFold(got, f.SHA256) {
|
||||
@@ -366,11 +368,19 @@ func validateUpdateTransaction(tx *UpdateTransaction) error {
|
||||
if !insideRepairDir(tx.BackupPath) {
|
||||
return fmt.Errorf("pending update backup is outside the repair directory")
|
||||
}
|
||||
// Every restorable file must carry a hash — rollback promises to
|
||||
// verify all backups before touching any binary, so an unhashed entry
|
||||
// would silently weaken that gate.
|
||||
if strings.TrimSpace(tx.BackupSHA256) == "" {
|
||||
return fmt.Errorf("pending update backup hash is missing")
|
||||
}
|
||||
primaryListed := len(tx.Files) == 0
|
||||
for i := range tx.Files {
|
||||
f := &tx.Files[i]
|
||||
f.TargetPath = filepath.Clean(f.TargetPath)
|
||||
f.BackupPath = filepath.Clean(f.BackupPath)
|
||||
primary := f.TargetPath == tx.TargetPath
|
||||
primaryListed = primaryListed || primary
|
||||
if !allowedUpdateTargetBase(filepath.Base(f.TargetPath), primary) {
|
||||
return fmt.Errorf("pending update lists an unexpected release file")
|
||||
}
|
||||
@@ -380,6 +390,12 @@ func validateUpdateTransaction(tx *UpdateTransaction) error {
|
||||
if !insideRepairDir(f.BackupPath) {
|
||||
return fmt.Errorf("pending update backup is outside the repair directory")
|
||||
}
|
||||
if strings.TrimSpace(f.SHA256) == "" {
|
||||
return fmt.Errorf("pending update release file hash is missing")
|
||||
}
|
||||
}
|
||||
if !primaryListed {
|
||||
return fmt.Errorf("pending update release unit omits the primary executable")
|
||||
}
|
||||
case "app-bundle":
|
||||
if !strings.HasSuffix(strings.ToLower(tx.TargetPath), ".app") || tx.BackupPath != tx.TargetPath+".reasonix-update-backup" {
|
||||
|
||||
@@ -46,10 +46,12 @@ func TestPendingUpdateRejectsUnexpectedReleaseFile(t *testing.T) {
|
||||
originalExecutable := repairExecutable
|
||||
repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
|
||||
t.Cleanup(func() { repairExecutable = originalExecutable })
|
||||
const hash = "deadbeef"
|
||||
bad := []UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(dir, "evil.exe"), BackupPath: backup},
|
||||
{TargetPath: filepath.Join(t.TempDir(), "reasonix-guard"), BackupPath: backup},
|
||||
{TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: filepath.Join(t.TempDir(), "loose.previous")},
|
||||
{TargetPath: filepath.Join(dir, "evil.exe"), BackupPath: backup, SHA256: hash},
|
||||
{TargetPath: filepath.Join(t.TempDir(), "reasonix-guard"), BackupPath: backup, SHA256: hash},
|
||||
{TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: filepath.Join(t.TempDir(), "loose.previous"), SHA256: hash},
|
||||
{TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: backup}, // missing hash
|
||||
}
|
||||
for _, file := range bad {
|
||||
tx := &UpdateTransaction{
|
||||
@@ -58,7 +60,8 @@ func TestPendingUpdateRejectsUnexpectedReleaseFile(t *testing.T) {
|
||||
TargetKind: "file",
|
||||
TargetPath: target,
|
||||
BackupPath: backup,
|
||||
Files: []UpdateTransactionFile{{TargetPath: target, BackupPath: backup}, file},
|
||||
BackupSHA256: hash,
|
||||
Files: []UpdateTransactionFile{{TargetPath: target, BackupPath: backup, SHA256: hash}, file},
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if err := WritePendingUpdate(tx); err != nil {
|
||||
@@ -69,3 +72,45 @@ func TestPendingUpdateRejectsUnexpectedReleaseFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingUpdateRejectsHashlessOrPrimaryLessTransactions(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "reasonix-desktop")
|
||||
backup := filepath.Join(home, "repair", "updates", "reasonix-desktop.previous")
|
||||
if err := os.MkdirAll(filepath.Dir(backup), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(backup, []byte("old"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalExecutable := repairExecutable
|
||||
repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
|
||||
t.Cleanup(func() { repairExecutable = originalExecutable })
|
||||
guardBackup := filepath.Join(home, "repair", "updates", "reasonix-guard.previous")
|
||||
txs := map[string]*UpdateTransaction{
|
||||
"missing primary hash": {
|
||||
SchemaVersion: 1, ToVersion: "v2", TargetKind: "file",
|
||||
TargetPath: target, BackupPath: backup,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
"release unit omits primary executable": {
|
||||
SchemaVersion: 1, ToVersion: "v2", TargetKind: "file",
|
||||
TargetPath: target, BackupPath: backup, BackupSHA256: "deadbeef",
|
||||
Files: []UpdateTransactionFile{{TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: guardBackup, SHA256: "deadbeef"}},
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
}
|
||||
for name, tx := range txs {
|
||||
if err := WritePendingUpdate(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadPendingUpdate(); err == nil {
|
||||
t.Fatalf("%s: transaction was accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,79 @@ func TestCancelPendingUpdateRemovesReleaseUnitBackups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverFailedInstallRollsBackAndClearsMarker pins the Windows helper
|
||||
// handoff contract: an installer failure recorded by the update helper makes
|
||||
// Guard restore the release unit on its next launch, clearing both the marker
|
||||
// and the pending transaction.
|
||||
func TestRecoverFailedInstallRollsBackAndClearsMarker(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "reasonix-desktop")
|
||||
guard := filepath.Join(dir, "reasonix-guard")
|
||||
originalExecutable := repairExecutable
|
||||
repairExecutable = func() (string, error) { return guard, nil }
|
||||
t.Cleanup(func() { repairExecutable = originalExecutable })
|
||||
for path, body := range map[string]string{target: "old-desktop", guard: "old-guard"} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := PrepareFileUpdate("v1", "v2", target, guard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a partial NSIS run followed by the helper's failure marker.
|
||||
if err := os.WriteFile(guard, []byte("new-guard"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MarkUpdateApplyFailed("v2", "installer exited with 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, failure, err := RecoverFailedInstall()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failure == nil || !result.RolledBack || result.ToVersion != "v1" {
|
||||
t.Fatalf("recover result = %+v failure = %+v", result, failure)
|
||||
}
|
||||
for path, want := range map[string]string{target: "old-desktop", guard: "old-guard"} {
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("restored %s = %q (%v), want %q", filepath.Base(path), got, err, want)
|
||||
}
|
||||
}
|
||||
if _, ok := ReadUpdateApplyFailure(); ok {
|
||||
t.Fatal("failure marker survived a successful rollback")
|
||||
}
|
||||
if HasPendingUpdate() {
|
||||
t.Fatal("pending update survived the rollback")
|
||||
}
|
||||
// Subsequent launches are a no-op.
|
||||
if result, failure, err := RecoverFailedInstall(); err != nil || failure != nil || result.RolledBack {
|
||||
t.Fatalf("second recover = %+v %+v %v", result, failure, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stale marker with nothing to roll back must be cleared, not retried
|
||||
// forever.
|
||||
func TestRecoverFailedInstallClearsStaleMarker(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
if err := MarkUpdateApplyFailed("v2", "installer exited with 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, failure, err := RecoverFailedInstall()
|
||||
if err != nil || failure == nil || result.RolledBack {
|
||||
t.Fatalf("recover = %+v %+v %v", result, failure, err)
|
||||
}
|
||||
if _, ok := ReadUpdateApplyFailure(); ok {
|
||||
t.Fatal("stale marker was not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthyUpdateRemovesBackup(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
|
||||
Reference in new issue
Block a user