2958946517
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
135 lines
5.5 KiB
Go
135 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|