Files
felhom-controller/controller/internal/backup/r408_invariant_walk_test.go
T
admin bdcbd50b42
gates / gates (push) Successful in 13s
controller v0.240.0: seven defects from the any-tier proof and the first nightly rotation
R-486 (P1): removing an app with its backups KEPT keeps its Tier-2 record,
so the second-drive restore is no longer refused over an intact mirror.
R-484: postgis/pgvector/timescaledb images are Postgres (logical dumps).
R-485: the backup card sizes the recovery unit and the mirror(s).
R-480: a held update's sentence leaves the card once the hold is lifted.
R-477: the update's off-site lookup is one snapshots call, no stats.
R-478: a copy older than this install's deploy does not count.
R-474: "delete backups" deletes the unit, the mirror(s) and the prefs.

Tests and red-proofs per row; evidence in felhom.eu
documentation/audits/v0240-2026-09-13/ and nightly-2026-09-13-adventurelog/.
2026-09-13 19:26:50 +02:00

226 lines
9.4 KiB
Go

package backup
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
// R-408 — THE INVARIANT IS PINNED BY A WALK, NOT ASSERTED IN A COMMENT.
//
// THE DEFECT THIS EXISTS FOR IS NOT THE MISSING ACQUIRE. `offbox_integrity.go`'s header has said
// *"Every off-site operation takes `acquireRunning` for exactly that reason"* since v0.227.0, nothing
// ever checked it, and it was **false for months** — `RestoreOffboxScratch` and
// `OffboxRestorePrepareFull` both issued restic commands without it. `resticStep`'s licence to run
// `unlock --remove-all` rests entirely on that sentence being true, so a false sentence there is a
// licence to delete a live operation's lock. Measured on demo-hp 2026-08-31, R-411.
//
// That is the ninth instance of this project's most-repeated class: a comment stating a guarantee the
// code does not provide. The workspace rule is *"a comment asserting an invariant needs a test pinning
// it, or it is a wish"*. **This is that test.**
//
// WHAT IT ASSERTS. Every function in `internal/backup` that reaches the off-site repository — by
// calling `resticStep`, `resticBackupStep` or the raw exec seam `m.runner()` — must EITHER call
// `acquireRunning` itself, OR be reachable only from a function that does, OR be registered below by
// name with the reason it is exempt.
//
// IT IS AN AST WALK AND NOT `strings.Contains`, deliberately: a commented-out call still contains the
// string. That is exactly how an earlier version of a test in this repo passed its own red-proof.
// offsiteExempt registers the functions that reach restic WITHOUT the flag, each with the reason.
// Adding a line here is a deliberate act and should be argued in the commit that adds it.
var offsiteExempt = map[string]string{
// R-87's proof restore. It is READ-ONLY BY CONSTRUCTION: `--no-lock`, no `unlockStale`, and it goes
// through `m.runner()` rather than `resticStep`, so the `unlock --remove-all` escalation is
// unreachable rather than merely unlikely. It cannot remove anyone's lock and it cannot take one.
// Its CALLER, `ProveOffboxUnit`, does take the flag — this is the inner helper.
"restoreUnitReadOnly": "R-87: --no-lock, no unlockStale, via m.runner() — cannot take or remove a lock; its caller ProveOffboxUnit holds the flag",
// The lock-hygiene helpers themselves. They are only ever called from inside a function that
// already holds the flag; flagging them would deadlock, since acquireRunning is not reentrant.
"unlockStale": "lock hygiene, called only from inside a flag-holding caller; acquireRunning is not reentrant",
"resticStep": "the shared step runner — its own doc comment records that every CALLER holds the flag, which is what this test pins",
// Probes and readers that take no lock. `restic snapshots` and `restic list` were measured on
// demo-hp 2026-08-31 NOT to lock (6 back-to-back invocations, sampler read locks=0 throughout).
"offboxLatestSnapshot": "restic snapshots — measured 2026-08-31 not to take a lock; always called from a flag-holding caller anyway",
"ensureOffboxRepo": "restic cat config / init probe, called from inside flag-holding callers only",
"offboxInventory": "restic snapshots --json, a read; no lock taken",
// Read-only listing behind two web pages (the recovery page and the restore list). It issues only
// `restic snapshots --json`, and `snapshots` was MEASURED on demo-hp 2026-08-31 not to take a lock
// — six back-to-back invocations, the sampler read locks=0 throughout. Flagging it would make
// browsing a page refuse while a backup runs, for no safety gain: it can neither take a lock nor
// remove one.
"OffsiteInventoryList": "restic snapshots --json only; snapshots measured 2026-08-31 not to lock, and it never routes through resticStep",
"offsiteNewestPerTag": "restic snapshots --json only — the one reader behind OffsiteInventoryList and OffsiteSnapshotTimes (R-477, v0.240.0); same measurement, never routes through resticStep",
}
// offsiteReachers are the calls that mean "this function talks to the off-site repository".
var offsiteReachers = map[string]bool{
"resticStep": true, "resticBackupStep": true, "runner": true,
}
func r408WalkBackupPackage(t *testing.T) (map[string]*ast.FuncDecl, *token.FileSet) {
t.Helper()
root, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
fset := token.NewFileSet()
fns := map[string]*ast.FuncDecl{}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0) // comments DROPPED on purpose
if perr != nil {
t.Fatalf("parse %s: %v — the R-408 invariant is now unasserted", path, perr)
}
for _, decl := range f.Decls {
if fd, ok := decl.(*ast.FuncDecl); ok && fd.Body != nil {
fns[fd.Name.Name] = fd
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
return fns, fset
}
func r408Calls(fd *ast.FuncDecl) map[string]bool {
out := map[string]bool{}
ast.Inspect(fd.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
switch fn := call.Fun.(type) {
case *ast.Ident:
out[fn.Name] = true
case *ast.SelectorExpr:
out[fn.Sel.Name] = true
// `m.runner()(ctx, env, args...)` — the seam is invoked through the value it returns, so
// the outer CallExpr's Fun is itself a CallExpr. Catch that shape explicitly.
}
if inner, ok := call.Fun.(*ast.CallExpr); ok {
if sel, ok := inner.Fun.(*ast.SelectorExpr); ok {
out[sel.Sel.Name] = true
}
}
return true
})
return out
}
// TestR408_EveryOffsiteEntryPointTakesTheFlagOrIsRegistered — B1.
//
// RED-PROOF (run 2026-09-01, recorded in REPORT.md): removing the `acquireRunning` from
// `RestoreOffboxScratch` makes this fail naming that function; adding an unregistered fake entry point
// that calls `m.resticStep(...)` makes it fail naming the fake.
func TestR408_EveryOffsiteEntryPointTakesTheFlagOrIsRegistered(t *testing.T) {
fns, _ := r408WalkBackupPackage(t)
calls := map[string]map[string]bool{}
for name, fd := range fns {
calls[name] = r408Calls(fd)
}
// A function is COVERED if it acquires the flag itself, or every path to it is through a function
// that does. Fixed-point: start from the self-acquirers and propagate to their callees.
covered := map[string]bool{}
for name, c := range calls {
if c["acquireRunning"] {
covered[name] = true
}
}
if len(covered) == 0 {
t.Fatal("no function in internal/backup calls acquireRunning — the walk is looking in the wrong place, and a green here would be meaningless")
}
for i := 0; i < 12; i++ { // depth cap; the call graph here is shallow
grew := false
for name := range covered {
for callee := range calls[name] {
if _, ours := fns[callee]; ours && !covered[callee] {
covered[callee] = true
grew = true
}
}
}
if !grew {
break
}
}
var offenders []string
for name, c := range calls {
reaches := false
for r := range offsiteReachers {
if c[r] {
reaches = true
break
}
}
if !reaches || covered[name] || offsiteExempt[name] != "" {
continue
}
offenders = append(offenders, name)
}
sort.Strings(offenders)
if len(offenders) > 0 {
t.Fatalf("these functions reach the off-site repository without the single-writer flag and are not registered exempt: %v\n\n"+
"`resticStep` escalates to `unlock --remove-all` on a lock error, and its licence to do that is\n"+
"that every caller holds the flag. R-411 measured what happens when one does not: a live\n"+
"customer restore's lock was deleted and logged as a crash that never happened.\n\n"+
"Take acquireRunning, or add the function to offsiteExempt WITH the reason it cannot collide.", offenders)
}
}
// TestR408_TheProofsReadOnlyPathIsARegisteredException — B2.
//
// The exemption must stay HONEST: `restoreUnitReadOnly` is exempt only because it is read-only. If it
// ever gains `unlockStale`, or routes through `resticStep`, or loses `--no-lock`, the exemption is a
// lie and this fails.
func TestR408_TheProofsReadOnlyPathIsARegisteredException(t *testing.T) {
if offsiteExempt["restoreUnitReadOnly"] == "" {
t.Fatal("restoreUnitReadOnly must be a REGISTERED exception, with its reason, not silently absent")
}
fns, _ := r408WalkBackupPackage(t)
fd := fns["restoreUnitReadOnly"]
if fd == nil {
t.Fatal("restoreUnitReadOnly is gone — the exemption now covers nothing and must be removed")
}
c := r408Calls(fd)
if c["resticStep"] || c["resticBackupStep"] {
t.Fatal("restoreUnitReadOnly now routes through resticStep — the unlock --remove-all escalation is reachable and the exemption is no longer true")
}
if c["unlockStale"] {
t.Fatal("restoreUnitReadOnly now calls unlockStale — it issues a delete verb and the exemption is no longer true")
}
// And it must still pass --no-lock.
var sawNoLock bool
ast.Inspect(fd.Body, func(n ast.Node) bool {
if lit, ok := n.(*ast.BasicLit); ok && strings.Trim(lit.Value, `"`) == "--no-lock" {
sawNoLock = true
}
return true
})
if !sawNoLock {
t.Fatal("restoreUnitReadOnly no longer passes --no-lock — it can now take a lock and the exemption is no longer true")
}
// Its caller must hold the flag, or the exemption rests on nothing.
if !r408Calls(fns["ProveOffboxUnit"])["acquireRunning"] {
t.Fatal("ProveOffboxUnit no longer takes the flag — restoreUnitReadOnly's exemption depends on its caller holding it")
}
}