package appbackup import ( "os" "path/filepath" "slices" "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) } }