package backup import ( "os" "path/filepath" "sort" "strings" ) // R-403 — a POORER copy must never delete a RICHER one. // // Measured on demo-hp 2026-08-31, on the shipped v0.229.0: an app's Tier-2 copy went from // 120 082 104 bytes (4 database dumps + 3 named-volume tars) to 7 036 bytes (none of either) in one // nightly run, and the run recorded itself as 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, in three lines of existing code that were each individually correct: // 1. `RunTier2` guards the unit leg with `os.Stat(unitDir)` — *does the folder exist*. // 2. `rsyncMirror` is `rsync -a --delete` — an exact mirror, which is what a derived copy must be. // 3. Nothing between them compares the source to the destination. // An EMPTY recovery unit is a folder that exists. So a primary unit that had lost its dumps — after a // restore, a failed dump run, a crash mid-capture, a remount — was mirrored over a complete copy, and // `--delete` removed the customer's last surviving package. // // WHAT THIS FILE DELIBERATELY DOES **NOT** DO: it does not make the Tier-2 copy un-shrinkable. // `07-backup-architecture.md` §8 row 5 records that the secondary is a DERIVED copy, rebuilt on the // next run ("Migration = rebuild, not preserve"), and `tier2.go`'s own header records that a // classified app's copy legitimately shrinks as `export` drops out of its class set. Fencing // shrinkage would be calling a decision a defect. The fence here is exactly one shape: a source that // carries NO data replacing a destination that carries some. // unitCarriesData reports whether a recovery-unit DIRECTORY holds RECOVERABLE DATA — the app's // database dumps or its named-volume tars. // // IT ASKS THE MANIFEST, NEVER THE BYTE SIZE, and that is the whole design of the predicate. A unit // with a large compose tree and no dumps is dangerous; a tiny unit belonging to a tiny app is fine. // Size answers "how big", and the question here is "is there anything to recover". `dirSizeBytes` // exists two files away and would have been the obvious wrong answer — TestR403_SizeIsNeverConsulted // is the guard that keeps it out. // // FAIL CLOSED on an absent or unparseable manifest: `readManifest` returns nil for both, and a unit // whose manifest cannot be read is a unit whose contents cannot be vouched for. Treating it as // data-bearing would let an unreadable source authorise a delete. // // ONE predicate, every caller. The mirror guard and the post-restore rehydrate both ask this // function; two copies of the definition is how the two halves of a fix drift apart. func unitCarriesData(unitDir string) bool { man := readManifest(UnitManifestFile(unitDir)) if man == nil { return false } return len(man.DBDumps) > 0 || len(man.VolumeDumps) > 0 } // unitIsHollow is `unitCarriesData` negated, named for the way both callers actually ask it. It is a // separate function only so the call sites read as the question they are asking. func unitIsHollow(unitDir string) bool { return !unitCarriesData(unitDir) } // ── R-87 — does this app's backup contain what THIS APP should have? ───────────────────────────── // // THE ACCEPTANCE RULE, AND WHY THE OBVIOUS ONE IS A TRAP. // // The spike that re-scoped R-87 summarised this job as "check the unit against its own packing list". // Taken literally that is worthless: **a hollow unit declares nothing, so everything it declares is // present, and the check passes on exactly the shape it exists to catch.** R-403 measured that shape // on this fleet — 120 082 104 B of dumps and tars became 7 036 B of neither, and the run recorded // itself a success. // // So the rule has TWO parts and needs both: // // 1. everything the manifest declares is present in the restored unit, AND // 2. the manifest declares what the app is SUPPOSED to have. // // Part 2 is the whole value. Part 1 alone is the trap. // // WHERE THE EXPECTATION COMES FROM, AND WHY IT IS NOT THE LIVE BOX. // // R-403's guard could tell hollow from legitimately-empty because it had TWO copies to compare. This // has ONE. The expectation therefore comes from INSIDE the unit — its own captured // `compose/docker-compose.yml` — and never from the running app: // // - the snapshot may predate the app's current shape, and the point is to judge the snapshot on its // own terms rather than against a box that has moved on; // - `GetDockerVolumes` (backup.go) enumerates from LIVE Docker, which answers a different question. // // THE TWO HALVES, both from the unit's own compose: // // - DATABASE — `DBServiceNames()` names the compose SERVICES whose `image:` is a supported engine. // It is the same honest discriminator `RestoreFromRecoveryUnit` already uses to decide whether a // dump must replay, so this cannot disagree with the restore path about what an app is. If the // compose declares a database service, the unit must declare at least one database dump. // - NAMED VOLUMES — `ParseComposeNamedVolumes()` reads the top-level `volumes:` block. If the // compose declares named volumes, the unit must declare at least one volume tar. // // THE VOLUME HALF IS DELIBERATELY AN EXISTENCE CHECK AND NOT A NAME MATCH, and the reason is that a // name match cannot be done honestly from inside a unit. Volume tars are named // `_.tar`, and `ResolveDockerVolumeNames` derives the project from // `filepath.Base(filepath.Dir(composePath))` — which inside a unit is the literal string `compose`, // not the stack. Measured 2026-08-31 on all eight real units on demo-hp, the count matches exactly // (bookstack 2/2, docmost 3/3, kimai 2/2, opengist 1/1, privatebin 1/1, calibre-web 1/1, // paperless-ngx 3/3, romm 3/3) and `_.tar` held in every case — but "held on eight" is // not "derivable", and R-355's standing rule is that a claim about the app must never be inferred // from a counter. Half a rule that is true beats a whole rule that is invented. // // THREE OUTCOMES, NOT TWO. "I could not judge this" is a first-class answer: collapsing it into a // pass hides a real gap, and collapsing it into a failure alarms on our own blind spot. That is the // same distinction `IntegrityResult` draws between a failed check and an unreachable store. // // ONE MANIFEST READER: this uses `readManifest`, the same function `unitCarriesData` above uses. // `unitCarriesData` answers the coarse question ("does this unit carry anything at all") and stays // exactly as R-403 shipped it; this answers the finer one, and an app whose unit carries nothing at // all while its compose declares either a database or a volume fails BOTH. // UnitProofVerdict is the outcome of judging one restored recovery unit. type UnitProofVerdict string const ( // UnitProofPass — the unit declares what the app should have, and holds everything it declares. UnitProofPass UnitProofVerdict = "pass" // UnitProofFail — the backup is READABLE and does not hold the app's data. Its canonical case is // the R-403 shape: intact and empty. That is NOT the same fact as a damaged store and must never // be reported as one — the customer's action differs. UnitProofFail UnitProofVerdict = "fail" // UnitProofCannotJudge — the unit does not carry enough to answer. Never reported as a pass. UnitProofCannotJudge UnitProofVerdict = "cannot_judge" ) // UnitProofReason is a stable machine code for WHY, so the log, the event and the message can each // say the same thing without re-deriving it from prose. type UnitProofReason string const ( ProofReasonOK UnitProofReason = "" ProofReasonManifestUnreadable UnitProofReason = "manifest_unreadable" ProofReasonDeclaredFileMissing UnitProofReason = "declared_file_missing" ProofReasonNoDatabaseDump UnitProofReason = "database_expected_none_captured" ProofReasonNoVolumeDump UnitProofReason = "volumes_expected_none_captured" ProofReasonComposeMissing UnitProofReason = "compose_missing" ProofReasonComposeUnparseable UnitProofReason = "compose_unparseable" ) // UnitProofResult is what one judgement decided, and enough to say it out loud. type UnitProofResult struct { Verdict UnitProofVerdict Reason UnitProofReason // Missing names the declared-but-absent files (ProofReasonDeclaredFileMissing) or the expectation // that went unmet. ASCII-safe: file names and compose service names only, never a path outside the // unit and never any file CONTENT — units carry portable secrets (R-9's rule for this whole area). Missing []string } // OK reports whether this verdict is the passing one. A helper rather than a `==` at every call site, // because "not a failure" and "a pass" are different questions here and the third outcome is why. func (r UnitProofResult) OK() bool { return r.Verdict == UnitProofPass } // JudgeRestoredUnit applies the rule above to a restored recovery-unit DIRECTORY. // // It is PURE and does no network, no docker and no restic: given a directory it reads the manifest, // the captured compose and the presence of the declared files, and nothing else. Size is never // consulted — `TestR403_SizeIsNeverConsulted` guards that for the R-403 predicate and // `TestR87_SizeIsNeverConsulted` guards it here, because "how big" has never been the question. func JudgeRestoredUnit(unitDir string) UnitProofResult { man := readManifest(UnitManifestFile(unitDir)) if man == nil { // FAIL CLOSED. A unit whose manifest cannot be read is a unit whose contents cannot be // vouched for — R-403's own rule, and the direction matters: treating it as sound would let // an unreadable backup pass as a proved one, which is the failure this job exists to end. return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonManifestUnreadable} } // Part 1 — everything declared is actually there. var missing []string for _, f := range man.DBDumps { if !fileExistsIn(UnitDBDumpDir(unitDir), f) { missing = append(missing, "db-dumps/"+f) } } for _, f := range man.VolumeDumps { if !fileExistsIn(UnitVolumeDumpDir(unitDir), f) { missing = append(missing, "volume-dumps/"+f) } } for _, f := range man.ConfigFiles { if !fileExistsIn(UnitComposeDir(unitDir), f) { missing = append(missing, "compose/"+f) } } if len(missing) > 0 { return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonDeclaredFileMissing, Missing: missing} } // Part 2 — the expectation, from the unit's own compose. composePath := filepath.Join(UnitComposeDir(unitDir), "docker-compose.yml") if _, err := os.Stat(composePath); err != nil { // CANNOT JUDGE, never a pass. Without the compose there is no honest expectation, and the // alternative — assuming the app needs nothing — is precisely how a hollow unit would slip // through. return UnitProofResult{Verdict: UnitProofCannotJudge, Reason: ProofReasonComposeMissing} } dbServices, err := DBServiceNames(composePath) if err != nil { // DBServiceNames already refuses to let "cannot tell" read as "no database" (its own doc // comment); this carries that refusal outward instead of flattening it. return UnitProofResult{Verdict: UnitProofCannotJudge, Reason: ProofReasonComposeUnparseable} } if len(dbServices) > 0 && len(man.DBDumps) == 0 { return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonNoDatabaseDump, Missing: dbServices} } volumes := ParseComposeNamedVolumes(composePath) if len(volumes) > 0 && len(man.VolumeDumps) == 0 { names := make([]string, 0, len(volumes)) for _, v := range volumes { names = append(names, v.Name) } sort.Strings(names) return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonNoVolumeDump, Missing: names} } return UnitProofResult{Verdict: UnitProofPass, Reason: ProofReasonOK} } // fileExistsIn reports whether name exists as a regular file directly inside dir. // // `name` is a manifest-declared BASE name; it is joined and then checked to still be inside dir, so a // manifest carrying `../../etc/passwd` cannot make this answer about a file outside the unit. The // manifest travels inside the snapshot and a restore writes it from the store, so it is not a trusted // input — this is the same instinct as DeleteOffsiteRestoreCopy's prefix check. func fileExistsIn(dir, name string) bool { p := filepath.Clean(filepath.Join(dir, name)) if !strings.HasPrefix(p, filepath.Clean(dir)+string(filepath.Separator)) { return false } fi, err := os.Stat(p) return err == nil && fi.Mode().IsRegular() }