8fadbd9891
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.
164 lines
7.0 KiB
Go
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")
|
|
}
|
|
}
|