diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe68cf..658da2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,12 +75,21 @@ picker paths, and the system drive is deliberately not a registered StoragePath. `{{if .System}}` would have failed at render for every share. `ShareRow` is now package-level and the render test constructs the exact type the handler passes. -**Caught during the live deploy leg and fixed in the same version:** `EnsureImportRoot` ensured only +**Caught during the live legs and fixed in the same version (two things):** + +1. The carry-list initially kept `import`, `import/paperless` and `import/calibre`, so the skeleton + would RE-CREATE a per-drive drop-zone on every drive forever — the exact dead lookalike this arc + removes, and one that is never backed up. Dropped from the carry-list. This is not a removal: + nothing deletes the dirs an existing box has (both demo boxes' old drop-zones were verified to hold + **zero files** first); they stop being maintained and stop appearing on fresh boxes. + `TestSkeletonNeverCreatesAPerDriveDropZone` pins it, and `TestUserdataSkeleton_List` was updated to + assert their absence. +2. `EnsureImportRoot` ensured only the leaf, so `MkdirAll`'s intermediates left `/userdata` at `755 root:root` — the one userdata root on the box outside the 2775/gid-1000 convention. Both the parent and the import dir now carry it (`TestEnsureImportRoot_ParentCarriesTheConvention`). -Tests 915 → 950, all green. Red-proofs recorded in REPORT.md for Scenario B (classification), +Tests 915 → 951, all green. Red-proofs recorded in REPORT.md for Scenario B (classification), C (determinism) and E (server-side share refusal). No destructive filesystem call was added anywhere in this arc. **MinAgent unchanged.** diff --git a/controller/internal/appbackup/skeleton_determinism_test.go b/controller/internal/appbackup/skeleton_determinism_test.go index a27e431..312d6bb 100644 --- a/controller/internal/appbackup/skeleton_determinism_test.go +++ b/controller/internal/appbackup/skeleton_determinism_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" ) @@ -132,3 +133,31 @@ func TestEnsureUserdataSkeletonCreatesOnly(t *testing.T) { 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") + } +} diff --git a/controller/internal/appbackup/userdata.go b/controller/internal/appbackup/userdata.go index a7447b9..a0de30b 100644 --- a/controller/internal/appbackup/userdata.go +++ b/controller/internal/appbackup/userdata.go @@ -57,20 +57,25 @@ func ImportDir(nsRoot string) string { // // - `documents` is implied by NO catalog app (SPIKE P0(a)) yet exists on both demo boxes and is // customer-visible — it may hold customer files. Derivation alone would drop it. -// - `import/paperless` and `import/calibre` moved to the canonical system-drive root in R-75, so -// derivation no longer implies them under a data drive either. The pre-existing ones stay put; -// nothing in this arc deletes a directory. // // It doubles as the fresh-box floor: on a box whose catalog has not synced yet the derived set is // empty, and the customer still gets the full standard tree instead of a nearly-empty one. // +// DELIBERATELY ABSENT: `import`, `import/paperless`, `import/calibre`. They were in the v0.171.0 +// hardcoded list, and carrying them would have the skeleton RE-CREATE a per-drive drop-zone on every +// drive forever — the exact dead-lookalike R-75 exists to remove, and one that is never backed up +// (`class: excluded`). Zero-removals is about not DELETING what a box already has, not about +// re-creating it on boxes that never had it: nothing here removes the pre-existing dirs on +// demo-felhom / demo-hp, they simply stop being maintained and stop appearing on fresh boxes. +// Verified before the change: both boxes' old drop-zones held ZERO files (2026-07-26). A box with +// pending files in an old drop-zone would need an operator-run move — see REPORT.md. +// // ASCII, no spaces (flows through ${} interpolation, shell, and the rsync merge walk). func UserdataSkeletonCarry() []string { return []string{ "media", "media/movies", "media/tv", "media/music", "media/audiobooks", "media/books", "media/comics", "media/photos", "downloads", - "import", "import/paperless", "import/calibre", "roms", "documents", } diff --git a/controller/internal/appbackup/userdata_test.go b/controller/internal/appbackup/userdata_test.go index 5e6c11e..9e3050d 100644 --- a/controller/internal/appbackup/userdata_test.go +++ b/controller/internal/appbackup/userdata_test.go @@ -13,9 +13,12 @@ func TestSharedContentGID(t *testing.T) { } } -// TestUserdataSkeleton_List asserts the locked skeleton subdir set. R-75 renamed the hardcoded list -// to UserdataSkeletonCarry (it is now the non-derived carry-list); the asserted set is UNCHANGED, -// which is exactly the zero-removals promise. +// TestUserdataSkeleton_List asserts the locked carry-list. R-75 renamed the hardcoded list to +// UserdataSkeletonCarry (it is now the non-derived carry-list) and DELIBERATELY dropped the three +// `import*` entries: carrying them would re-create a per-drive drop-zone on every drive forever, the +// dead lookalike the canonical root exists to remove. That is not a removal — nothing deletes the +// dirs an existing box has; they stop being maintained and stop appearing on fresh boxes. Every other +// entry is unchanged, which is the zero-removals promise. func TestUserdataSkeleton_List(t *testing.T) { got := map[string]bool{} for _, s := range UserdataSkeletonCarry() { @@ -23,13 +26,18 @@ func TestUserdataSkeleton_List(t *testing.T) { } for _, want := range []string{ "media/movies", "media/tv", "media/music", "media/audiobooks", "media/books", - "media/comics", "media/photos", "downloads", "import/paperless", "import/calibre", + "media/comics", "media/photos", "downloads", "roms", "documents", } { if !got[want] { t.Errorf("skeleton missing %q", want) } } + for _, gone := range []string{"import", "import/paperless", "import/calibre"} { + if got[gone] { + t.Errorf("carry-list must NOT hold %q — the drop-zone is canonical on the system drive (R-75)", gone) + } + } } // TestUserdataDir confirms the userdata root is a sibling under the namespace.