package backup import ( "os" "path/filepath" "strings" "testing" ) // R-403 Group A — the hollowness predicate. // // It decides whether a nightly copy is allowed to delete a customer's last package, so it is worth // pinning precisely. Every case below is a shape that exists on a real box. // r403Unit writes a recovery unit directory carrying the given dump lists, plus whatever extra files // the caller asks for. The manifest is written by the PRODUCTION writeManifest, so the predicate and // the capture meet at real bytes rather than at a hand-rolled JSON literal. func r403Unit(t *testing.T, dbDumps, volDumps []string, extra map[string]string) string { t.Helper() dir := filepath.Join(t.TempDir(), "recovery-unit") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } man := &RecoveryManifest{SchemaVersion: 2, AppName: "app", DBDumps: dbDumps, VolumeDumps: volDumps} if err := writeManifest(UnitManifestFile(dir), man); err != nil { t.Fatal(err) } for rel, body := range extra { mustWrite(t, filepath.Join(dir, rel), body) } return dir } // A1 — a manifest listing neither kind of dump is HOLLOW. This is the exact shape measured on // demo-hp: `"db_dumps": []`, `"volume_dumps": null`. func TestR403_ManifestWithNoDumpsIsHollow(t *testing.T) { for _, tc := range []struct { name string dbs, vols []string }{ {"both empty slices", []string{}, []string{}}, {"both nil (the measured shape: db_dumps [] and volume_dumps null)", nil, nil}, {"empty db, nil volumes", []string{}, nil}, } { dir := r403Unit(t, tc.dbs, tc.vols, nil) if !unitIsHollow(dir) { t.Errorf("%s: unitIsHollow()=false, want true", tc.name) } if unitCarriesData(dir) { t.Errorf("%s: unitCarriesData()=true, want false", tc.name) } } } // A2 — volume tars alone are enough. For the 45 class-B apps that archive is the entire dataset, so // treating a volume-only unit as hollow would let the guard delete exactly the material it exists to // protect. func TestR403_ManifestWithVolumeDumpsOnlyIsNotHollow(t *testing.T) { dir := r403Unit(t, nil, []string{"app_data.tar"}, nil) if unitIsHollow(dir) { t.Error("a unit carrying a volume tar was called hollow") } } // A3 — a database dump alone is enough, for the same reason from the other side. func TestR403_ManifestWithDBDumpsOnlyIsNotHollow(t *testing.T) { dir := r403Unit(t, []string{"app-postgres.sql"}, nil, nil) if unitIsHollow(dir) { t.Error("a unit carrying a database dump was called hollow") } } // A4 — an absent manifest is HOLLOW. FAIL CLOSED: a unit whose contents cannot be vouched for must // never authorise a delete of one whose contents can. func TestR403_AbsentManifestIsHollow(t *testing.T) { dir := filepath.Join(t.TempDir(), "recovery-unit") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if !unitIsHollow(dir) { t.Error("a unit directory with no manifest was treated as data-bearing") } // And a directory that does not exist at all. if !unitIsHollow(filepath.Join(t.TempDir(), "nope")) { t.Error("an absent directory was treated as data-bearing") } } // A5 — an unparseable manifest is HOLLOW, for the same fail-closed reason. func TestR403_UnparseableManifestIsHollow(t *testing.T) { dir := filepath.Join(t.TempDir(), "recovery-unit") mustWrite(t, UnitManifestFile(dir), "{ this is not json") if !unitIsHollow(dir) { t.Error("a unit with an unparseable manifest was treated as data-bearing") } } // A6 — TestR403_SizeIsNeverConsulted. THE DESIGN OF THE PREDICATE, as a test. // // A unit with a large compose tree and no dumps is DANGEROUS — it is exactly the shape that deleted // 120 MB of dumps on demo-hp, and `dirSizeBytes` sits two files away and would call it substantial. // A tiny unit belonging to a tiny app is FINE. Size answers "how big"; the question is "is there // anything to recover", and only the manifest answers that. // // Red-proof (recorded in REPORT.md): switch `unitCarriesData` to a `dirSizeBytes` threshold and this // test fails on the big-but-empty case. func TestR403_SizeIsNeverConsulted(t *testing.T) { // BIG and hollow: a fat compose capture, no dumps. big := r403Unit(t, nil, nil, map[string]string{ "compose/docker-compose.yml": strings.Repeat("# padding\n", 20000), "compose/app.yaml": strings.Repeat("# padding\n", 20000), }) bigBytes := dirSizeBytes(big) if bigBytes < 100000 { t.Fatalf("fixture is not big enough to make the point: %d bytes", bigBytes) } if !unitIsHollow(big) { t.Errorf("a %d-byte unit listing NO dumps was called data-bearing — size was consulted", bigBytes) } // TINY and data-bearing: one small dump, nothing else. small := r403Unit(t, nil, []string{"v.tar"}, map[string]string{"volume-dumps/v.tar": "x"}) smallBytes := dirSizeBytes(small) if unitIsHollow(small) { t.Errorf("a %d-byte unit listing a volume tar was called hollow — size was consulted", smallBytes) } if smallBytes >= bigBytes { t.Fatalf("fixture inverted: small=%d big=%d", smallBytes, bigBytes) } // The consequence stated plainly: the SMALLER unit is the one that carries data. if !unitCarriesData(small) || unitCarriesData(big) { t.Error("the predicate ordered these by size rather than by contents") } }