package backup import ( "context" "encoding/json" "errors" "os" "path/filepath" "sort" "strings" "testing" "time" ) // R-1105: the off-site code was measured against restic 0.14.0; v0.323.0 ships 0.18.1. The fixtures in // testdata/restic// are REAL outputs of both official binaries, captured 2026-10-16 on one shared scratch // repository (made by 0.14.0, so both versions read the same snapshot set; paths rewritten to /data/x). Each // assumption the code makes about restic's output is asserted for BOTH versions through the code's own functions — // so a future restic that rewords one of them fails here, by name, instead of moving a failure to `unknown` on a // customer's box (07-backup-architecture.md §6.7, §6.8). var r1105Versions = []string{"0.14.0", "0.18.1"} func r1105Fixture(t *testing.T, version, name string) []byte { t.Helper() b, err := os.ReadFile(filepath.Join("testdata", "restic", version, name)) if err != nil { t.Fatal(err) } return b } // fixtureRunner answers each restic subcommand with a captured output; err != nil when restic exited non-zero. func r1105Runner(t *testing.T, version string, answers map[string]string) offboxRunner { return func(_ context.Context, _ []string, args ...string) ([]byte, error) { joined := " " + strings.Join(args, " ") + " " for _, key := range sortedKeys(answers) { if strings.Contains(joined, " "+key+" ") { name := answers[key] var err error if strings.HasPrefix(name, "!") { // restic exited non-zero for this one name, err = name[1:], errors.New("exit status 1") } return r1105Fixture(t, version, name), err } } t.Fatalf("restic %s: no fixture for %q", version, joined) return nil, nil } } func sortedKeys(m map[string]string) []string { k := make([]string, 0, len(m)) for s := range m { k = append(k, s) } sort.Slice(k, func(i, j int) bool { return len(k[i]) > len(k[j]) }) // the longest (most specific) match first return k } // The probe and the failure classifier: each captured failure lands in the class it has always landed in. func TestR1105_EachResticFailureKeepsItsClass(t *testing.T) { cases := []struct { fixture string probe string // classifyResticProbe on `cat config` runClass OffsiteFailureClass // ClassifyOffsiteFailure on the error ensureOffboxRepo returns ("" = not reached) }{ {"wrong-password.txt", "orphaned", ""}, {"no-repo.txt", "norepo", ""}, {"sftp-refused-port.txt", "other", OffsiteFailTransport}, {"sftp-refused-key.txt", "other", OffsiteFailTransport}, {"sftp-unreachable.txt", "other", OffsiteFailTransport}, {"rclone-dead-ssh.txt", "other", OffsiteFailTransport}, } for _, v := range r1105Versions { for _, c := range cases { out := r1105Fixture(t, v, c.fixture) if got := classifyResticProbe(out, errors.New("exit status 1")); got != c.probe { t.Errorf("restic %s %s: probe class %q, want %q", v, c.fixture, got, c.probe) } if c.runClass == "" { continue } m, _ := newOffboxManager(t) m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"cat": "!" + c.fixture})) err := m.ensureOffboxRepo(context.Background(), []string{"-r", "x"}, nil) if got := ClassifyOffsiteFailure(err); got != c.runClass { t.Errorf("restic %s %s: run class %q, want %q (err: %v)", v, c.fixture, got, c.runClass, err) } } // A lock left by a crashed run on another hostname (the C2 shape), both forms. for _, f := range []string{"locked-shared.txt", "locked-exclusive.txt"} { out := r1105Fixture(t, v, f) if !offboxLockRe.Match(out) { t.Errorf("restic %s %s: the lock text is no longer recognised — resticStep would not self-heal", v, f) } if got := ClassifyOffsiteFailure(errors.New(string(out))); got != OffsiteFailLocked { t.Errorf("restic %s %s: class %q, want locked", v, f, got) } } // The integrity check: a damaged store reads as damage, a healthy one does not. if !looksLikeRepositoryDamage(r1105Fixture(t, v, "check-damaged.txt")) { t.Errorf("restic %s: a damaged pack is no longer recognised as damage", v) } if looksLikeRepositoryDamage(r1105Fixture(t, v, "check-ok.txt")) { t.Errorf("restic %s: a healthy check reads as damage", v) } } } // A first run that races another init: „already exists" is still read as success, so the run proceeds. func TestR1105_InitRaceIsStillSuccess(t *testing.T) { for _, v := range r1105Versions { m, _ := newOffboxManager(t) m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"cat": "!no-repo.txt", "init": "!init-exists.txt"})) if err := m.ensureOffboxRepo(context.Background(), []string{"-r", "x"}, nil); err != nil { t.Errorf("restic %s: an init that finds the repository already made must be success, got %v", v, err) } } } // The JSON the code parses: snapshot lists, the newest snapshot of a tag, the sizes, the progress lines. func TestR1105_TheJSONTheCodeReadsStillParses(t *testing.T) { counts := map[string]int{} for _, v := range r1105Versions { m, sett := newOffboxManager(t) m.SetOffboxRunner(r1105Runner(t, v, map[string]string{ "snapshots": "snapshots.json", "latest": "snapshots-latest-tag.json", "stats": "stats-id.json", "raw-data": "stats-raw.json", })) snaps, err := m.listGuardSnaps(context.Background(), nil, nil) if err != nil || len(snaps) == 0 { t.Fatalf("restic %s: snapshot list: %d, %v", v, len(snaps), err) } for _, s := range snaps { if s.ID == "" || s.ShortID == "" || s.Time.IsZero() || s.Hostname == "" || len(s.Tags) == 0 { t.Fatalf("restic %s: a snapshot lost a field the guard reads: %+v", v, s) } } counts[v] = len(snaps) id, paths, err := m.offboxLatestSnapshot(context.Background(), "a") if err != nil || id == "" || len(paths) == 0 { t.Errorf("restic %s: newest snapshot of a tag: %q %v %v", v, id, paths, err) } if size, err := m.offboxSnapshotSize(context.Background(), id); err != nil || size <= 0 { t.Errorf("restic %s: snapshot size: %d %v", v, size, err) } n, ok := m.offboxRecordStats(context.Background(), nil, nil) if !ok || n != counts[v] || sett.GetOffboxTarget().RepoSizeBytes <= 0 { t.Errorf("restic %s: repo stats: count %d ok %v size %d", v, n, ok, sett.GetOffboxTarget().RepoSizeBytes) } moved := false for _, line := range strings.Split(strings.TrimSpace(string(r1105Fixture(t, v, "backup-status.jsonl"))), "\n") { p, ok := parseResticStatus(line) if !ok { t.Errorf("restic %s: a status line no longer parses: %s", v, line) } if p.TotalBytes > 0 && p.TotalFiles > 0 { moved = true } } if !moved { t.Errorf("restic %s: no status line carried the totals the progress bar shows", v) } } if counts["0.14.0"] != counts["0.18.1"] { t.Errorf("the two versions list a different number of snapshots of ONE repository: %v", counts) } } // The clean-up plan — the ONE assumption that MOVED. On the same snapshot set, 0.18.1 removes a SUBSET of what 0.14.0 // removes: since restic 0.17, while a --keep-monthly window has unused slots, restic also keeps the group's OLDEST // snapshot (reason „oldest monthly snapshot"). Measured: a 41-day history → 0.14.0 removes 62, 0.18.1 removes 60 (one // per group); a 230-day history (every monthly slot used) → identical plans (474 of 502, audits/debian13-restic- // 2026-10-16/). It only ever KEEPS more, never removes more, and what it keeps extra is old — so the fake-snapshot // guard (R-822/R-867), which refuses a plan that removes a YOUNG snapshot, passes the 0.18.1 plan whenever it passes // the 0.14.0 one. Pinned here: subset, the difference is exactly the „oldest" keeps, and the guard's verdict holds. func TestR1105_TheForgetPlanOnlyEverKeepsMore(t *testing.T) { plans := map[string]map[string]bool{} var all []guardSnap for _, v := range r1105Versions { m, _ := newOffboxManager(t) m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"forget": "forget-dryrun.json", "snapshots": "snapshots.json"})) plan, err := m.planRemovals(context.Background(), nil, nil) if err != nil { t.Fatalf("restic %s: %v", v, err) } plans[v] = map[string]bool{} for _, s := range plan { plans[v][s.ID] = true } // Independent read of the same file: the parser drops nothing restic planned. var groups []struct { Remove []json.RawMessage `json:"remove"` } if err := json.Unmarshal(r1105Fixture(t, v, "forget-dryrun.json"), &groups); err != nil { t.Fatal(err) } want := 0 for _, g := range groups { want += len(g.Remove) } if len(plan) != want || want == 0 { t.Errorf("restic %s: the parsed plan holds %d removals, restic planned %d", v, len(plan), want) } // The guard, at the minute the plans were captured: both plans pass it. if all == nil { if all, err = m.listGuardSnaps(context.Background(), nil, nil); err != nil { t.Fatal(err) } } captured := time.Date(2026, 10, 16, 18, 20, 0, 0, time.UTC) if _, refuse := offsiteGuard(all, plan, captured, time.Time{}, -1); refuse != "" { t.Errorf("restic %s: the guard refuses the honest plan: %s", v, refuse) } } for id := range plans["0.18.1"] { if !plans["0.14.0"][id] { t.Errorf("0.18.1 removes snapshot %s, which 0.14.0 keeps — the new restic removes MORE", id) } } // Every snapshot only 0.14.0 removes is one 0.18.1 keeps as the „oldest" of a window with unused slots. var groups []struct { Reasons []struct { Snapshot struct { ID string `json:"id"` } `json:"snapshot"` Matches []string `json:"matches"` } `json:"reasons"` } if err := json.Unmarshal(r1105Fixture(t, "0.18.1", "forget-dryrun.json"), &groups); err != nil { t.Fatal(err) } why := map[string]string{} for _, g := range groups { for _, r := range g.Reasons { why[r.Snapshot.ID] = strings.Join(r.Matches, ",") } } extra := 0 for id := range plans["0.14.0"] { if plans["0.18.1"][id] { continue } extra++ if !strings.Contains(why[id], "oldest") { t.Errorf("0.18.1 keeps snapshot %s that 0.14.0 removes, for a reason other than “oldest”: %q", id, why[id]) } } if extra == 0 { t.Error("the fixture no longer shows the 0.17+ “oldest” keep — it no longer tests the difference it documents") } }