R-403: a poorer copy must never delete a richer one
gates / gates (push) Successful in 11s

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).
This commit is contained in:
2026-08-31 14:02:13 +02:00
parent fed272e62a
commit 2358e561b7
12 changed files with 931 additions and 9 deletions
+34 -3
View File
@@ -1203,6 +1203,9 @@ type AppBackupRow struct {
// Tier2UnitConfirm is the assembled destructive-confirm sentence (tier2UnitConfirmMsg). Built in
// Go, not in the attribute, so it is one named string a test can assert verbatim.
Tier2UnitConfirm string
// Tier2UnitStaleNotice (R-403) — non-empty when the newest run PRESERVED this copy's package
// instead of refreshing it, so the card cannot render a preserved package as a fresh one.
Tier2UnitStaleNotice string
// Drive disconnected — app's home drive is currently disconnected
DriveDisconnected bool
@@ -1428,8 +1431,14 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
// row keeps rendering everything else it already showed.
if cov, covErr := s.backupMgr.Tier2RestoreCoverage(app.StackName); covErr == nil {
row.Tier2UnitRestorable = cov.CanRestoreUnit()
row.Tier2CopyDate, row.Tier2CopyDateProven = cov.Tier2CopyDate()
row.Tier2UnitConfirm = tier2UnitConfirmMsg(row.Tier2CopyDate, row.Tier2CopyDateProven)
// R-403: the UNIT action names the PACKAGE's date, not the run's. After a
// preserved leg those are different dates and the run's is the flattering one.
pkgDate, stale := cov.UnitRestoreDate()
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, cov.CopyLastSuccess != ""
row.Tier2UnitConfirm = tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
if stale && pkgDate != "" {
row.Tier2UnitStaleNotice = fmt.Sprintf(tier2UnitStaleNoticeFmt, fmtRFC3339Local(pkgDate))
}
}
switch cd.LastStatus {
case "ok":
@@ -1669,6 +1678,16 @@ const (
tier2UnitConfirmDateUnprovenFmt = " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt."
tier2UnitConfirmContrast = " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll."
// tier2UnitStaleClause (R-403) — the package in this copy is OLDER than the copy's newest run,
// because that run PRESERVED it rather than replacing it with an empty one. Without this the
// confirm would name a date the customer reads as "last night" over a package from before it.
// The whole point of preserving the copy is lost if the surface then misdescribes what it kept.
tier2UnitStaleClause = " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja."
// tier2UnitStaleNoticeFmt (R-403) — the same fact on the per-app backup card, where the customer
// looks BEFORE deciding anything. %s is the package's own date.
tier2UnitStaleNoticeFmt = "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
)
// tier2UnitConfirmMsg assembles the destructive confirm for one app's Tier-2 unit restore. Pure, so
@@ -1677,6 +1696,13 @@ const (
// A copy with no recorded date at all still gets a confirm — it just cannot name one. Dropping the
// whole confirm because a date is missing would remove the warning and keep the destruction.
func tier2UnitConfirmMsg(copyDate string, proven bool) string {
return tier2UnitConfirmWithStaleness(copyDate, proven, false)
}
// tier2UnitConfirmWithStaleness is tier2UnitConfirmMsg for a copy whose PACKAGE may be older than its
// newest run (R-403). ONE implementation, two callers — the two-argument form above is the ordinary
// case where the run really did refresh the package.
func tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
msg := tier2UnitConfirmBase
if copyDate != "" {
if proven {
@@ -1685,7 +1711,12 @@ func tier2UnitConfirmMsg(copyDate string, proven bool) string {
msg += fmt.Sprintf(tier2UnitConfirmDateUnprovenFmt, fmtRFC3339Local(copyDate))
}
}
return msg + tier2UnitConfirmContrast
msg += tier2UnitConfirmContrast
// R-403 last, so it is the sentence the customer is left holding before they press.
if stale {
msg += tier2UnitStaleClause
}
return msg
}
// tier2UnitSourceMsg renders the "which copy" clause for the Tier-2 unit restore's outcome, or "" if
@@ -0,0 +1,101 @@
package web
import (
"strings"
"testing"
)
// R-403 Group D — the surfaces must not call a PRESERVED package a FRESH one.
//
// The guard keeps the customer's data. That gain is thrown away if the page then reports the run's
// own timestamp as the package's date: the customer would restore a week-old package believing it was
// last night's. Trading a data loss for a comforting lie is the failure family this project keeps
// finding, and it is not a fix.
//
// Every assertion compares against the NAMED CONSTANT rather than a Hungarian literal retyped here
// (R-364): a re-typed accented string can differ from the shipped one by a character nobody sees.
// D1 — TestR403_SkippedUnitLegIsNotRenderedAsFresh.
func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
const runDate = "2026-08-31T03:30:00Z"
const pkgDate = "2026-08-25T03:30:00Z"
stale := r103Row(true, pkgDate, true)
stale.Tier2LastRun, stale.Tier2LastSuccess = runDate, runDate
stale.Tier2UnitStaleNotice = staleNoticeFor(pkgDate)
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{stale}))
if !strings.Contains(html, stale.Tier2UnitStaleNotice) {
t.Error("the preserved-package notice is not on the page — a preserved copy renders as a fresh one")
}
// The PACKAGE's date is shown, not only the run's.
if !strings.Contains(html, fmtRFC3339Local(pkgDate)) {
t.Errorf("the package's own date %q is not on the page", fmtRFC3339Local(pkgDate))
}
// NEGATIVE CONTROL: an ordinary row must NOT carry the notice, or D1 would pass on a page that
// shows the warning to everybody.
fresh := r103Row(true, runDate, true)
freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{fresh}))
if strings.Contains(freshHTML, staleNoticeFor(pkgDate)) {
t.Error("an ordinary row carried the preserved-package notice")
}
if !strings.Contains(freshHTML, "/backup/tier2/unit-restore") {
t.Fatal("the ordinary row did not render at all — the negative control proves nothing")
}
}
func staleNoticeFor(pkgDate string) string {
return strings.Replace(tier2UnitStaleNoticeFmt, "%s", fmtRFC3339Local(pkgDate), 1)
}
// D2 — TestR403_UnitRestoreOfferNamesTheOlderPackageDate.
//
// The confirm is the last thing between the customer and an overwrite of their live data. After a
// preserved leg it must name the PACKAGE's date and say why it is older than the copy's newest run.
func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
const pkgDate = "2026-08-25T03:30:00Z"
staleConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, true)
freshConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, false)
if !strings.Contains(staleConfirm, tier2UnitStaleClause) {
t.Error("the confirm does not say the package is older than the newest run")
}
if !strings.Contains(staleConfirm, fmtRFC3339Local(pkgDate)) {
t.Error("the confirm does not name the package's date")
}
// Everything the ordinary confirm promised is still promised.
if !strings.Contains(staleConfirm, tier2UnitConfirmBase) || !strings.Contains(staleConfirm, tier2UnitConfirmContrast) {
t.Error("the stale confirm lost the overwrite warning or the additive contrast")
}
// NEGATIVE CONTROL: the ordinary confirm must NOT carry the clause.
if strings.Contains(freshConfirm, tier2UnitStaleClause) {
t.Error("an ordinary confirm carried the preserved-package clause")
}
if staleConfirm == freshConfirm {
t.Error("a preserved package and a fresh one produced the SAME confirm")
}
// And it reaches the rendered markup, not only the constant.
row := r103Row(true, pkgDate, true)
row.Tier2UnitConfirm = staleConfirm
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{row}))
if !strings.Contains(html, "FIGYELEM") { // ASCII-only fragment, R-364
t.Error("the stale clause never reached the page")
}
// The ASCII control: the same page WITHOUT the clause must not match.
rowFresh := r103Row(true, pkgDate, true)
freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{rowFresh}))
if strings.Contains(freshHTML, "FIGYELEM") {
t.Error("the ASCII fragment matches a page that has no stale clause — the control fails")
}
}
// The two-argument wrapper still produces the ordinary confirm — yesterday's callers are unchanged.
func TestR403_TheOrdinaryConfirmIsUnchanged(t *testing.T) {
const d = "2026-08-25T03:30:00Z"
if tier2UnitConfirmMsg(d, true) != tier2UnitConfirmWithStaleness(d, true, false) {
t.Error("the two-argument confirm is no longer the not-stale case")
}
}
@@ -245,6 +245,10 @@
{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
{{if .Tier2LastWarning}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2LastWarning}}</span>{{end}}
{{/* R-403: a run that PRESERVED the copy's package instead of refreshing it must
not render as a plain fresh copy. The status line above is about the RUN;
this one is about the PACKAGE, and after a preserved leg they differ. */}}
{{if .Tier2UnitStaleNotice}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2UnitStaleNotice}}</span>{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">