From f8b2580cb4db58039c3fb89cb8e157c423fcbb94 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 07:24:46 +0200 Subject: [PATCH] fix(backup): refuse incomplete restore points after interruption --- .changeset/interrupted-restore-point.md | 5 + go/internal/backup/archive.go | 79 +++++++--- go/internal/backup/archive_test.go | 9 ++ go/internal/backup/restore_crash_test.go | 158 ++++++++++++++++++++ go/internal/backup/restore_point.go | 142 ++++++++++++++++++ go/internal/backup/restore_point_test.go | 174 +++++++++++++++++++++++ 6 files changed, 545 insertions(+), 22 deletions(-) create mode 100644 .changeset/interrupted-restore-point.md create mode 100644 go/internal/backup/restore_crash_test.go create mode 100644 go/internal/backup/restore_point.go create mode 100644 go/internal/backup/restore_point_test.go diff --git a/.changeset/interrupted-restore-point.md b/.changeset/interrupted-restore-point.md new file mode 100644 index 000000000..ae763f267 --- /dev/null +++ b/.changeset/interrupted-restore-point.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Record complete restore points before activation. Refuse incomplete or older unverified points before reverting, and retain moved data if rollback fails. Full backups from older releases still import normally. diff --git a/go/internal/backup/archive.go b/go/internal/backup/archive.go index b3938a867..9b5e119a1 100644 --- a/go/internal/backup/archive.go +++ b/go/internal/backup/archive.go @@ -34,6 +34,8 @@ const ( var ensureBackupDiskSpace = state.EnsureDiskSpace +var moveRestoreEntry = os.Rename + // ComponentInventory records the independently versioned runtime that created // the backup. Driver artifacts themselves are also captured under data/. type ComponentInventory struct { @@ -96,8 +98,9 @@ type Info struct { } type RestoreResult struct { - DataDir string `json:"data_dir"` - SafetyDir string `json:"safety_dir,omitempty"` + DataDir string `json:"data_dir"` + SafetyDir string `json:"safety_dir,omitempty"` + SafetyComplete bool `json:"safety_complete"` } // Inspect performs a full verification and returns stable archive metadata. @@ -867,6 +870,9 @@ func RestoreContents(archivePath, dataDir string, now time.Time) (RestoreResult, if !info.IsDir() { return RestoreResult{}, fmt.Errorf("backup: restore target is not a directory: %s", dataDir) } + if err := checkRestorePointsBeforeRestore(dataDir); err != nil { + return RestoreResult{}, err + } manifest, err := verifyInWorkspace(archivePath, dataDir) if err != nil { return RestoreResult{}, err @@ -900,27 +906,33 @@ func RestoreContents(archivePath, dataDir string, now time.Time) (RestoreResult, _ = os.Remove(safetyDir) return RestoreResult{}, err } + if err := writeRestorePoint(safetyDir, current, false); err != nil { + return RestoreResult{}, fmt.Errorf("backup: record previous file list: %w", err) + } movedCurrent := make([]string, 0, len(current)) for _, name := range current { - if err := os.Rename(filepath.Join(dataDir, name), filepath.Join(safetyDir, name)); err != nil { - rollbackErr := restoreMoveSet(safetyDir, dataDir, movedCurrent) - _ = os.Remove(safetyDir) + if err := moveRestoreEntry(filepath.Join(dataDir, name), filepath.Join(safetyDir, name)); err != nil { + rollbackErr := rollbackRestorePoint(safetyDir, dataDir, movedCurrent) return RestoreResult{}, errors.Join(fmt.Errorf("backup: preserve current %s: %w", name, err), rollbackErr) } movedCurrent = append(movedCurrent, name) } + if err := completeRestorePoint(dataDir, safetyDir, current); err != nil { + rollbackErr := rollbackRestorePoint(safetyDir, dataDir, movedCurrent) + return RestoreResult{}, errors.Join(fmt.Errorf("backup: confirm previous files: %w", err), rollbackErr) + } restored, err := topLevelEntries(staging) if err != nil { - rollbackErr := restoreMoveSet(safetyDir, dataDir, movedCurrent) + rollbackErr := rollbackRestorePoint(safetyDir, dataDir, movedCurrent) return RestoreResult{}, errors.Join(err, rollbackErr) } activated := make([]string, 0, len(restored)) for _, name := range restored { - if err := os.Rename(filepath.Join(staging, name), filepath.Join(dataDir, name)); err != nil { + if err := moveRestoreEntry(filepath.Join(staging, name), filepath.Join(dataDir, name)); err != nil { rollbackErr := errors.Join( restoreMoveSet(dataDir, staging, activated), - restoreMoveSet(safetyDir, dataDir, movedCurrent), + rollbackRestorePoint(safetyDir, dataDir, movedCurrent), ) return RestoreResult{}, errors.Join(fmt.Errorf("backup: activate restored %s: %w", name, err), rollbackErr) } @@ -929,12 +941,12 @@ func RestoreContents(archivePath, dataDir string, now time.Time) (RestoreResult, if err := syncDir(dataDir); err != nil { rollbackErr := errors.Join( restoreMoveSet(dataDir, staging, activated), - restoreMoveSet(safetyDir, dataDir, movedCurrent), + rollbackRestorePoint(safetyDir, dataDir, movedCurrent), syncDir(dataDir), ) return RestoreResult{}, errors.Join(fmt.Errorf("backup: sync activated restore: %w", err), rollbackErr) } - return RestoreResult{DataDir: dataDir, SafetyDir: safetyDir}, nil + return RestoreResult{DataDir: dataDir, SafetyDir: safetyDir, SafetyComplete: true}, nil } // RevertContents puts a RestoreContents safety directory back into service. @@ -953,12 +965,16 @@ func RevertContents(dataDir, safetyDir string) (RestoreResult, error) { if filepath.Dir(safetyDir) != dataDir || !strings.HasPrefix(filepath.Base(safetyDir), ".ftw-pre-restore-") { return RestoreResult{}, errors.New("backup: safety directory is not a direct FTW restore point") } - if info, statErr := os.Stat(safetyDir); statErr != nil || !info.IsDir() { + if info, statErr := os.Lstat(safetyDir); statErr != nil || !info.IsDir() { if statErr != nil { return RestoreResult{}, statErr } return RestoreResult{}, errors.New("backup: safety path is not a directory") } + if err := verifyRestorePoint(safetyDir); err != nil { + return RestoreResult{}, err + } + currentComplete := currentRestoreStateComplete(dataDir) rejected, err := os.MkdirTemp(dataDir, ".ftw-rejected-restore-") if err != nil { return RestoreResult{}, err @@ -973,30 +989,49 @@ func RevertContents(dataDir, safetyDir string) (RestoreResult, error) { if err != nil { return RestoreResult{}, err } + if err := writeRestorePoint(rejected, current, false); err != nil { + return RestoreResult{}, fmt.Errorf("backup: record rejected file list: %w", err) + } + // Retain moved data even if an error prevents rollback. A failed revert + // must never delete readings that are absent from the source archive. + keepRejected = true movedCurrent := make([]string, 0, len(current)) for _, name := range current { - if err := os.Rename(filepath.Join(dataDir, name), filepath.Join(rejected, name)); err != nil { - rollbackErr := restoreMoveSet(rejected, dataDir, movedCurrent) + if err := moveRestoreEntry(filepath.Join(dataDir, name), filepath.Join(rejected, name)); err != nil { + rollbackErr := rollbackRestorePoint(rejected, dataDir, movedCurrent) return RestoreResult{}, errors.Join(fmt.Errorf("backup: stage rejected %s: %w", name, err), rollbackErr) } movedCurrent = append(movedCurrent, name) } + if err := errors.Join(syncDir(dataDir), syncDir(rejected)); err != nil { + rollbackErr := rollbackRestorePoint(rejected, dataDir, movedCurrent) + return RestoreResult{}, errors.Join(fmt.Errorf("backup: confirm rejected files: %w", err), rollbackErr) + } + if currentComplete { + if err := writeRestorePoint(rejected, current, true); err != nil { + rollbackErr := rollbackRestorePoint(rejected, dataDir, movedCurrent) + return RestoreResult{}, errors.Join(fmt.Errorf("backup: confirm rejected file list: %w", err), rollbackErr) + } + } previous, err := topLevelEntries(safetyDir) if err != nil { - rollbackErr := restoreMoveSet(rejected, dataDir, movedCurrent) + rollbackErr := rollbackRestorePoint(rejected, dataDir, movedCurrent) return RestoreResult{}, errors.Join(err, rollbackErr) } restored := make([]string, 0, len(previous)) for _, name := range previous { - if err := os.Rename(filepath.Join(safetyDir, name), filepath.Join(dataDir, name)); err != nil { - rollbackErr := errors.Join( - restoreMoveSet(dataDir, safetyDir, restored), - restoreMoveSet(rejected, dataDir, movedCurrent), - ) + if err := moveRestoreEntry(filepath.Join(safetyDir, name), filepath.Join(dataDir, name)); err != nil { + rollbackErr := restoreMoveSet(dataDir, safetyDir, restored) + if rollbackErr == nil { + rollbackErr = rollbackRestorePoint(rejected, dataDir, movedCurrent) + } return RestoreResult{}, errors.Join(fmt.Errorf("backup: reactivate previous %s: %w", name, err), rollbackErr) } restored = append(restored, name) } + if err := os.Remove(filepath.Join(safetyDir, restorePointFile)); err != nil { + return RestoreResult{DataDir: dataDir, SafetyDir: rejected}, fmt.Errorf("backup: previous data is active but rejected restore remains at %s: %w", rejected, err) + } if err := os.Remove(safetyDir); err != nil { keepRejected = true return RestoreResult{DataDir: dataDir, SafetyDir: rejected}, fmt.Errorf("backup: previous data is active but rejected restore remains at %s: %w", rejected, err) @@ -1009,7 +1044,7 @@ func RevertContents(dataDir, safetyDir string) (RestoreResult, error) { if err := syncDir(dataDir); err != nil { return RestoreResult{}, err } - return RestoreResult{DataDir: dataDir, SafetyDir: safetyDir}, nil + return RestoreResult{DataDir: dataDir, SafetyDir: safetyDir, SafetyComplete: currentComplete}, nil } func topLevelEntries(dir string, exclude ...string) ([]string, error) { @@ -1035,7 +1070,7 @@ func topLevelEntries(dir string, exclude ...string) ([]string, error) { } func isRestoreInternalName(name string) bool { - return strings.HasPrefix(name, ".ftw-restore-stage-") || + return name == restorePointFile || strings.HasPrefix(name, ".ftw-restore-stage-") || strings.HasPrefix(name, ".ftw-pre-restore-") || strings.HasPrefix(name, ".ftw-rejected-restore-") } @@ -1043,7 +1078,7 @@ func isRestoreInternalName(name string) bool { func restoreMoveSet(fromDir, toDir string, names []string) error { var result error for i := len(names) - 1; i >= 0; i-- { - if err := os.Rename(filepath.Join(fromDir, names[i]), filepath.Join(toDir, names[i])); err != nil { + if err := moveRestoreEntry(filepath.Join(fromDir, names[i]), filepath.Join(toDir, names[i])); err != nil { result = errors.Join(result, fmt.Errorf("backup: roll back move for %s: %w", names[i], err)) } } diff --git a/go/internal/backup/archive_test.go b/go/internal/backup/archive_test.go index bb2ac7431..badf6e28a 100644 --- a/go/internal/backup/archive_test.go +++ b/go/internal/backup/archive_test.go @@ -440,6 +440,15 @@ func TestRestoreContentsAndRevertPreserveBothStates(t *testing.T) { if err != nil || string(got) != "generation: backup\n" { t.Fatalf("rejected restore not retained = %q err=%v", got, err) } + // The rejected state is itself a complete point, so another revert can + // switch back without accepting a stale list from the previous state. + if _, err := RevertContents(dataDir, reverted.SafetyDir); err != nil { + t.Fatal(err) + } + got, err = os.ReadFile(filepath.Join(dataDir, "config.yaml")) + if err != nil || string(got) != "generation: backup\n" { + t.Fatalf("second revert config = %q err=%v", got, err) + } } func writeTestFile(t *testing.T, filename, body string) { diff --git a/go/internal/backup/restore_crash_test.go b/go/internal/backup/restore_crash_test.go new file mode 100644 index 000000000..b03664de3 --- /dev/null +++ b/go/internal/backup/restore_crash_test.go @@ -0,0 +1,158 @@ +//go:build !windows + +package backup + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +func TestRestoreCrashChild(t *testing.T) { + phase := os.Getenv("FTW_RESTORE_CRASH_PHASE") + if phase == "" { + return + } + data := os.Getenv("FTW_RESTORE_CRASH_DATA") + marker := os.Getenv("FTW_RESTORE_CRASH_MARKER") + realMove := moveRestoreEntry + moved := 0 + moveRestoreEntry = func(from, to string) error { + if err := realMove(from, to); err != nil { + return err + } + preserving := filepath.Dir(from) == data && strings.HasPrefix(filepath.Base(filepath.Dir(to)), ".ftw-pre-restore-") + activating := filepath.Dir(to) == data && strings.HasPrefix(filepath.Base(filepath.Dir(from)), ".ftw-restore-stage-") + if (phase == "preserving" && preserving) || (phase == "activating" && activating) { + moved++ + if moved == 64 { + if err := os.WriteFile(marker, []byte(filepath.Dir(to)), 0o600); err != nil { + return err + } + // Hold a real, partly completed filesystem transaction for the + // parent's SIGKILL without depending on scheduler speed. + for { + time.Sleep(time.Minute) + } + } + } + return nil + } + if _, err := RestoreContents(os.Getenv("FTW_RESTORE_CRASH_ARCHIVE"), data, time.Now()); err != nil { + t.Fatal(err) + } + t.Fatal("restore completed without reaching the crash window") +} + +func TestRestoreProcessCrashPreservesDataAndRefusesPartialPoints(t *testing.T) { + for _, phase := range []string{"preserving", "activating"} { + t.Run(phase, func(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source") + if err := os.Mkdir(source, 0o700); err != nil { + t.Fatal(err) + } + st, err := state.Open(filepath.Join(source, "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.SaveConfig("goal", "synthetic saved goal"); err != nil { + t.Fatal(err) + } + for i := range 128 { + writeTestFile(t, filepath.Join(source, fmt.Sprintf("%04d-new", i)), "new data") + } + archive, err := Create(context.Background(), CreateOptions{State: st, StatePath: filepath.Join(source, "state.db"), DataDir: source, OutputDir: filepath.Join(root, "backups")}) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + data := filepath.Join(root, "target") + for i := range 128 { + writeTestFile(t, filepath.Join(data, fmt.Sprintf("%04d-old", i)), "original data") + } + marker := filepath.Join(root, "crash-window") + child := exec.Command(os.Args[0], "-test.run=^TestRestoreCrashChild$", "-test.count=1") + child.Env = append(os.Environ(), "FTW_RESTORE_CRASH_PHASE="+phase, "FTW_RESTORE_CRASH_DATA="+data, "FTW_RESTORE_CRASH_ARCHIVE="+archive.Path, "FTW_RESTORE_CRASH_MARKER="+marker) + log, err := os.Create(filepath.Join(root, "child.log")) + if err != nil { + t.Fatal(err) + } + defer log.Close() + child.Stdout, child.Stderr = log, log + if err := child.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = child.Process.Kill() }) + deadline := time.Now().Add(30 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + if time.Now().After(deadline) { + body, _ := os.ReadFile(filepath.Join(root, "child.log")) + t.Fatalf("crash window not reached: %s", body) + } + time.Sleep(time.Millisecond) + } + if err := child.Process.Kill(); err != nil { + t.Fatal(err) + } + if err := child.Wait(); err == nil { + t.Fatal("child survived SIGKILL") + } else if status, ok := child.ProcessState.Sys().(syscall.WaitStatus); !ok || !status.Signaled() || status.Signal() != syscall.SIGKILL { + t.Fatalf("child did not terminate through SIGKILL: %v", child.ProcessState) + } + points, err := filepath.Glob(filepath.Join(data, ".ftw-pre-restore-*")) + if err != nil || len(points) != 1 { + t.Fatalf("missing retained point: %v %v", points, err) + } + point := points[0] + if phase == "preserving" { + if _, err := RevertContents(data, point); err == nil { + t.Fatal("partial preservation was accepted") + } + if _, err := RestoreContents(archive.Path, data, time.Now()); err == nil { + t.Fatal("partial active root was certified by a retry") + } + } else { + result, err := RevertContents(data, point) + if err != nil || result.SafetyComplete { + t.Fatalf("complete source did not recover with partial target retained: %+v %v", result, err) + } + if _, err := RevertContents(data, point); err == nil { + t.Fatal("partial target was accepted for reactivation") + } + } + for i := range 128 { + name := fmt.Sprintf("%04d-old", i) + atRoot, rootErr := os.ReadFile(filepath.Join(data, name)) + atPoint, pointErr := os.ReadFile(filepath.Join(point, name)) + if phase == "activating" && (rootErr != nil || string(atRoot) != "original data") { + t.Fatalf("original %s not active after recovery", name) + } + if (rootErr != nil || string(atRoot) != "original data") && (pointErr != nil || string(atPoint) != "original data") { + t.Fatalf("original %s lost after %s crash", name, phase) + } + } + fresh := filepath.Join(root, "fresh-recovery") + if err := os.Mkdir(fresh, 0o700); err != nil { + t.Fatal(err) + } + if _, err := RestoreContents(archive.Path, fresh, time.Now()); err != nil { + t.Fatal("full backup did not recover on a separate copy:", err) + } + }) + } +} diff --git a/go/internal/backup/restore_point.go b/go/internal/backup/restore_point.go new file mode 100644 index 000000000..e795b9bf0 --- /dev/null +++ b/go/internal/backup/restore_point.go @@ -0,0 +1,142 @@ +package backup + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +const restorePointFile = ".ftw-restore-point.json" + +type restorePoint struct { + Schema int `json:"schema"` + Complete bool `json:"complete"` + Entries []string `json:"entries"` +} + +// Record the file list before moving anything. A failed or interrupted write +// remains unconfirmed; revert must not treat it as a complete previous state. +func writeRestorePoint(dir string, entries []string, complete bool) error { + file, err := os.OpenFile(filepath.Join(dir, restorePointFile), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + writeErr := json.NewEncoder(file).Encode(restorePoint{Schema: 1, Complete: complete, Entries: entries}) + if writeErr == nil { + writeErr = file.Sync() + } + return errors.Join(writeErr, file.Close(), syncDir(dir)) +} + +func completeRestorePoint(dataDir, pointDir string, entries []string) error { + if err := errors.Join(syncDir(dataDir), syncDir(pointDir)); err != nil { + return err + } + return writeRestorePoint(pointDir, entries, true) +} + +func verifyRestorePoint(dir string) error { + refuse := func(err error) error { + return fmt.Errorf("backup: restore point is incomplete or unverified; keep FTW stopped, retain both directories and recover from a verified full backup: %w", err) + } + name := filepath.Join(dir, restorePointFile) + info, err := os.Lstat(name) + if err != nil { + return refuse(err) + } + if !info.Mode().IsRegular() || info.Size() > 16<<20 { + return refuse(errors.New("invalid restore point record")) + } + file, err := os.Open(name) + if err != nil { + return refuse(err) + } + defer file.Close() + var point restorePoint + decoder := json.NewDecoder(io.LimitReader(file, 16<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&point); err != nil { + return refuse(err) + } + if err := decoder.Decode(new(any)); err != io.EOF { + return refuse(errors.New("invalid trailing restore point data")) + } + if point.Schema != 1 || !point.Complete { + return refuse(errors.New("previous files were not fully preserved")) + } + for i, entry := range point.Entries { + if entry == "" || entry == "." || entry == ".." || strings.ContainsAny(entry, `/\`) || + isRestoreInternalName(entry) || (i > 0 && entry <= point.Entries[i-1]) { + return refuse(errors.New("invalid previous file list")) + } + } + entries, err := topLevelEntries(dir) + if err != nil { + return refuse(err) + } + if !slices.Equal(entries, point.Entries) { + return refuse(errors.New("previous files do not match their recorded list")) + } + return nil +} + +func hasRestoreStaging(entries []os.DirEntry) bool { + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".ftw-restore-stage-") { + return true + } + } + return false +} + +// A retry must not certify a partially moved active directory as a new, +// complete previous state. Interrupted extraction alone has no safety point +// and can still be retried. Unrecorded older points require a separate target. +func checkRestorePointsBeforeRestore(dataDir string) error { + entries, err := os.ReadDir(dataDir) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".ftw-pre-restore-") && !strings.HasPrefix(entry.Name(), ".ftw-rejected-restore-") { + continue + } + point := filepath.Join(dataDir, entry.Name()) + if !entry.IsDir() { + return fmt.Errorf("backup: unresolved restore point %s; retain the data and restore a full backup into a separate directory", point) + } + if err := verifyRestorePoint(point); err != nil { + return fmt.Errorf("backup: unresolved restore point %s; retain the data and restore a full backup into a separate directory: %w", point, err) + } + } + return nil +} + +func currentRestoreStateComplete(dataDir string) bool { + entries, err := os.ReadDir(dataDir) + return err == nil && !hasRestoreStaging(entries) && checkRestorePointsBeforeRestore(dataDir) == nil +} + +func rollbackRestorePoint(pointDir, dataDir string, moved []string) error { + if err := restoreMoveSet(pointDir, dataDir, moved); err != nil { + return err + } + // Only our record may be removed here. Never clean up retained file data + // merely because a rollback appeared to finish. + entries, err := os.ReadDir(pointDir) + if err != nil { + return err + } + if len(entries) != 1 || entries[0].Name() != restorePointFile { + return errors.New("rollback left files in its retained restore point") + } + if err := os.Remove(filepath.Join(pointDir, restorePointFile)); err != nil { + return err + } + return os.Remove(pointDir) +} diff --git a/go/internal/backup/restore_point_test.go b/go/internal/backup/restore_point_test.go new file mode 100644 index 000000000..9ffef0a32 --- /dev/null +++ b/go/internal/backup/restore_point_test.go @@ -0,0 +1,174 @@ +package backup + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRevertRejectsInterruptedPreservationWithoutMovingFiles(t *testing.T) { + for _, receipt := range []string{"", `{"schema":1,"complete":false,"entries":["first","second"]}`} { + t.Run(receipt, func(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "first"), "original first") + writeTestFile(t, filepath.Join(data, "second"), "original second") + if receipt != "" { + writeTestFile(t, filepath.Join(safety, ".ftw-restore-point.json"), receipt) + } + if _, err := RevertContents(data, safety); err == nil { + t.Fatal("revert accepted an incomplete or unrecorded restore point") + } + // An older unrecorded point is also refused without staging: an + // older error path may have cleaned staging after a failed rollback. + if _, err := RestoreContents("unused.ftwbak", data, time.Time{}); err == nil || !strings.Contains(err.Error(), "unresolved restore point") { + t.Fatalf("retry did not reject the incomplete root before import: %v", err) + } + for path, want := range map[string]string{ + filepath.Join(safety, "first"): "original first", + filepath.Join(data, "second"): "original second", + } { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("rejected revert changed %s: %q %v", path, got, err) + } + } + }) + } +} + +func TestRevertRecoversCompleteOriginalFromInterruptedActivation(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "old"), "complete original") + writeTestFile(t, filepath.Join(data, "new"), "partly activated") + if err := os.Mkdir(filepath.Join(data, ".ftw-restore-stage-proof"), 0o700); err != nil { + t.Fatal(err) + } + if err := writeRestorePoint(safety, []string{"old"}, true); err != nil { + t.Fatal(err) + } + result, err := RevertContents(data, safety) + if err != nil || result.SafetyComplete { + t.Fatalf("recovery certified the incomplete rejected state: %+v %v", result, err) + } + if got, err := os.ReadFile(filepath.Join(data, "old")); err != nil || string(got) != "complete original" { + t.Fatalf("original not recovered: %q %v", got, err) + } + if _, err := RevertContents(data, result.SafetyDir); err == nil { + t.Fatal("revert accepted the retained incomplete target") + } + if got, err := os.ReadFile(filepath.Join(result.SafetyDir, "new")); err != nil || string(got) != "partly activated" { + t.Fatalf("incomplete target not retained: %q %v", got, err) + } +} + +func TestRevertKeepsRejectedDataWhenRollbackCannotMoveIt(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "old"), "original data") + writeTestFile(t, filepath.Join(data, "new"), "new readings absent from the backup") + if err := writeRestorePoint(safety, []string{"old"}, true); err != nil { + t.Fatal(err) + } + realMove := moveRestoreEntry + t.Cleanup(func() { moveRestoreEntry = realMove }) + moveRestoreEntry = func(from, to string) error { + if from == filepath.Join(safety, "old") || strings.HasPrefix(filepath.Base(filepath.Dir(from)), ".ftw-rejected-restore-") { + return errors.New("injected move failure") + } + return realMove(from, to) + } + if _, err := RevertContents(data, safety); err == nil { + t.Fatal("revert unexpectedly succeeded") + } + rejected, err := filepath.Glob(filepath.Join(data, ".ftw-rejected-restore-*", "new")) + if err != nil || len(rejected) != 1 { + t.Fatalf("failed rollback lost the current data: %v %v", rejected, err) + } + if got, err := os.ReadFile(rejected[0]); err != nil || string(got) != "new readings absent from the backup" { + t.Fatalf("failed rollback changed the current data: %q %v", got, err) + } + if got, err := os.ReadFile(filepath.Join(safety, "old")); err != nil || string(got) != "original data" { + t.Fatalf("failed rollback changed original data: %q %v", got, err) + } +} + +func TestFailedRevertRollbackDoesNotOverwriteTheOriginal(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "a-shared"), "original value") + writeTestFile(t, filepath.Join(safety, "b-fail"), "other original") + writeTestFile(t, filepath.Join(data, "a-shared"), "new value") + if err := writeRestorePoint(safety, []string{"a-shared", "b-fail"}, true); err != nil { + t.Fatal(err) + } + realMove := moveRestoreEntry + t.Cleanup(func() { moveRestoreEntry = realMove }) + moveRestoreEntry = func(from, to string) error { + if from == filepath.Join(safety, "b-fail") || (from == filepath.Join(data, "a-shared") && to == filepath.Join(safety, "a-shared")) { + return errors.New("injected move failure") + } + return realMove(from, to) + } + if _, err := RevertContents(data, safety); err == nil { + t.Fatal("revert unexpectedly succeeded") + } + if got, err := os.ReadFile(filepath.Join(data, "a-shared")); err != nil || string(got) != "original value" { + t.Fatalf("rollback overwrote the original: %q %v", got, err) + } + rejected, err := filepath.Glob(filepath.Join(data, ".ftw-rejected-restore-*", "a-shared")) + if err != nil || len(rejected) != 1 { + t.Fatalf("new value not retained: %v %v", rejected, err) + } + if got, err := os.ReadFile(rejected[0]); err != nil || string(got) != "new value" { + t.Fatalf("new value changed: %q %v", got, err) + } +} + +func TestRevertRejectsMalformedAndLinkedRecords(t *testing.T) { + for _, body := range []string{ + `{"schema":1,"complete":true,"entries":["../old"]}`, + `{"schema":1,"complete":true,"entries":["old","old"]}`, + `{"schema":1,"complete":true,"entries":["old"]} trailing`, + `{"schema":2,"complete":true,"entries":["old"]}`, + } { + t.Run(body, func(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "old"), "old") + writeTestFile(t, filepath.Join(safety, restorePointFile), body) + if _, err := RevertContents(data, safety); err == nil { + t.Fatal("revert accepted a malformed record") + } + }) + } + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "old"), "old") + external := filepath.Join(t.TempDir(), "external.json") + writeTestFile(t, external, `{"schema":1,"complete":true,"entries":["old"]}`) + if err := os.Symlink(external, filepath.Join(safety, restorePointFile)); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := RevertContents(data, safety); err == nil { + t.Fatal("revert accepted a linked record") + } +} + +func TestRevertRejectsMissingRecordedEntry(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "first"), "original first") + writeTestFile(t, filepath.Join(data, "new"), "new active data") + writeTestFile(t, filepath.Join(safety, ".ftw-restore-point.json"), `{"schema":1,"complete":true,"entries":["first","missing"]}`) + if _, err := RevertContents(data, safety); err == nil { + t.Fatal("revert accepted a point missing a recorded entry") + } + if got, err := os.ReadFile(filepath.Join(data, "new")); err != nil || string(got) != "new active data" { + t.Fatalf("rejected revert moved current data: %q %v", got, err) + } +}