Files
felhom-controller/controller/internal/backup/r403_hollow_test.go
T
admin 2358e561b7
gates / gates (push) Successful in 11s
R-403: a poorer copy must never delete a richer one
MEASURED FIRST, then fixed. On the shipped v0.229.0, on demo-hp, an app's Tier-2 copy went from
120 082 104 B (4 database dumps + 3 named-volume tars) to 7 036 B (none of either) in ONE nightly
run, and the run recorded itself a success: 'Tier 2 copied docmost -> ... (14.9 KB, 0 leg(s), 0s)'.
Evidence: felhom.eu/documentation/audits/DRILL-r403-tier2-delete-2026-08-31/.

The mechanism was three individually-correct lines: RunTier2 guards the unit leg with os.Stat only
(does the folder exist), rsyncMirror is rsync -a --delete, and nothing between them compared source
to destination. An EMPTY unit is a folder that exists.

THE GUARD. One predicate, unitCarriesData/unitIsHollow (r403_hollow.go), asking the MANIFEST and
never the byte size - a big compose tree with no dumps is dangerous, a tiny unit for a tiny app is
fine. Fail closed on an absent or unparseable manifest. RunTier2 skips the unit leg when the source
is hollow AND the destination is not; the other legs still run, the run is not failed, and the skip
is recorded for the SURFACE (CrossDriveBackup.UnitLegSkipped + UnitPackageDate) as well as logged.

--delete STAYS and shrinking stays legal. 07 section 8 row 5's derived-copy rule is unchanged; the
fence is exactly one shape. TestR403_DataLegShrinkIsUnaffected is the guard on the guard.

THE HONESTY. A preserved package is older than the run that preserved it, so the card carries a
notice and the unit-restore confirm names the PACKAGE's date - read from the mirrored manifest's own
created_at, not from the status record - plus a clause saying why it is older.

THE CAUSE. RestoreTier2Unit now refills a hollow or absent primary unit from the mirror it just
restored from, INSIDE the call before returning. The hollow manifest was written two seconds after
a restore by the 5-minute capture job; any follow-up job races it. The capture itself is NOT guarded:
a capture describing an empty drive as empty is correct, and with the primary refilled there is no
hollow state left to describe. Never over a complete primary, never after a failed restore.

recordTier2Success and tier2UnitConfirmMsg keep their old signatures as thin callers, so no existing
test needed editing. New seam unitRehydrate, separate from tier2Mirror on purpose.

22 new Go tests. Red-proofs run and reverted: A6 (predicate -> size threshold), B1 (guard removed ->
the copy's 3 files are DELETED and the seam is called), B6 (a general never-shrink rule -> the shrink
case fails), C2 (only-when-hollow dropped -> the complete primary is overwritten).
2026-08-31 14:02:13 +02:00

135 lines
5.2 KiB
Go

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")
}
}