package backup import ( "errors" "os" "path/filepath" "sort" "strings" "testing" "time" ) // v0.269.0 — the WHOLE restore from the second drive (`09` §3 decision 26, R-661). The file rules are // asserted as CONSEQUENCES over the whole tree: every live file is fingerprinted before and after, and // nothing the household had may disappear or be silently overwritten. func writeAt(t *testing.T, p, body string, mt time.Time) { t.Helper() mustWrite(t, p, body) if err := os.Chtimes(p, mt, mt); err != nil { t.Fatal(err) } } func readFile(t *testing.T, p string) string { t.Helper() b, err := os.ReadFile(p) if err != nil { t.Fatalf("read %s: %v", p, err) } return string(b) } // fingerprint maps every regular file under root to its content. func fingerprint(t *testing.T, root string) map[string]string { t.Helper() out := map[string]string{} _ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { if err == nil && fi.Mode().IsRegular() { rel, _ := filepath.Rel(root, p) out[rel] = readFile(t, p) } return nil }) return out } // TestWhole_TheFourFileRules — one mirror, one live tree, every rule exercised at once. // // COMPANION RED-PROOFS (REPORT): // - rule 2 removed (no newer-check): "b.txt, the household's NEWER copy, was overwritten"; // - rule 4 without keeping the live copy: "the older live copy of c.txt is gone"; // - rule 3 removed: "a.txt, missing live, was not brought back". func TestWhole_TheFourFileRules(t *testing.T) { tmp := t.TempDir() mirror, live := filepath.Join(tmp, "mirror"), filepath.Join(tmp, "live") old := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) mid := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) newer := time.Date(2026, 9, 24, 10, 0, 0, 0, time.UTC) writeAt(t, filepath.Join(mirror, "photos", "a.txt"), "A from the copy", mid) // live: missing writeAt(t, filepath.Join(mirror, "photos", "b.txt"), "B from the copy", mid) // live: newer writeAt(t, filepath.Join(mirror, "photos", "c.txt"), "C from the copy", mid) // live: older, different writeAt(t, filepath.Join(mirror, "photos", "d.txt"), "D same", mid) // live: same writeAt(t, filepath.Join(mirror, "docs", "deep", "e.txt"), "E from the copy", mid) // live: whole dir missing writeAt(t, filepath.Join(live, "photos", "b.txt"), "B edited by the household", newer) // newer live writeAt(t, filepath.Join(live, "photos", "c.txt"), "C broken by the update", old) // older live writeAt(t, filepath.Join(live, "photos", "d.txt"), "D same", old) // same content writeAt(t, filepath.Join(live, "photos", "only-live.txt"), "not in the copy", old) // must survive before := fingerprint(t, live) ts := time.Date(2026, 9, 24, 21, 0, 0, 0, time.UTC) c, err := mergeRestoreFiles(mirror, live, ts) if err != nil { t.Fatal(err) } after := fingerprint(t, live) // Rule 1 — nothing the household had is gone: every file present before still exists, or (c.txt) // its content survives beside it. kept := "photos/c.txt" + wholeRestoreSuffix(ts) for rel, body := range before { if got, ok := after[rel]; ok && (got == body || rel == "photos/c.txt") { continue } t.Fatalf("live file %s (%q) is gone or changed — rule 1", rel, body) } if after[kept] != "C broken by the update" { t.Fatalf("the older live copy of c.txt is gone — rule 4 must keep it beside as %s; files: %v", kept, keys(after)) } // Rule 2 if after["photos/b.txt"] != "B edited by the household" { t.Fatalf("b.txt, the household's NEWER copy, was overwritten: %q", after["photos/b.txt"]) } // Rule 3 if after["photos/a.txt"] != "A from the copy" || after["docs/deep/e.txt"] != "E from the copy" { t.Fatalf("a.txt / e.txt, missing live, were not brought back: %q %q", after["photos/a.txt"], after["docs/deep/e.txt"]) } // Rule 4 if after["photos/c.txt"] != "C from the copy" { t.Fatalf("c.txt (older, different) was not replaced from the copy: %q", after["photos/c.txt"]) } if after["photos/only-live.txt"] != "not in the copy" { t.Fatal("a live file absent from the copy was touched") } if c.Restored != 2 || c.Replaced != 1 || c.KeptNewer != 1 || c.Unchanged != 1 { t.Fatalf("counts = %+v, want restored 2, replaced 1, kept-newer 1, unchanged 1", c) } // mtime of a restored file is the copy's, so a later restore's rule 2 compares like with like. if fi, _ := os.Stat(filepath.Join(live, "photos", "a.txt")); !fi.ModTime().Equal(mid) { t.Fatalf("a.txt mtime %v, want the copy's %v", fi.ModTime(), mid) } // Idempotent: a second run changes nothing and deletes nothing. c2, err := mergeRestoreFiles(mirror, live, ts.Add(time.Hour)) if err != nil { t.Fatal(err) } if c2.Restored != 0 || c2.Replaced != 0 { t.Fatalf("a second run moved files again: %+v", c2) } } func keys(m map[string]string) []string { var out []string for k := range m { out = append(out, k) } sort.Strings(out) return out } // fileAppProvider makes the R-102 fixture's app a FILE app: a mandatory drive leg. type fileAppProvider struct{ *fakeRecoveryProvider } func (f fileAppProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) { return []ClassifiedBind{mandatoryHDD("appdata/app")}, true } // wholeFixture: the R-102 fixture (a real RunTier2 mirror + unit restore seams), turned into a file app // with photos in the mirror's hdd leg and a damaged live tree. func wholeFixture(t *testing.T) (*r102T2, string, string) { f := r102Tier2Fixture(t, []string{"app_data.tar"}, pgDump(1)) f.m.stackProvider = fileAppProvider{f.fake} mid := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) mirrorLeg := filepath.Join(f.destBase, "hdd", "appdata", "app") liveLeg := filepath.Join(f.liveDrive, "appdata", "app") writeAt(t, filepath.Join(mirrorLeg, "p1.jpg"), "P1", mid) writeAt(t, filepath.Join(mirrorLeg, "p2.jpg"), "P2", mid) writeAt(t, filepath.Join(liveLeg, "p2.jpg"), "P2 newer", mid.Add(48*time.Hour)) return f, mirrorLeg, liveLeg } // TestWhole_BringsTheFilesAndTheUnitBack — the whole action: files merged by the rules, then the unit // restore FROM THE MIRROR (not the primary), with the app stopped throughout. func TestWhole_BringsTheFilesAndTheUnitBack(t *testing.T) { f, _, liveLeg := wholeFixture(t) res, err := f.m.RestoreTier2Whole("app") if err != nil { t.Fatalf("whole restore: %v", err) } if readFile(t, filepath.Join(liveLeg, "p1.jpg")) != "P1" || readFile(t, filepath.Join(liveLeg, "p2.jpg")) != "P2 newer" { t.Fatal("the file half did not follow the rules") } if res.Files.Restored != 1 || res.Files.KeptNewer != 1 { t.Fatalf("file counts %+v", res.Files) } if len(*f.dbPaths) == 0 || !strings.HasPrefix((*f.dbPaths)[0], f.destBase) { t.Fatalf("the database was not replayed from the MIRROR: %v", *f.dbPaths) } if !f.fake.stopped { t.Fatal("the app was not stopped") } } // TestWhole_RefusesBeforeAnythingMoves — no proven copy, no room, not a file app: refused, the app never // stopped, not one file written. func TestWhole_RefusesBeforeAnythingMoves(t *testing.T) { t.Run("no room", func(t *testing.T) { f, _, liveLeg := wholeFixture(t) f.m.freeBytesFn = func(string) int64 { return 1 << 20 } _, err := f.m.RestoreTier2Whole("app") var sp *wholeRestoreSpace if !errors.As(err, &sp) || f.fake.stopped { t.Fatalf("err=%v stopped=%v — want the space refusal before the stop", err, f.fake.stopped) } if _, e := os.Stat(filepath.Join(liveLeg, "p1.jpg")); e == nil { t.Fatal("a file was written by a refused restore") } }) t.Run("unit not openable", func(t *testing.T) { f, _, _ := wholeFixture(t) if err := os.Remove(UnitManifestFile(tier2UnitDir(f.destBase))); err != nil { t.Fatal(err) } if _, err := f.m.RestoreTier2Whole("app"); !errors.Is(err, ErrWholeRestoreNoProvenCopy) || f.fake.stopped { t.Fatalf("err=%v stopped=%v", err, f.fake.stopped) } }) t.Run("not a file app", func(t *testing.T) { f := r102Tier2Fixture(t, []string{"app_data.tar"}, pgDump(1)) if _, err := f.m.RestoreTier2Whole("app"); !errors.Is(err, ErrWholeRestoreNotFileApp) || f.fake.stopped { t.Fatalf("err=%v stopped=%v", err, f.fake.stopped) } }) } // TestWhole_R538StillRefusesTheUnitOnlyRestore — decision 26 is a NEW action beside R-538, not its // removal: the unit-only restore of a file app (own unit AND the second drive's unit) still refuses. func TestWhole_R538StillRefusesTheUnitOnlyRestore(t *testing.T) { f, _, _ := wholeFixture(t) _, err := f.m.RestoreTier2Unit("app") var refusal *ErrUnitLacksFileLegs if !errors.As(err, &refusal) { t.Fatalf("the unit-only restore of a file app must still refuse (R-538): %v", err) } } // TestWhole_SecondDriveIsWholeForAFileAppWithAMirror — decision 25's table gains the second drive. func TestWhole_SecondDriveIsWholeForAFileAppWithAMirror(t *testing.T) { f, _, _ := wholeFixture(t) if !f.m.WholeOnTier("app", UpdateTierSecondDrive) { t.Fatal("a file app with a mirrored unit AND file legs must be whole on the second drive") } if f.m.WholeOnTier("app", UpdateTierLocal) { t.Fatal("the own unit is still NOT whole for a file app") } if err := os.RemoveAll(filepath.Join(f.destBase, "hdd")); err != nil { t.Fatal(err) } if f.m.WholeOnTier("app", UpdateTierSecondDrive) { t.Fatal("a mirror without the file leg is not whole") } }