Files
felhom-controller/controller/internal/appbackup/skeleton_determinism_test.go
T
admin 8fadbd9891 v0.172.0 fixup 2: drop import/* from the carry-list
Found on the demo-hp live leg: with import, import/paperless and import/calibre
in the carry-list, the derived skeleton RE-CREATES a per-drive drop-zone on every
drive forever — the dead lookalike the canonical root exists to remove, and one
that is never backed up (class: excluded).

Not a zero-removals violation: nothing deletes what an existing box has. Both
demo boxes' old drop-zones were verified to hold zero files before the change.
2026-07-26 08:22:13 +02:00

164 lines
7.0 KiB
Go

package appbackup
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// R-75 Scenario C — DETERMINISM. This is the P6 gate.
//
// The spike measured the naive map-order derivation producing 20 DISTINCT outputs from 20 identical
// runs. fbNeedsRecreate force-recreates FileBrowser on ANY byte difference in the generated config,
// and SyncFileBrowserMounts has ~14 call sites — so a non-deterministic skeleton is a fleet-wide
// FileBrowser restart loop, the v0.151-class bug. 20 identical generations or this fails.
func TestScenarioC_SkeletonDeterminism(t *testing.T) {
// Deliberately UNSORTED input, with duplicates and a deep path, so the function has real work to
// normalise. A sort applied only to the input would not save a map-ordered implementation.
derived := []string{
"media/podcasts", "roms", "media/books", "downloads", "media",
"media/photos", "media/books", "a/b/c/d",
}
const n = 20
first := BuildUserdataSkeleton(derived)
for i := 1; i < n; i++ {
got := BuildUserdataSkeleton(derived)
if !slices.Equal(got, first) {
t.Fatalf("generation %d/%d differs — a non-deterministic skeleton force-recreates FileBrowser on every sync pass\n first: %v\n got: %v",
i+1, n, first, got)
}
}
if !slices.IsSorted(first) {
t.Errorf("skeleton must be sorted, got %v", first)
}
// Ancestor expansion: a deep derived path implies its whole chain.
for _, want := range []string{"a", "a/b", "a/b/c", "a/b/c/d"} {
if !slices.Contains(first, want) {
t.Errorf("ancestor chain incomplete: %q missing from %v", want, first)
}
}
// Dedup: "media/books" appeared twice in the input and "media" both derived and as an ancestor.
for _, d := range []string{"media", "media/books"} {
if c := countOf(first, d); c != 1 {
t.Errorf("%q appears %d times, want exactly 1", d, c)
}
}
}
func countOf(xs []string, want string) int {
n := 0
for _, x := range xs {
if x == want {
n++
}
}
return n
}
// R-75 Scenario D — ZERO REMOVALS, proven by construction.
//
// The derived set drops `documents` (implied by no catalog app) and, after the R-75 move, the two
// import/* entries. The carry-list is what keeps them. This asserts the merged set is a strict
// SUPERSET of the historical hardcoded skeleton for any derived input — including the empty one, the
// fresh-box case where the catalog has not synced yet.
func TestScenarioD_SkeletonNeverDropsACarriedDir(t *testing.T) {
for _, derived := range [][]string{
nil, // fresh box, catalog not yet synced
{"media/podcasts"}, // the one genuinely new entry
{"roms", "downloads", "media/photos"}, // a partial catalog
} {
got := BuildUserdataSkeleton(derived)
for _, carried := range UserdataSkeletonCarry() {
if !slices.Contains(got, carried) {
t.Errorf("derived=%v: carried dir %q was DROPPED — zero-removals violated", derived, carried)
}
}
}
// And the new entry really is added when the catalog implies it.
if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "media/podcasts") {
t.Error("media/podcasts must be added when the catalog implies it")
}
// `documents` is the specific entry the spike flagged: in the carry-list, in no catalog app.
if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "documents") {
t.Error("`documents` must survive — it exists on both demo boxes and may hold customer files")
}
}
// A traversal or absolute entry reaching the skeleton would make EnsureUserdataSkeleton create a
// directory outside the userdata root. The derived set comes from a compose parser, so this is a
// guard on untrusted-ish catalog input, not defence in depth.
func TestSkeletonRefusesEscapes(t *testing.T) {
got := BuildUserdataSkeleton([]string{"../escape", "..", "", "/abs/path", "ok/dir"})
for _, bad := range []string{"../escape", "..", "", "/abs/path"} {
if slices.Contains(got, bad) {
t.Errorf("escape entry %q must not reach the skeleton: %v", bad, got)
}
}
for _, d := range got {
if filepath.IsAbs(d) || d == ".." || len(d) > 3 && d[:3] == "../" {
t.Errorf("unsafe skeleton entry %q", d)
}
}
if !slices.Contains(got, "ok/dir") {
t.Error("a legitimate entry alongside bad ones must still be kept")
}
// "/abs/path" is not dropped outright — it is normalised to a relative path and kept, which is
// safe (it lands under the userdata root). Pin that so the behaviour is a decision, not a guess.
if !slices.Contains(got, "abs/path") {
t.Errorf("an absolute entry should be normalised to relative, got %v", got)
}
}
// EnsureUserdataSkeleton creates every dir it is given and NOTHING ELSE, and never removes.
func TestEnsureUserdataSkeletonCreatesOnly(t *testing.T) {
ns := t.TempDir()
// A pre-existing customer dir that no catalog app implies and the carry-list does not contain.
stray := filepath.Join(UserdataDir(ns), "sajat-mappa")
if err := os.MkdirAll(stray, 0o755); err != nil {
t.Fatal(err)
}
dirs := BuildUserdataSkeleton([]string{"media/podcasts"})
if err := EnsureUserdataSkeleton(ns, dirs); err != nil {
// chown to gid 1000 fails for a non-root test user; the dirs are still created.
t.Logf("EnsureUserdataSkeleton returned %v (expected when not running as root)", err)
}
for _, d := range dirs {
if fi, err := os.Stat(filepath.Join(UserdataDir(ns), d)); err != nil || !fi.IsDir() {
t.Errorf("skeleton dir %q not created: %v", d, err)
}
}
if _, err := os.Stat(stray); err != nil {
t.Errorf("a pre-existing customer dir was removed — zero-removals violated: %v", err)
}
}
// R-75: a DATA drive must never get a per-drive drop-zone from the skeleton. Carrying the old
// `import/*` entries would have the skeleton re-create a dead lookalike on every drive forever —
// one that is also never backed up, since import paths are class: excluded.
//
// This is NOT a zero-removals violation: nothing deletes the dirs a box already has (see
// TestEnsureUserdataSkeletonCreatesOnly). They stop being maintained and stop appearing on fresh boxes.
func TestSkeletonNeverCreatesAPerDriveDropZone(t *testing.T) {
// The catalog no longer implies any ${USERDATA_PATH}/import path — the binds moved to
// ${IMPORT_PATH} — so the only way one could appear is via the carry-list.
for _, derived := range [][]string{nil, {"media/podcasts", "roms"}} {
for _, d := range BuildUserdataSkeleton(derived) {
if d == "import" || strings.HasPrefix(d, "import/") {
t.Errorf("derived=%v: skeleton created a per-drive drop-zone %q — the canonical root is on the SYSTEM drive", derived, d)
}
}
}
for _, c := range UserdataSkeletonCarry() {
if c == "import" || strings.HasPrefix(c, "import/") {
t.Errorf("the carry-list still holds %q", c)
}
}
// A catalog app that genuinely declares a ${USERDATA_PATH}/import/... bind would still be
// honoured — the rule is "don't carry them", not "filter them out".
if !slices.Contains(BuildUserdataSkeleton([]string{"import/valami"}), "import/valami") {
t.Error("a genuinely derived userdata import path must still be created")
}
}