package backup import ( "os" "path/filepath" "testing" ) // v0.147.0 slice 4a — the verification-copy listing/delete surface. // // DeleteOffsiteRestoreCopy is the ONLY delete this slice adds, so these tests are about what it must // REFUSE as much as what it must do. Every refusal is asserted as a NON-EFFECT: the neighbouring copy // and the customer's live data must still be on disk afterwards. A guard that returns an error but // deletes anyway passes a naive test and loses data. // // RED-PROOF (run manually, confirmed): neutralise the isSafeStackName check in // DeleteOffsiteRestoreCopy and TestDeleteVerifyCopyRefusesUnsafeNames fails hard — `stack: ""` // resolves to the offsite-restore ROOT and os.RemoveAll takes every verification copy with it. That // is the failure this guard exists to prevent, and it is data loss, not a bad error message. // // The HasPrefix containment check inside DeleteOffsiteRestoreCopy could NOT be red-proofed // independently: with isSafeStackName in front of it, no input this API accepts can reach it with an // escaping path, so removing it leaves every test green (and the variable unused). It is deliberate // defence-in-depth against a future caller or a refactor that loosens the name check — kept, but // honestly labelled here as unproven-by-test rather than pretending to a red-proof it does not have. // verifyCopyEnv wires a manager with one drive and materialised verification copies. type verifyCopyEnv struct { m *Manager drive string root string // /backups/offsite-restore } func newVerifyCopyEnv(t *testing.T, copies ...string) *verifyCopyEnv { t.Helper() drive := t.TempDir() m, _, _ := classifiedOffboxManager(t, drive) root := m.offsiteRestoreRootFor(drive) for _, c := range copies { p := filepath.Join(root, c) if err := os.MkdirAll(p, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(p, "payload.bin"), []byte("restored bytes"), 0o644); err != nil { t.Fatal(err) } } return &verifyCopyEnv{m: m, drive: drive, root: root} } func TestListOffsiteRestoreCopiesReportsPathAndSize(t *testing.T) { e := newVerifyCopyEnv(t, "immich", "nextcloud") got := e.m.ListOffsiteRestoreCopies() if len(got) != 2 { t.Fatalf("listed %d copies, want 2: %+v", len(got), got) } byStack := map[string]OffsiteRestoreCopy{} for _, c := range got { byStack[c.Stack] = c } for _, name := range []string{"immich", "nextcloud"} { c, ok := byStack[name] if !ok { t.Fatalf("%s missing from the listing", name) } // THE POINT of the listing: the customer could not previously see WHERE the copy was. want := filepath.Join(e.root, name) if c.Path != want { t.Errorf("%s path = %q, want %q", name, c.Path, want) } if c.SizeHuman == "" { t.Errorf("%s has no humanized size", name) } if c.Created.IsZero() { t.Errorf("%s has no creation time", name) } } } func TestListOffsiteRestoreCopiesEmptyIsNotAnError(t *testing.T) { // No offsite-restore directory at all — the normal state on a box that never ran a verification // restore. Must be an empty list, not a crash and not a phantom entry. e := newVerifyCopyEnv(t) if got := e.m.ListOffsiteRestoreCopies(); len(got) != 0 { t.Errorf("listed %d copies on a clean box, want 0: %+v", len(got), got) } } func TestDeleteVerifyCopyRemovesOnlyTheNamedOne(t *testing.T) { e := newVerifyCopyEnv(t, "immich", "nextcloud") // Live customer data next to the sandbox — must be untouched by any delete. live := filepath.Join(e.drive, "immich-live") if err := os.MkdirAll(live, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(live, "photo.jpg"), []byte("irreplaceable"), 0o644); err != nil { t.Fatal(err) } if err := e.m.DeleteOffsiteRestoreCopy("immich"); err != nil { t.Fatalf("delete: %v", err) } if _, err := os.Stat(filepath.Join(e.root, "immich")); !os.IsNotExist(err) { t.Error("the named copy survived the delete") } if _, err := os.Stat(filepath.Join(e.root, "nextcloud", "payload.bin")); err != nil { t.Errorf("a NEIGHBOURING copy was destroyed: %v", err) } if _, err := os.Stat(filepath.Join(live, "photo.jpg")); err != nil { t.Errorf("LIVE CUSTOMER DATA was destroyed: %v", err) } } func TestDeleteVerifyCopyRefusesUnsafeNames(t *testing.T) { e := newVerifyCopyEnv(t, "immich") // Something outside the sandbox that a traversal would reach. outside := filepath.Join(e.drive, "backups", "primary") if err := os.MkdirAll(outside, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(outside, "unit.tar"), []byte("recovery unit"), 0o644); err != nil { t.Fatal(err) } for _, bad := range []string{ "../primary", "../../..", "..", "immich/../../primary", "/etc", "", } { if err := e.m.DeleteOffsiteRestoreCopy(bad); err == nil { t.Errorf("delete(%q) was ACCEPTED — it must be refused", bad) } // The refusal must also be a NON-EFFECT. if _, err := os.Stat(filepath.Join(outside, "unit.tar")); err != nil { t.Fatalf("delete(%q) destroyed data outside the sandbox: %v", bad, err) } if _, err := os.Stat(filepath.Join(e.root, "immich", "payload.bin")); err != nil { t.Fatalf("delete(%q) destroyed an unrelated copy: %v", bad, err) } } } func TestDeleteVerifyCopyStaysInsideTheSandbox(t *testing.T) { e := newVerifyCopyEnv(t, "immich") // A well-formed name that simply does not exist must be an error, never a silent success that // could mask a path-resolution bug. if err := e.m.DeleteOffsiteRestoreCopy("no-such-app"); err == nil { t.Error("deleting a non-existent copy reported success") } // Everything under the sandbox root must resolve strictly inside it. for _, c := range e.m.ListOffsiteRestoreCopies() { rel, err := filepath.Rel(e.root, c.Path) if err != nil || rel == ".." || filepath.IsAbs(rel) || len(rel) > 2 && rel[:2] == ".." { t.Errorf("listed copy %q resolves outside %q", c.Path, e.root) } } } // TestOffsiteRestoreScratchPathMatchesTheListing pins the two halves together: the path the UI names // in the completion flash must be the same path the listing (and therefore the delete button) uses. // If these ever diverge, the customer is told about a directory the page cannot show or remove. func TestOffsiteRestoreScratchPathMatchesTheListing(t *testing.T) { e := newVerifyCopyEnv(t, "immich") named := e.m.OffsiteRestoreScratchPath("immich") if named == "" { t.Fatal("OffsiteRestoreScratchPath returned empty — the flash would fall back to the vague wording") } var listed string for _, c := range e.m.ListOffsiteRestoreCopies() { if c.Stack == "immich" { listed = c.Path } } if named != listed { t.Errorf("flash names %q but the listing shows %q", named, listed) } }