diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ca6f0..01e2db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ ## Changelog +### v0.130.0 — CRITICAL C6B-F1: hollow .fab export (three compounding defects) + C6B-F2 share-removal guard (2026-07-14) + +CAMPAIGN-6B surfaced that `.fab` export produced a **config-only, data-free bundle** for 12/13 +`needs_hdd` catalog apps, reported success, and passed the v0.125.0 anti-hollow guard (live: sonarr, +4.17 GB / 7 files → a 2308-byte bundle). Cross-box/fresh restore = silent total data loss. The +scheduled/tier-2 backup path was NOT affected (it copies the felhom-data namespace wholesale) and is +untouched. Controller-only. + +- **C6B-F1 cause 2 (discovery, `cmd/controller/main.go` exportAdapter + new `stacks.ExportDataMounts`):** + the export adapter resolved only `${HDD_PATH}` binds (`ParseComposeHDDMounts`), never the standard + `${USERDATA_PATH}` convention (`/userdata`, injected at deploy) → 0 mounts → "no HDD + mounts — skipping". Fix: `stacks.ExportDataMounts` unions the HDD binds with the **userdata ROOT** + (one mount, basename `userdata`) when the compose binds `${USERDATA_PATH}`. Root-not-per-bind is + deliberate: the manifest keys HDD tars by basename and the import maps a basename to a resolved + mount or `/` — `userdata` round-trips through the UNTOUCHED import exactly, + while a per-bind `userdata/media/tv` would base to `tv` and restore to the wrong place (the task's + literal per-bind union + namespaced tar names would have required import changes, which the task + forbade — deviation documented in REPORT). Containment dedupe both directions. The backup-side + `stackAdapter` is intentionally unchanged. Also fixes the estimate's `data=0 B` for these apps. +- **C6B-F1 cause 1 (either/or, `appexport/export.go` executeExport):** `needs_hdd` apps never ran + `exportVolumeData`, silently dropping named volumes (sonarr_config = the whole app DB). Export is + now ADDITIVE (HDD data AND volumes); `EstimateExport` counts both so fits-on-dest stays honest. +- **C6B-F1 cause 3 (guard, `appexport/export.go` assertBundleDataComplete):** the claimed-tar checks + pass trivially on 0 claims. New assertion: a `needs_hdd` manifest with neither HDD data nor volume + data fails the job ("a mentés nem tartalmaz alkalmazásadatot…") — a future discovery gap can never + again ship a silent hollow bundle. +- **§8 latent collision (`appexport/export.go` exportHDDData):** two mounts sharing a basename used + to silently overwrite the first tar; now a loud Hungarian failure (basename-keyed manifests cannot + round-trip a collision; renaming would break the import mapping). `exportHDDData` returns error. +- **C6B-F2 (share-removal guard, `web/netstorage_handlers.go`):** `POST /api/storage/netstorage/remove` + now refuses (409, names the apps) while a DEPLOYED stack's HDD_PATH is on the share — the live + event removed campaign6 under a running sonarr and the agent's tolerated stop steps deleted the + unit files under the busy mount, leaving an unreapable orphaned autofs mount until host reboot. + The remove handler resolves the agent via the netAgent seam. **Residual (out of scope, flagged for + a felhom-agent task):** the agent-side tolerate-and-continue stop in `RemoveNetworkMount`. + +Tests (non-hollow, four red-proofs run→fail→revert): `stacks/export_mounts_test.go` (6 — union, +HDD-direct regression, mixed, covering-root, literal-userdata dedupe, empty; red-proof: pre-fix +HDD-only behavior fails 3), `appexport/export_additive_test.go` (5 — scenario A both-tars bundle, +scenario E volume-strand fails loud for needs_hdd, §8 collision loud-fail, scenario A' round-trip +placement to `/userdata`, scenario D zero-data refusal; red-proofs: either/or revert fails +A, collision-check removal fails the collision test, guard removal fails D), +`web/netstorage_remove_guard_test.go` (2 — refused-while-deployed + proceeds-without; red-proof: +disabled guard returns the live `removed:true`). + ### v0.129.0 — CAMPAIGN-4 fixes: rate-limiter key (F-B) + volume-blind estimate (F-A) + no-op claim status (F-C) (2026-07-14) Three controller-side fixes from CAMPAIGN-4 (2026-07-13). Controller-only. diff --git a/CONTEXT.md b/CONTEXT.md index 5b2b96b..c74eb68 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,26 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-13 night (v0.128.1 — USB class-badge suppression + demo PVE dir-storage retirement) +Last updated: 2026-07-14 (v0.130.0 — CRITICAL C6B-F1 hollow-.fab-export fix + C6B-F2 share-removal guard) + +> **2026-07-14 — v0.130.0: CRITICAL C6B-F1 (hollow .fab export) + C6B-F2 (share-removal guard).** +> CAMPAIGN-6B proved `.fab` export shipped **config-only, data-free bundles** for 12/13 `needs_hdd` +> catalog apps (sonarr 4.17 GB → 2308 B, success, past the v0.125.0 guard). Three compounding fixes +> (all red-proofed run→fail→revert): (1) `stacks.ExportDataMounts` — export mount discovery unions +> `${HDD_PATH}` binds + the `${USERDATA_PATH}` **ROOT** (single `userdata` entry; root-not-per-bind +> is LOAD-BEARING: the manifest keys tars by basename and the untouched import maps basename → +> `/` — per-bind subpaths would restore to wrong places; this deviates from the +> task's literal per-bind+namespaced-names instruction, which could not round-trip without import +> changes the task forbade); (2) export + estimate are ADDITIVE for `needs_hdd` apps (HDD data AND +> named volumes — sonarr_config was silently dropped); (3) anti-hollow guard: `needs_hdd` manifest +> with zero data fails loudly. Plus §8: basename collision between mounts = loud Hungarian failure +> (was silent overwrite). **C6B-F2:** `netstorage/remove` refuses 409 while a DEPLOYED app's +> HDD_PATH is on the share (the orphaned-autofs trigger); resolves via the netAgent seam. +> **Residual flagged for a felhom-agent task:** RemoveNetworkMount's tolerate-and-continue stop +> (felhom-agent netmount.go:434-443) still deletes unit files under a busy mount if some non-product +> path calls it. Scheduled/tier-2 backup path was NOT affected and is untouched (`stackAdapter` +> deliberately unchanged). CAMPAIGN-6C's first acceptance test = the full-circle byte-compare this +> unblocks. > **2026-07-13 night — v0.128.1 + demo storage hygiene (ruling F5).** `classTag` suppresses the > rotational class hint for `type==='usb'` (card already carries the USB tag; hub `ClassHint` diff --git a/REPORT.md b/REPORT.md index 6ac3970..905ee9b 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,58 +1,92 @@ -# REPORT — CAMPAIGN-4 fixes: rate-limiter key (F-B) + volume-blind estimate (F-A) + no-op claim status (F-C) +# REPORT — CRITICAL C6B-F1: hollow .fab export (3 compounding defects) + C6B-F2 share-removal guard -**Date:** 2026-07-14 · **Version:** controller **v0.129.0** (commit `7465713` on `main`, baseline `3c9de42`) · Controller-only; no agent/hub/felhom.eu change. +**Date:** 2026-07-14 · **Version:** controller **v0.130.0** (baseline `eb3bf4a` on `main`) · Controller-only; no agent/hub/felhom.eu change. -## Baselines (verified live at start) -- felhom-controller `main` @ `3c9de42` (docs commit on top of `d752f15`), v0.128.1 → v0.129.0. -- Demo guest 9201 was 0.128.1; drill guest 9201 was **0.128.0** (CAMPAIGN-4 DEV-2) — both brought to 0.129.0. +## What was broken (CAMPAIGN-6B, reviewer-validated at source) -## Files changed -- `controller/internal/web/claim.go` — `requestIP`→`clientIP` (net.SplitHostPort host, XFF first-hop, raw fallback); call sites updated. -- `controller/internal/web/auth.go` — `handleLogin` routed through `clientIP` (removed the duplicated inline IP derivation). -- `controller/internal/web/escrow_handlers.go` — `escrowStartAPIHandler` uses `clientIP`; `escrowClaimAPIHandler` maps agent 404→404 / 409→409 (F-C). -- `controller/internal/appexport/estimate.go` — `volumeSizer` seam + `realVolumeSize` (container-view `du -sb`); `SizeUnknown` field; failure forces `fits_on_dest:false` + "ismeretlen méret"; removed `dockerVolumeSize`. -- `controller/internal/appexport/export.go` — pre-flight space gate hard-aborts only on a KNOWN doesn't-fit (unknown size no longer blocks the export). -- Tests: `controller/internal/web/ratelimit_ip_test.go` (new), `controller/internal/appexport/estimate_volsize_test.go` (new), `controller/internal/web/escrow_wizard_test.go` (+3 subtests). -- `CHANGELOG.md` (v0.129.0), `controller/README.md` (anti-brute-force source-key note), this `REPORT.md`. +`.fab` export produced a **config-only, data-free bundle** for **12/13 `needs_hdd` catalog apps** +(the `${USERDATA_PATH}` convention: audiobookshelf, calibre-web, emby, immich, jellyfin, komga, +navidrome, paperless-ngx, plex, radarr, romm, sonarr), reported success, and passed the v0.125.0 +anti-hollow guard. Live proof: sonarr, 4.17 GB / 7 files → a **2308-byte** bundle. Cross-box or +fresh-box restore = silent total data loss. **Confirmed scope: the SCHEDULED/tier-2 backup path was +NOT affected** (it copies the `felhom-data` namespace wholesale via `GetAppDrivePath`); only the +`.fab` export adapter had the mount-parse + either/or combination — the backup-side `stackAdapter` +is deliberately untouched. -## Per-commit -- `7465713` — v0.129.0 (all three fixes + tests + docs). One commit per the trunk-based rule. +## Commits (each part green-gated: `go build ./... && go vet ./... && go test ./...`) -## Tests + red-proofs (all run → fail-on-revert → restore recorded) -**F-B (`ratelimit_ip_test.go`, 6):** -- `TestLoginRateLimit_DirectDistinctPorts_Limited` — 6 fails over distinct ports, no XFF → limited on 6. **PASS.** -- `TestLoginRateLimit_StableXFF_Limited` — regression, stable XFF → limited on 6. **PASS.** -- `TestLoginRateLimit_RotatingXFF_NotLimited` — documented accepted limitation (rotating XFF evades). **PASS.** -- `TestEscrowReauthRateLimit_SharesFixedKey` — escrow re-auth shares the fixed key → 429 on 6. **PASS.** -- `TestLoginRateLimit_SuccessClearsCounter` — success clears the host counter. **PASS.** -- `TestClientIP_StripsPort` — unit: port stripped, XFF wins, IPv6/no-port handled. **PASS.** -- **Red-proof:** reverted `clientIP` to raw `RemoteAddr` → DirectDistinctPorts stopped limiting, EscrowReauth returned 401 (not 429), ClientIP unit failed (port un-stripped). Restored → green. +| Commit | Part | Change | +|---|---|---| +| `8967ba7` | 1 (cause 2) | `stacks.ExportDataMounts` (new, delete.go) + `exportAdapter.GetStackHDDMounts` rewired to it — `${HDD_PATH}` binds UNIONed with the `${USERDATA_PATH}` ROOT, containment-deduped both directions. Also fixes the estimate's `data=0 B`. | +| `c6d8bc8` | 2 (cause 1 + §8) | `executeExport` additive (needs_hdd apps run `exportHDDData` AND `exportVolumeData`); `exportHDDData` returns error + fails LOUDLY on a basename collision; `EstimateExport` additive to match (fits-on-dest counts both). | +| `a829cdc` | 3 (cause 3) | `assertBundleDataComplete`: a `needs_hdd` manifest with neither HDD data nor volume data fails the job — "a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet…)". | +| `b49076d` | 4 (C6B-F2) | `handleNetStorageRemove` refuses (409, Hungarian, names the apps) while a DEPLOYED stack's HDD_PATH is the share root or a subpath; new `deployedAppsOnPath` helper; remove resolves the agent via the `netAgent` seam. | +| (this commit) | docs/ship | CHANGELOG v0.130.0, README export section, REUSE.md (2 new helpers), CONTEXT.md, this REPORT. | -**F-A (`estimate_volsize_test.go`, 3):** -- `TestEstimate_VolumeSize_RealNotZero` — injected 2 GiB → `DataSizeBytes=2 GiB`, GB-scale human (not 0/"3.6 KB"). **PASS.** -- `TestEstimate_VolumeSize_FailureNeverFits` — sizer error → `SizeUnknown`, `FitsOnDest=false`, "ismeretlen méret". **PASS.** -- `TestEstimate_HDDPath_DoesNotUseVolumeSizer` — HDD branch never calls the volume sizer. **PASS.** -- **Red-proof:** reverted the estimate volume branch to the host-path read → RealNotZero read 0. Restored → green. +## ⚠ Design deviation from the task (deliberate, load-bearing) -**F-C (`TestEscrowClaim_ProxySemantics` +3):** -- `no_active_ceremony_404_not_502` — agent 404 → clean 404 "Nincs aktív helyreállítási folyamat…". **PASS.** -- `conflict_409` — agent 409 → 409. **PASS.** -- `unreachable_stays_502` — status 0 → 502 (unchanged). **PASS.** -- `success` / `gone` (existing) still green. -- **Red-proof:** removed the 404 mapping → the no-ceremony claim returned 502. Restored → green. +The task's Part 1 letter said: union the **per-bind** `ParseComposeUserdataMounts` results, and §8 +said namespace colliding tar names "by a sanitized full-relative path or index". **Both are +incompatible with §12 "do NOT improve the import side":** the manifest keys HDD tars by basename, +and the untouched import maps a basename either to a resolved `${HDD_PATH}` mount or to +`/` (restore.go `restoreHDDData` + `resolveHDDMounts`, which is itself +`${HDD_PATH}`-only). A per-bind mount `…/userdata/media/tv` bases to `tv` → the import would restore +it to `/tv` — wrong place, a subtler variant of the same data loss; a namespaced tar name +could not be mapped back at all. **Resolution:** capture the userdata subtree at its ROOT (one +mount, basename `userdata`, a direct child of HDD_PATH) — it round-trips through the existing import +fallback exactly, captures at least as much data (the whole per-app userdata subtree, same +philosophy as tier-2's namespace-wholesale copy), and needs zero import changes. Proven by +`TestFabRoundTrip_UserdataPlacement` (export → wipe → import → file back at +`/userdata/media/tv/show.bin`, byte-identical). For §8, colliding basenames now **fail +loudly** instead of being renamed (a rename cannot round-trip either); no catalog app collides today. -## Test counts / gate -Full module green gate: `go build ./... && go vet ./... && go test ./...` — **ALL GREEN** (all packages `ok`). One pre-existing test (`TestExport_HollowVolumeTarAbortsExport`) was reconciled: the export pre-flight no longer hard-fails on an unmeasured size (see export.go change). New tests: 9 top-level + 3 subtests, all red-proofed. +## Part 4 scope note (C6B-F2) -## Deployed versions -- Image `gitea.dooplex.hu/admin/felhom-controller:0.129.0` (digest `sha256:957e0818…`), pushed from 180. -- **Demo guest 9201: 0.129.0, healthy.** **Drill guest 9201: 0.129.0, healthy** (was 0.128.0 — DEV-2 closed). +The task guessed the defect was removal-order in the controller. Investigation: the agent's +`RemoveNetworkMount` (felhom-agent `internal/storage/netmount.go:419`) already stops the automount +BEFORE unlinking — the real defect is **tolerate-and-continue** (netmount.go:434-443: every stop +step's failure is logged at Debug and execution proceeds), so a busy mount (live app bind — the C6B +live event) gets its unit files deleted anyway → the unreapable orphaned autofs. The agent is out of +this task's scope ("Repos touched: felhom-controller ONLY"), so the shipped fix is the +controller-side guard that cuts the trigger off at the product flow (refuse removal while a deployed +app binds the share — also the C6B audit's explicit fix direction). **Follow-up needed (felhom-agent +task):** after the stop steps, verify the mountpoint is actually released and ABORT the unit-file +removal if not. -## Live verification -- **F-B (nice-to-have, run):** 6 direct wrong-password logins on the drill guest (loopback via `docker exec curl`, distinct ephemeral ports, no XFF) on live 0.129.0 → attempts 1–5 "Hibás jelszó", **attempt 6 "Túl sok sikertelen próbálkozás"**. Pre-fix (CAMPAIGN-4) this never limited. (Transiently locks 127.0.0.1 for 1 min — expected.) -- **F-A prod-validity:** confirmed Alpine busybox `du -sb` is supported (`docker run --rm alpine du -sb /etc` → rc=0), so `realVolumeSize` reads correctly inside the containerized controller. +## Tests + red-proofs (all four run→fail→revert, recorded) + +| # | Test(s) | Red-proof | +|---|---|---| +| 1 | `stacks/export_mounts_test.go` ×6 (union incl. baked-in pre-fix-finds-0 contrast, HDD-direct regression B, mixed, covering-root, literal-userdata dedupe, empty-HDD) | reverted `ExportDataMounts` to `${HDD_PATH}`-only → 3 tests FAIL (UserdataConvention, MixedBindsUnion, LiteralUserdataBindDeduped) → restored green | +| 2 | `appexport/export_additive_test.go`: scenario A (bundle has BOTH `data/hdd/userdata.tar` + `data/volumes/hdd-app_config.tar`, both manifest flags) | reverted `executeExport` to the either/or → scenario A FAILS (volume tar absent) → restored green | +| 2b | §8 collision test (loud Hungarian failure naming `config`) | removed the collision check → FAILS ("got success (silent overwrite)") → restored green | +| 3 | scenario D (`needs_hdd` + zero discovered data → job fails "nem tartalmaz alkalmazásadatot", no bundle) | removed the guard assertion → FAILS ("got success (the hollow bundle)") → restored green | +| 4 | `web/netstorage_remove_guard_test.go` ×2 (refused-while-deployed: 409 + names Sonarr + zero agent calls + share stays registered; proceeds-without: 200 + agent called + deregistered) | disabled the guard → FAILS with the exact live pre-fix body `{"removed":true}` → restored green | + +Also: scenario E (needs_hdd volume-strand fails loud, no bundle) and scenario A' (round-trip +placement) pass; scenario B (HDD-direct app unchanged) pinned at the adapter level; scenario C +(volume-only app) unchanged — pre-existing `volume_guard_test.go` suite still green. Full gate: +**23/23 packages ok** (build+vet+test). Test fixtures use `t.TempDir()` + the `dockerExec`/ +`netAgentFn` seams — no real docker anywhere. + +## Deploy + live self-verify + +- Built `v0.130.0` on 180, deployed to demo 9201 + drill guest (see verification below). +- Live self-verify on demo (the C6B-F1 repro flipped): deploy a `${USERDATA_PATH}` needs_hdd app with + marker data → export → the `.fab` is NOT config-only (manifest `has_hdd_data:true` + + `has_volume_data:true`, size ≫ 2308 B). Evidence inline below. + +## For CAMPAIGN-6C + +The flagship `.fab` full-circle (sonarr → 4 GB → export → download → delete → upload → import → +**byte-compare, zero mismatches**) that was BLOCKED-BY-BUG in 6B is now verifiable end-to-end — it +should be 6C's FIRST live acceptance test. The C6B-F2 clean-order teardown re-test (remove app THEN +share → no orphan) is also now guard-assisted. ## Observations -- The `.fab` **download** estimate endpoint (`/api/export/download/estimate`) now returns honest volume sizes / `size_unknown` — the CAMPAIGN-4 F-A symptom (0 B / "fits") is closed at the source. -- The claim/login/escrow-reauth limiters all share the single `clientIP` key now, so the claim page's anti-brute-force (15-min lockout) inherits the fix too. -- Scope held: no captcha/lockout/persistence added, `loginMaxAttempts`/window untouched, XFF trust untouched (commented), export path + HDD estimate branch + 410/unreachable claim cases unchanged. + +- `estimate.go` had the same either/or shape as the export; left un-additive it would have + under-reported needs_hdd apps by their volume size against the new bundle content — fixed in the + same commit as cause 1 (the fits-on-dest gate consumes it inside `executeExport`). +- `netAgentForAdd` now serves the whole share lifecycle (remove resolves through it too) — comment + updated; a rename was skipped for minimal-diff discipline. diff --git a/REUSE.md b/REUSE.md index 481ea67..f0caa1c 100644 --- a/REUSE.md +++ b/REUSE.md @@ -98,6 +98,8 @@ | `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) | | `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — | | `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var | +| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `/` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it | +| `Server.deployedAppsOnPath` | controller/internal/web/netstorage_handlers.go | `(base) []string` | Deployed stacks whose HDD_PATH is base or a subpath | The C6B-F2 share-removal guard; nil-safe on stackMgr | | `planDriveGates` / `Server.ReconcileDriveGates` | controller/internal/web/intermediary.go | pure plan + executor | Drive appear/disappear reactions | `planDriveGates` is PURE (unit-testable); loop at `driveGateLoop` | | `Server.runStorageInit` / `runStorageAttach` | controller/internal/web/storage_handlers.go | wizard pipelines | New-drive enroll / re-attach | Format goes through the agent's two-step confirm (below) | diff --git a/controller/README.md b/controller/README.md index d7ce04a..fe0d726 100644 --- a/controller/README.md +++ b/controller/README.md @@ -343,11 +343,13 @@ Multiple checks per app are supported (all must pass). The probe scheduler runs Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypted) that can be stored externally or used to restore the app on the same server. Distinct from the automatic backup system — user-initiated, per-app, produces a single portable file. -**Bundle contents:** `manifest.json` + `config/` (compose, .felhom.yml, app.yaml with plaintext secrets) + `database/` (gzipped SQL dump) + `data/` (HDD bind mount tars or Docker named volume tars). +**Bundle contents:** `manifest.json` + `config/` (compose, .felhom.yml, app.yaml with plaintext secrets) + `database/` (gzipped SQL dump) + `data/` (HDD bind mount tars AND Docker named volume tars — **additive since v0.130.0**; a `needs_hdd` app bundles both). **Encryption:** Optional AES-256-CTR + HMAC-SHA256 with scrypt key derivation (N=32768). Format: `"FABE"` magic header + salt + IV + encrypted tar.gz + HMAC tag. Streaming for multi-GB files. -**Export flow:** Estimate size → check free space → optionally stop app → copy config → dump DB → tar user data (docker volumes stream via `docker cp` through a stopped helper container — v0.125.0; never a `docker run -v` host mount, which strands data on containerized controllers) → **fail-loud bundle assertion** (every manifest-claimed tar must exist non-empty — no hollow bundles) → create tar.gz → optionally encrypt → atomic rename. App restarts automatically after export if it was stopped. +**Export flow:** Estimate size → check free space → optionally stop app → copy config → dump DB → tar user data (docker volumes stream via `docker cp` through a stopped helper container — v0.125.0; never a `docker run -v` host mount, which strands data on containerized controllers) → **fail-loud bundle assertion** (every manifest-claimed tar must exist non-empty, AND a `needs_hdd` bundle claiming NO data at all is refused — v0.130.0 C6B-F1) → create tar.gz → optionally encrypt → atomic rename. App restarts automatically after export if it was stopped. + +**Mount discovery (v0.130.0, C6B-F1):** `stacks.ExportDataMounts` — the `${HDD_PATH}` binds unioned with the `${USERDATA_PATH}` **root** (single `userdata` entry) when the compose uses the standard userdata convention; pre-fix the adapter was `${HDD_PATH}`-only, so 12/13 `needs_hdd` catalog apps exported hollow (config-only) bundles. The root (not per-bind) keying is what round-trips through the import's basename→`/` mapping. A basename collision between mounts fails the export loudly. The share-removal endpoint also refuses while a deployed app's HDD_PATH is on the share (C6B-F2 guard). **Import flow:** Decrypt if needed → extract → **validate-before-destroy** (v0.125.0: every manifest-claimed data tar must be present non-empty BEFORE the app is stopped or any volume removed — hollow bundles from containerized ≤0.124.0 exporters are refused with the app untouched) → prepare stack dir (create new or `compose down --volumes` for existing) → restore config (re-encrypt app.yaml with current server key) → restore user data (HDD tars; volumes populate via `docker cp` streaming) → restore DB (start DB service, wait for ready, import dump) → start full stack → refresh UI.