v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces

${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.
This commit is contained in:
2026-07-26 08:12:57 +02:00
parent 3b672ba74c
commit 2958946517
57 changed files with 2228 additions and 151 deletions
+79
View File
@@ -1,5 +1,84 @@
## Changelog
### v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces (2026-07-26)
Spike: `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`.
**The drop-zone is now ONE canonical location on the system drive.** New `${IMPORT_PATH}` =
`<system namespace root>/userdata/import`, injected at BOTH compose-env builders
(`withUserdataPath``withPathVars`, `deploy.go` + `manager.go`) — the initial-deploy path missing
`USERDATA_PATH` once bound a bogus root-owned dir at the container root, and `IMPORT_PATH` has the
identical failure mode. It is derived from the SYSTEM drive, never from `HDD_PATH`, and has **no
per-drive fallback**: an unresolvable root leaves the variable UNSET so compose fails loudly instead
of quietly building a second, non-functional drop-zone. *Operator ruling, overriding the spike's
Fork-1 recommendation:* each drop-zone app has exactly one ingest bind, so a per-drive `import/`
would put a folder that LOOKS like a drop-zone on every drive while only one works — and since
import paths are `class: excluded`, files stranded in a dead one are never backed up either.
**Third `BindRoot` + the whole-block regression it prevents.** `RootImport` / `${IMPORT_PATH}` in
`composeVarRoots`, an `Import []BindSpec` list in `BackupSpec`, and `ValidateBackupSpec` /
`ClassifyBinds` extended. This is load-bearing: `ValidateBackupSpec` rejects an entry matching no
compose bind and the rejection is WHOLE-BLOCK, so moving paperless's ingest bind while leaving
`userdata: import/paperless` in place would have discarded the entire block — taking
`hdd: appdata/paperless/media class: mandatory` with it and silently degrading the customer's
document originals to legacy handling. `TestScenarioB_*` is the gate.
**Exhaustive-root audit — `resolveAbs` was the sharp one.** An import bind resolved against `hddPath`
would name a directory on the WRONG DRIVE. `resolveAbs`, `structuralGuard`, `ComputeCaptureSet` and
`ComputeFabBuckets` now take `importRoot` explicitly (compile-forced at all 4 call sites), and an
unresolvable root is refused LOUDLY into `Skipped` (`reasonNoImportRoot`) rather than joined onto "".
`GetImportRoot()` added to both provider interfaces + both adapters. `fabplan`/`tier2DestRel`/
`export.go`/`appbackup_bridge.go` audited and recorded in REPORT.md.
**Catalog-derived skeleton, deterministic by construction.** `UserdataSkeleton()`
`UserdataSkeletonCarry()` (the v0.171.0 list verbatim, retained forever) + `BuildUserdataSkeleton()`,
which merges it with `DeriveUserdataDirs(stacksDir)` and **sorts**. The carry-list makes zero-removals
true by construction — `documents` is implied by no catalog app yet exists on both demo boxes — and
doubles as the fresh-box floor. The sort is not tidiness: the spike measured the naive map-order
derivation at **20 distinct outputs from 20 identical runs**, and `fbNeedsRecreate` force-recreates on
any byte difference across ~14 `SyncFileBrowserMounts` call sites — a fleet-wide FileBrowser restart
loop. `TestScenarioC_SkeletonDeterminism` pins 20/20. The catalog sync is deliberately **still not**
wired to `SyncFileBrowserMounts`. The canonical import root is excluded from per-app migration
(`appDataSkipSet`) so it never moves with an app.
**One authoritative compose parser.** `ParseComposeUserdataMounts` is now a thin resolver over
`ParseComposeClassifiableBinds`. The classifier won because it is the richer of the two byte-identical
scanners (it keeps the root and the `:ro` flag). One deliberate behaviour drop, recorded not hidden:
the old textual replace also accepted a LITERAL absolute path under `userdataPath`; no catalog
template has ever used that form and such a compose would be pinned to one machine's drive layout.
The deploy belt now handles both roots, gated differently — the drive-absent gate applies to the app's
data drive and must NOT suppress a system-drive import dir.
**Surfaces.** FileBrowser gains a separate `/srv/beolvasas` bind + a „Beolvasás" sidebar source
(separate, not nested — a nested source is indexed twice). New app-page block **„Hova tegyem a
fájlokat?"** for DEPLOYED apps declaring `data_paths`, with a deep link built from the shipped
Quantum router template, `url.PathEscape` per segment (**never `QueryEscape`** — it encodes space as
`+`, a literal plus in a path), the system-drive free space on import rows, and a **class-driven**
consequence line so the UI can never promise a backup the engines do not make. Copy does not promise
one click: a cold deep link goes through the FileBrowser login.
**`data_paths:` annotation** (`stacks.Metadata.DataPaths`) — role + Hungarian label over paths that
must ALREADY exist as compose binds; it can never declare one. Fork-3 asymmetry, deliberate: a
malformed PATH is a whole-block reject (data handling; reuses `ValidateBackupSpec`'s refusal set via
the extracted `appbackup.ValidateRelPath` — no second validator), an unknown ROLE fails OPEN with one
WARN (presentation; the `Lifecycle` precedent). Catalog: paperless-ngx, calibre-web, romm.
**System-owned import share.** `SMBShare.System`; a `beolvasas` share auto-created when sharing is
ENABLED (never before — deploying an app must not put SMB on the household LAN), `Offsite: false`
because the data is `class: excluded`. Deletion refused **server-side at both the handler and the
store**, and the button omitted in the template — three checks proving different things (the v0.70.1
ghost-delete lesson: a render gate is not enforcement, a handler test is not reachability). The share
is written directly rather than through `sharingResolvePath`: that guard validates CUSTOMER-supplied
picker paths, and the system drive is deliberately not a registered StoragePath.
**A latent 500 caught on the way:** the sharing template's row struct was function-local, so adding
`{{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.
Tests 915 → 949, 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.**
### v0.171.0 — Disk-health card: device-model label (pairs with agent v0.95.0) (2026-07-25)
`agentapi.SmartSummary` gains `ModelName` (mirrors the agent v0.95.0 `model_name`); the "Lemezek
+31 -1
View File
@@ -7,7 +7,37 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-07-24 (v0.169.0 — disk-health card + degradation notification "Lemezek állapota")
Last updated: 2026-07-26 (v0.172.0 — R-75 canonical import root + catalog-derived skeleton)
> **2026-07-26 — v0.172.0 (R-75).** Spike `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`;
> feature doc `felhom.eu/documentation/controller/import-and-data-paths.md`.
>
> **RULING — the import root is CANONICAL on the system drive, overriding the spike's Fork-1
> recommendation of per-drive roots.** The spike weighed sidebar clutter and per-app link ambiguity and
> concluded per-drive; the operator overruled it on an argument the spike missed: each drop-zone app has
> exactly ONE ingest bind, so on a two-drive box every import folder except the app's own would look like
> a drop-zone and silently do nothing — and because `import/*` is `class: excluded`, files stranded there
> are never backed up either. A canonical root is the only shape with no dead drop-zone. Recorded as a
> deliberate deviation, not an oversight.
>
> **Phase-0 probe changed the shape of Part 6.** The system drive is NOT a registered `StoragePath` on
> either demo box (`/mnt/felhom-drives/hdd_1` on demo-felhom; `nvme-1tb` + `Felhom-Share` on demo-hp),
> so `sharingResolvePath` REFUSES `<sysroot>/userdata/import` — verified against the real guard with a
> passing control. Registering the drive was rejected (it would make the 50 GB volume holding the
> recovery units a customer-visible drive, deploy target and wipe candidate, and `SharingDeniedRoots`
> would then deny the namespace-consistent shape anyway). **Chosen: leave it unregistered and have the
> controller write the `beolvasas` share directly** — the picker guard validates CUSTOMER-supplied paths,
> a controller-generated constant is a different trust class. No guard was weakened.
>
> Also note: `withUserdataPath` computes `USERDATA_PATH` as `<hdd>/userdata`, NOT
> `NamespaceRoot(hdd)/userdata`. For an app on the system drive those disagree
> (`/mnt/sys_drive/userdata` vs the `felhom-data` namespace). Latent — no app with a userdata bind has
> ever been deployed there — but it is a real inconsistency, left untouched here.
>
> The other three forks followed the spike unchanged: all-apps skeleton / deployed-only in the UI;
> unknown role fails OPEN while a malformed path whole-block rejects; drop-zone copy driven by the
> derived backup class.
> **2026-07-24 — v0.169.0 (disk-health card + degradation alert).** Consumes the agent's new `smart`
> field (agent v0.94.0; MinAgent floor unchanged — feature-detect by presence). **Rulings:** (1) ONE
+5 -1
View File
@@ -14,7 +14,11 @@
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path |
| `AppDBDumpPath` / `AppVolumeDumpPath` / `AppDataDir` | controller/internal/appbackup/paths.go | `(nsRoot, stackName) string` | Per-app dump/data dirs | Same nsRoot contract. `AppDataDir`'s final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → `paperless`); resolve via `AppDataDirNames` first (F-S2/F-S3) |
| `AppDataDirNames` / `AppDataBindsPresent` | controller/internal/appbackup/paths.go | `(hddPath, stackName string, hddMounts []string) []string` / `(hddPath, hddMounts) bool` | Resolve the real `appdata/<name>` dir(s) from compose `${HDD_PATH}` binds (F-S2/F-S3) | `hddMounts` = ParseComposeHDDMounts shape. Deduped+sorted; falls back to `[stackName]` when no appdata bind. Tier-2 (`backup.Manager.tier2AppDataName`) refuses N>1; migrate (`stacks.Manager.ResolveAppDataDirNames`) loops N. `BindsPresent` drives the WARN-on-missing-declared-dir |
| `UserdataDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention | Linux-only chown via build-tag twin userdata_linux.go |
| `UserdataDir` / `ImportDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(nsRoot)` / `(nsRoot, dirs []string)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention. **R-75:** `ImportDir` is the CANONICAL drop-zone (`<nsRoot>/userdata/import`) and callers MUST resolve it against the SYSTEM namespace, never an app's HDD_PATH — use `stacks.Manager.GetImportRoot()`. `EnsureUserdataSkeleton` now takes the dir set: build it with `BuildUserdataSkeleton(DeriveUserdataDirs(stacksDir))`, or via `Manager.EnsureUserdataSkeleton` / `web.Server.ensureUserdataSkeleton`. | Linux-only chown via build-tag twin userdata_linux.go. **The set MUST stay sorted**`fbNeedsRecreate` force-recreates FileBrowser on any byte diff and the naive map-order derivation measured 20/20 distinct (SPIKE P6). `UserdataSkeletonCarry()` is the old hardcoded list, retained forever so derivation can only ADD (zero removals). |
| `BuildUserdataSkeleton` / `UserdataSkeletonCarry` / `DeriveUserdataDirs` | appbackup/userdata.go, stacks/skeleton_derive.go | `([]string)` / `()` / `(stacksDir)` | catalog-derived userdata skeleton (R-75) | Derives `${USERDATA_PATH}` binds only — `${IMPORT_PATH}` is NOT part of a drive skeleton (one root, system drive, `Manager.EnsureImportRoot`). Do NOT wire the catalog sync to `SyncFileBrowserMounts`. |
| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** |
| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). |
| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. |
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
| `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/<n>` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert |
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
+22 -2
View File
@@ -393,8 +393,18 @@ Each app can define rich metadata in `.felhom.yml`:
surfaces it on `/apps/{slug}` as a "Kezdeti belépési adatok" card (masked password + reveal/copy),
labelled as the *initial* credential (stays valid only until the customer changes it in-app). Hidden
when the container is down / file missing / parse fails. Reuse for any future self-seeding app.
- `backup` (v0.132.0): the **referential-coupling classification** block (Task 2). Two optional lists,
`userdata:` (relative to `${USERDATA_PATH}`) and `hdd:` (relative to `${HDD_PATH}`), each of
- `data_paths` (v0.172.0, R-75): the **customer-facing folder annotation**`{path, root, role, label}`
where `root ∈ {import, userdata, hdd}` and `role ∈ {import, library, export}`. It ANNOTATES paths that
must already exist as compose binds and can never declare one (so no new filesystem-write primitive
comes from catalog data). Validation is deliberately ASYMMETRIC: a malformed **path** is a whole-block
reject (reusing `appbackup.ValidateRelPath`, the same refusal set as `backup:`), an unknown **role**
fails OPEN with one WARN (the `Lifecycle` precedent — presentation, not data handling). Rendered on
`/apps/{slug}` as „Hova tegyem a fájlokat?" for DEPLOYED apps only, each row a FileBrowser deep link
plus a **class-driven** consequence line. Full contract:
`felhom.eu/documentation/controller/import-and-data-paths.md`.
- `backup` (v0.132.0; **three** lists since v0.172.0): the **referential-coupling classification** block
(Task 2). Optional lists `userdata:` (relative to `${USERDATA_PATH}`), `hdd:` (relative to
`${HDD_PATH}`) and `import:` (relative to `${IMPORT_PATH}`, R-75), each of
`{path, class}` where `class ∈ {mandatory, optional, excluded}` (COUPLED / DECOUPLED-precious /
DECOUPLED-bulk). `LoadMetadata` validates the block against the app's compose binds and **rejects the
WHOLE block** (→ nil + one `[ERROR]`, app behaves as legacy) on any defect. Semantics
@@ -501,6 +511,16 @@ The `/apps/{slug}` page renders hero section, screenshots, setup guide, and opti
#### Base-infrastructure bring-up (`stacks/infra.go` + `internal/infra/`, v0.41.0)
**Canonical import root (v0.172.0, R-75).** `${IMPORT_PATH}` = `<system namespace root>/userdata/import`
— ONE drop-zone per box, on the always-available system drive, never per data drive (each drop-zone app
has exactly one ingest bind, so a per-drive `import/` would put a dead lookalike on every other drive,
and `import/*` is `class: excluded` so files stranded there are unbacked too). Injected at BOTH
compose-env builders; **no per-drive fallback** — unresolvable leaves it unset so compose fails loudly.
The system drive is deliberately NOT a registered `StoragePath`, so the FileBrowser bind
(`/srv/beolvasas`, sidebar „Beolvasás"), the skeleton and the system-owned `beolvasas` SMB share each
reach it explicitly. The **userdata skeleton is catalog-derived** (`DeriveUserdataDirs` +
`UserdataSkeletonCarry`, sorted — the sort is load-bearing, see REUSE.md) and can only ever ADD.
The controller stands up its own base stack — **traefik** (reverse proxy), **cloudflared** (external tunnel), **filebrowser** — instead of relying on the bare-metal `scripts/docker-setup.sh` (which a Proxmox-provisioned guest never runs). `internal/infra` renders the compose + config files from `controller.yaml` via embedded `text/template`s (lifted from `docker-setup.sh`); image tags are **pinned constants there** (`TraefikImage`/`CloudflaredImage`/`FileBrowserImage`) and the web FileBrowser sync path delegates to the same renderers, so the pinned versions can never diverge.
`Manager.EnsureBaseStack()` creates the `traefik-public` network, then deploys traefik → cloudflared → filebrowser under `${stacks_dir}/<name>`. It is:
+10
View File
@@ -1328,6 +1328,11 @@ func (a *stackAdapter) GetDockerVolumes(name string) []string {
return backup.ResolveDockerVolumeNames(s.ComposePath)
}
// GetImportRoot delegates to the stack manager's canonical drop-zone root (R-75). Both the backup
// and the export provider need it: ${IMPORT_PATH} binds live on the SYSTEM drive and can NOT be
// resolved from an app's HDD_PATH.
func (a *stackAdapter) GetImportRoot() string { return a.mgr.GetImportRoot() }
func (a *stackAdapter) GetStackHDDPath(name string) string {
s, ok := a.mgr.GetStack(name)
if !ok {
@@ -1543,6 +1548,11 @@ func (a *exportAdapter) GetStackHDDMounts(name string) []string {
return nil
}
// GetImportRoot delegates to the stack manager's canonical drop-zone root (R-75). Both the backup
// and the export provider need it: ${IMPORT_PATH} binds live on the SYSTEM drive and can NOT be
// resolved from an app's HDD_PATH.
func (a *exportAdapter) GetImportRoot() string { return a.mgr.GetImportRoot() }
func (a *exportAdapter) GetStackHDDPath(name string) string {
s, ok := a.mgr.GetStack(name)
if !ok {
@@ -27,6 +27,7 @@ func (p *snapshotsStubProvider) GetStackComposePath(name string) (string, bool)
func (p *snapshotsStubProvider) ListDeployedStacks() []backup.StackSummary { return nil }
func (p *snapshotsStubProvider) GetStackHDDMounts(string) []string { return nil }
func (p *snapshotsStubProvider) GetStackHDDPath(string) string { return p.hdd }
func (p *snapshotsStubProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *snapshotsStubProvider) GetDockerVolumes(string) []string { return nil }
func (p *snapshotsStubProvider) StopStack(string) error { return nil }
func (p *snapshotsStubProvider) StartStack(string) error { return nil }
+5 -1
View File
@@ -19,7 +19,11 @@ type StackDataProvider interface {
GetStackComposePath(name string) (composePath string, ok bool)
ListDeployedStacks() []StackSummary
GetStackHDDMounts(name string) []string
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
// GetImportRoot returns the CANONICAL drop-zone root (R-75): <system namespace root>/userdata/import.
// It is app-INDEPENDENT and lives on the SYSTEM drive, so ${IMPORT_PATH} binds cannot be resolved
// from GetStackHDDPath. Empty when unresolvable — structuralGuard refuses such binds loudly.
GetImportRoot() string
GetDockerVolumes(name string) []string // full Docker volume names (project-prefixed)
StopStack(name string) error
StartStack(name string) error
+35 -13
View File
@@ -59,6 +59,8 @@ const (
reasonEscape = "path escapes the drive root"
reasonBareRoot = "bare drive-root bind would capture the backups tree"
reasonReserved = "path inside the reserved backups zone"
// reasonNoImportRoot: a ${IMPORT_PATH} bind with no resolvable system namespace root (R-75).
reasonNoImportRoot = "canonical import root unresolvable (system_data_path unconfigured)"
)
// ComputeCaptureSet resolves an app's classified binds into the tier-filtered absolute capture set.
@@ -71,16 +73,17 @@ const (
// so the engines' no-block branch stays byte-identical to today (the SQ5 cost-regression guard).
//
// Resolution: RootHDD → path.Join(hddPath, relPath); RootUserdata → path.Join(hddPath, "userdata",
// relPath). Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by
// this tier, refused for structural safety".
func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath string) CaptureSet {
// relPath); RootImport → path.Join(importRoot, relPath) — the SYSTEM drive, never hddPath (R-75).
// Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by this tier,
// refused for structural safety".
func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath, importRoot string) CaptureSet {
if !hasClassification {
return CaptureSet{HasClassification: false}
}
cs := CaptureSet{HasClassification: true}
// Stages 13: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) })
uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(c BindClass) bool { return tierKeeps(tier, c) })
cs.Skipped = skipped
// Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor).
@@ -107,18 +110,18 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
// collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply
// containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a
// mandatory child inside an excluded parent stays independently addressable).
func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
func resolveGuardCollapse(binds []ClassifiedBind, hddPath, importRoot string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
var resolved []CapturePath
for _, b := range binds {
if !keep(b.Class) {
continue
}
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
if reason, bad := structuralGuard(b.Root, b.RelPath, importRoot); bad {
skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason})
continue
}
resolved = append(resolved, CapturePath{
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
Abs: resolveAbs(hddPath, importRoot, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
})
}
byAbs := make(map[string]CapturePath, len(resolved))
@@ -153,12 +156,12 @@ type FabBuckets struct {
// selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet
// (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is
// Abs-sorted (deterministic).
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets {
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath, importRoot string) FabBuckets {
if !hasClassification {
return FabBuckets{HasClassification: false}
}
fb := FabBuckets{HasClassification: true}
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true })
uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(BindClass) bool { return true })
fb.Skipped = skipped
for _, cp := range uniq {
switch cp.Class {
@@ -201,10 +204,19 @@ func tierKeeps(tier CaptureTier, class BindClass) bool {
// there (ParseComposeClassifiableBinds path.Cleans; ValidateBackupSpec vets only SPEC entries), so an
// unlisted writable "${HDD_PATH}/../x" bind reaches here classed mandatory — this guard is
// load-bearing security, not defence-in-depth.
func structuralGuard(root BindRoot, relPath string) (reason string, bad bool) {
func structuralGuard(root BindRoot, relPath, importRoot string) (reason string, bad bool) {
if relPathEscapes(relPath) {
return reasonEscape, true
}
// RootImport (R-75) resolves against the SYSTEM drive, not hddPath. If that root is unresolvable
// (system_data_path unconfigured) the bind cannot be placed at all — refuse it LOUDLY into Skipped
// rather than let resolveAbs join onto "" and produce a relative, wrong-drive path. The other two
// roots cannot hit this: hddPath is checked by their own callers.
if root == RootImport && importRoot == "" {
return reasonNoImportRoot, true
}
// A bare ${IMPORT_PATH} bind is allowed: it resolves to <sysNS>/userdata/import, which nests no
// backups/ tree (backups live at <sysNS>/backups, a sibling of userdata).
if root == RootHDD {
if relPath == "" {
return reasonBareRoot, true // bare ${HDD_PATH} would nest <hddPath>/backups into the capture
@@ -233,11 +245,21 @@ func relPathEscapes(relPath string) bool {
}
// resolveAbs maps a guarded (root, relPath) to its in-container absolute path via slash algebra.
func resolveAbs(hddPath string, root BindRoot, relPath string) string {
if root == RootUserdata {
//
// RootImport is the one root that does NOT resolve against hddPath: the canonical drop-zone lives on
// the SYSTEM drive (R-75), so importRoot is supplied separately by the caller. Resolving it against
// hddPath would silently name a directory on the WRONG DRIVE — a .fab opt-in would then capture (or
// on restore, write) somewhere that merely looks plausible. An empty importRoot is the unresolvable
// case and is refused upstream by structuralGuard, never silently joined.
func resolveAbs(hddPath, importRoot string, root BindRoot, relPath string) string {
switch root {
case RootUserdata:
return path.Join(hddPath, "userdata", relPath)
case RootImport:
return path.Join(importRoot, relPath)
default:
return path.Join(hddPath, relPath)
}
return path.Join(hddPath, relPath)
}
// strongerCapture picks the winner of an equal-Abs collision: mandatory beats optional; on equal
@@ -38,12 +38,12 @@ func TestComputeCaptureSet_PerTierSplit(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, Class: ClassOptional, Origin: OriginExplicit},
}
off := ComputeCaptureSet(binds, true, TierOffsite, drv)
off := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
if got, want := absList(off), []string{hdd("appdata/immich")}; !reflect.DeepEqual(got, want) {
t.Errorf("offsite Paths = %v, want %v (mandatory only — the :ro optional must NOT ship offsite)", got, want)
}
sec := ComputeCaptureSet(binds, true, TierSecondary, drv)
sec := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
want := []string{hdd("appdata/immich"), udat("media/photos")}
if got := absList(sec); !reflect.DeepEqual(got, want) {
t.Errorf("secondary Paths = %v, want %v (sorted)", got, want)
@@ -72,7 +72,7 @@ func TestComputeCaptureSet_LegacyInert(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/sonarr"}, Origin: OriginLegacy},
}
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
cs := ComputeCaptureSet(binds, false, tier, drv)
cs := ComputeCaptureSet(binds, false, tier, drv, "")
if cs.HasClassification {
t.Errorf("%s: HasClassification=true for a legacy app", tier)
}
@@ -94,7 +94,7 @@ func TestComputeCaptureSet_ExcludedInvisible(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "import/paperless"}, Class: ClassExcluded, Origin: OriginExplicit},
}
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
cs := ComputeCaptureSet(binds, true, tier, drv)
cs := ComputeCaptureSet(binds, true, tier, drv, "")
if got, want := absList(cs), []string{hdd("appdata/paperless/media")}; !reflect.DeepEqual(got, want) {
t.Errorf("%s Paths = %v, want %v (excluded filtered)", tier, got, want)
}
@@ -113,7 +113,7 @@ func TestComputeCaptureSet_StructuralGuards(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d4 bare userdata — ALLOWED
}
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
// Paths: ONLY d4's userdata root — no escaped root, no backups/ anywhere.
if got, want := absList(cs), []string{udat("")}; !reflect.DeepEqual(got, want) {
@@ -156,7 +156,7 @@ func TestComputeCaptureSet_LegitDotDotName(t *testing.T) {
binds := []ClassifiedBind{
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/a..b"}, Class: ClassMandatory, Origin: OriginExplicit},
}
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
if got, want := absList(cs), []string{hdd("appdata/a..b")}; !reflect.DeepEqual(got, want) {
t.Errorf("Paths = %v, want %v (a..b is a legit name, not traversal)", got, want)
}
@@ -174,7 +174,7 @@ func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional, Origin: OriginExplicit}, // Abs collides with next
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory, Origin: OriginExplicit}, // same Abs, mandatory
}
cs := ComputeCaptureSet(binds, true, TierSecondary, drv)
cs := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
want := []string{hdd("appdata/paperless"), udat("media")}
if got := absList(cs); !reflect.DeepEqual(got, want) {
@@ -186,7 +186,7 @@ func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) {
}
// determinism: recompute and compare full struct
cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv)
cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
if !reflect.DeepEqual(cs, cs2) {
t.Error("ComputeCaptureSet is non-deterministic across runs")
}
+54 -16
View File
@@ -34,12 +34,23 @@ type BindRoot string
const (
RootUserdata BindRoot = "userdata" // relative to ${USERDATA_PATH}
RootHDD BindRoot = "hdd" // relative to ${HDD_PATH}
// RootImport is relative to ${IMPORT_PATH} — the CANONICAL drop-zone root (R-75). Unlike the
// other two it does NOT resolve against the app's own drive: it lives on the system drive's
// namespace, so every app's ingest folder is in one place. Resolvers therefore need the import
// root passed in separately; they cannot derive it from hddPath.
RootImport BindRoot = "import"
)
// BackupSpec is the .felhom.yml `backup:` block. Paths are forward-slash, relative, path.Clean'd.
type BackupSpec struct {
Userdata []BindSpec `yaml:"userdata,omitempty" json:"userdata,omitempty"`
HDD []BindSpec `yaml:"hdd,omitempty" json:"hdd,omitempty"`
// Import classifies ${IMPORT_PATH}-relative binds (R-75). An app whose ingest bind moved from
// ${USERDATA_PATH}/import/<app> to ${IMPORT_PATH}/<app> MUST move its backup entry here in the
// same change: ValidateBackupSpec rejects an entry matching no compose bind, and the rejection is
// WHOLE-BLOCK, so a stale `userdata: import/<app>` would discard the app's OTHER classifications
// (e.g. an hdd appdata path classed mandatory) and silently degrade it to legacy.
Import []BindSpec `yaml:"import,omitempty" json:"import,omitempty"`
}
// BindSpec is one classified entry in a BackupSpec.
@@ -86,6 +97,42 @@ func validClass(c BindClass) bool {
}
}
// ValidateRelPath is THE path-safety refusal set for every ${VAR}-relative catalog path — the
// `backup:` block and `data_paths:` both run through it, so there is exactly ONE definition of what
// a safe relative path is. Refuses: empty, backslash, absolute, non-path.Clean'd, and any leading
// ".." escape. It deliberately does NOT check "matches a compose bind" — that rule needs the bind
// list and differs per caller (whole-block reject for backup:, per-entry for data_paths:).
func ValidateRelPath(root BindRoot, p string) error {
where := fmt.Sprintf("%s[%q]", root, p)
if p == "" {
return fmt.Errorf("%s: empty path", where)
}
if strings.ContainsRune(p, '\\') {
return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where)
}
if path.IsAbs(p) {
return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root)
}
if p != path.Clean(p) {
return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(p))
}
// path.Clean has run — ".." can only survive as a leading "../" segment.
if p == ".." || strings.HasPrefix(p, "../") {
return fmt.Errorf("%s: path escapes the root (..)", where)
}
return nil
}
// ValidRoot reports whether r is one of the three known bind roots.
func ValidRoot(r BindRoot) bool {
switch r {
case RootUserdata, RootHDD, RootImport:
return true
default:
return false
}
}
// ValidateBackupSpec checks a parsed backup block against the app's actual compose binds and returns
// the FIRST defect (whole-block semantics — the caller rejects the ENTIRE block on any error, so the
// app degrades to legacy rather than partially classifying). A nil spec is vacuously valid (legacy).
@@ -113,21 +160,8 @@ func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error {
if !validClass(e.Class) {
return fmt.Errorf("%s: invalid class %q (want mandatory|optional|excluded)", where, e.Class)
}
if e.Path == "" {
return fmt.Errorf("%s: empty path", where)
}
if strings.ContainsRune(e.Path, '\\') {
return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where)
}
if path.IsAbs(e.Path) {
return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root)
}
if e.Path != path.Clean(e.Path) {
return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(e.Path))
}
// path.Clean has run — ".." can only survive as a leading "../" segment.
if e.Path == ".." || strings.HasPrefix(e.Path, "../") {
return fmt.Errorf("%s: path escapes the root (..)", where)
if err := ValidateRelPath(root, e.Path); err != nil {
return err
}
key := string(root) + "\x00" + e.Path
if seen[key] {
@@ -143,7 +177,10 @@ func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error {
if err := check(RootUserdata, spec.Userdata); err != nil {
return err
}
return check(RootHDD, spec.HDD)
if err := check(RootHDD, spec.HDD); err != nil {
return err
}
return check(RootImport, spec.Import)
}
// ClassifyBinds resolves every compose bind to a class + origin, applying the two-level default. The
@@ -181,6 +218,7 @@ func ClassifyBinds(spec *BackupSpec, binds []ComposeBind) (classified []Classifi
}
add(RootUserdata, spec.Userdata)
add(RootHDD, spec.HDD)
add(RootImport, spec.Import)
for _, b := range binds {
cb := ClassifiedBind{ComposeBind: b}
@@ -21,7 +21,7 @@ func TestComputeFabBuckets_Classified(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/movies"}, Class: ClassExcluded},
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/app"}, Class: ClassMandatory},
}
fb := ComputeFabBuckets(binds, true, drv)
fb := ComputeFabBuckets(binds, true, drv, "")
if !fb.HasClassification {
t.Fatal("HasClassification must be true")
}
@@ -39,7 +39,7 @@ func TestComputeFabBuckets_Classified(t *testing.T) {
// legacy (no block) → empty buckets (the full-root capture stays out of the classified plan).
func TestComputeFabBuckets_LegacyEmpty(t *testing.T) {
binds := []ClassifiedBind{{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy}}
fb := ComputeFabBuckets(binds, false, drv)
fb := ComputeFabBuckets(binds, false, drv, "")
if fb.HasClassification || fb.Mandatory != nil || fb.Optional != nil || fb.Excluded != nil {
t.Errorf("legacy app must yield empty buckets, got %+v", fb)
}
@@ -52,7 +52,7 @@ func TestComputeFabBuckets_GuardsAllClasses(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassExcluded},
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/ok"}, Class: ClassMandatory},
}
fb := ComputeFabBuckets(binds, true, drv)
fb := ComputeFabBuckets(binds, true, drv, "")
for _, b := range [][]CapturePath{fb.Mandatory, fb.Optional, fb.Excluded} {
for _, p := range b {
if p.RelPath == "../evil" {
@@ -71,7 +71,7 @@ func TestComputeFabBuckets_NoCrossBucketContainment(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassExcluded},
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/books"}, Class: ClassMandatory},
}
fb := ComputeFabBuckets(binds, true, drv)
fb := ComputeFabBuckets(binds, true, drv, "")
if got, want := bucketAbs(fb.Mandatory), []string{udat("media/books")}; !reflect.DeepEqual(got, want) {
t.Errorf("mandatory child must survive independently: Mandatory = %v, want %v", got, want)
}
@@ -86,7 +86,7 @@ func TestComputeFabBuckets_EqualAbsMandatoryWins(t *testing.T) {
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional},
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory},
}
fb := ComputeFabBuckets(binds, true, drv)
fb := ComputeFabBuckets(binds, true, drv, "")
if got, want := bucketAbs(fb.Mandatory), []string{udat("media")}; !reflect.DeepEqual(got, want) {
t.Errorf("collapsed path must land in Mandatory, got Mandatory=%v", got)
}
@@ -0,0 +1,134 @@
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)
}
}
+78 -4
View File
@@ -2,7 +2,10 @@ package appbackup
import (
"os"
"path"
"path/filepath"
"sort"
"strings"
)
// Customer-facing userdata layout + the shared-storage ownership convention (v0.66.0).
@@ -28,9 +31,41 @@ func UserdataDir(nsRoot string) string {
return filepath.Join(nsRoot, "userdata")
}
// UserdataSkeleton is the standard subtree created on every storage path (relative to UserdataDir).
// ImportDirName is the single import (drop-zone) subtree name under a userdata root.
const ImportDirName = "import"
// ImportDir returns the CANONICAL drop-zone root under a namespace root (R-75).
//
// Unlike every other userdata dir, this one is drive-INDEPENDENT: the caller resolves it against the
// SYSTEM drive's namespace root, never against the app's own HDD_PATH, so a multi-drive box has
// exactly ONE import tree. That is the whole point. Each drop-zone app has exactly one ingest bind,
// so a per-drive import/ would put a folder that LOOKS like a drop-zone on every drive while only
// one of them does anything — and because import paths are `class: excluded`, files stranded in a
// dead one are never backed up either.
//
// It deliberately stays INSIDE the userdata tree, so the 2775/setgid/GID-1000 convention, the
// FileBrowser mount and the ownership rules all apply to it unchanged.
func ImportDir(nsRoot string) string {
return filepath.Join(UserdataDir(nsRoot), ImportDirName)
}
// UserdataSkeletonCarry is the explicit NON-DERIVED carry-list: every entry the v0.171.0 hardcoded
// skeleton created, retained verbatim and forever.
//
// It exists so the catalog-derived skeleton (R-75) can only ever ADD. That makes the zero-removals
// invariant true BY CONSTRUCTION rather than by review, and it is not hypothetical:
//
// - `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.
//
// ASCII, no spaces (flows through ${} interpolation, shell, and the rsync merge walk).
func UserdataSkeleton() []string {
func UserdataSkeletonCarry() []string {
return []string{
"media", "media/movies", "media/tv", "media/music", "media/audiobooks",
"media/books", "media/comics", "media/photos",
@@ -41,6 +76,41 @@ func UserdataSkeleton() []string {
}
}
// BuildUserdataSkeleton merges the catalog-derived dirs with the carry-list into the final, SORTED
// set. Each entry is expanded to its ancestor chain ("media/podcasts" implies "media"), deduped, and
// sorted.
//
// SORTING IS A HARD REQUIREMENT, not tidiness. The FileBrowser config is regenerated from this set
// and fbNeedsRecreate force-recreates the container on ANY byte difference. Go randomises map
// iteration, and the spike measured the naive map-order derivation producing 20 DISTINCT outputs from
// 20 identical runs (SPIKE P6) — which across SyncFileBrowserMounts' ~14 call sites is a fleet-wide
// FileBrowser restart loop. TestSkeletonDeterminism pins this.
func BuildUserdataSkeleton(derived []string) []string {
set := make(map[string]bool, len(derived)+16)
addChain := func(rel string) {
rel = path.Clean(strings.TrimPrefix(filepath.ToSlash(rel), "/"))
if rel == "" || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") {
return // never let a traversal or an empty entry become a directory to create
}
parts := strings.Split(rel, "/")
for i := range parts {
set[strings.Join(parts[:i+1], "/")] = true
}
}
for _, d := range UserdataSkeletonCarry() {
addChain(d)
}
for _, d := range derived {
addChain(d)
}
out := make([]string, 0, len(set))
for d := range set { // map order is RANDOM — the sort below is what makes this deterministic
out = append(out, d)
}
sort.Strings(out)
return out
}
// EnsureDirOwned creates path (idempotent) and enforces the convention: mode 2775 via an explicit
// Chmod incl. setgid (MkdirAll cannot) + group = gid. Setting an arbitrary group needs CAP_CHOWN —
// the in-guest controller runs as root, so this succeeds in production. Returns the first hard error.
@@ -60,7 +130,11 @@ func EnsureUserdataDir(path string) error { return EnsureDirOwned(path, SharedCo
// EnsureUserdataSkeleton creates the full userdata tree under a namespace root with the convention.
// It creates ALL dirs even if one errors (so a single chown/chmod hiccup doesn't truncate the tree),
// returning the first error seen for the caller to log.
func EnsureUserdataSkeleton(nsRoot string) error {
//
// dirs is the merged, sorted set from BuildUserdataSkeleton. This function only ever CREATES: there
// is no removal path here or anywhere in R-75, so a directory the current catalog no longer implies
// simply stays where it is (Scenario D).
func EnsureUserdataSkeleton(nsRoot string, dirs []string) error {
base := UserdataDir(nsRoot)
var firstErr error
rec := func(e error) {
@@ -69,7 +143,7 @@ func EnsureUserdataSkeleton(nsRoot string) error {
}
}
rec(EnsureUserdataDir(base))
for _, sub := range UserdataSkeleton() {
for _, sub := range dirs {
rec(EnsureUserdataDir(filepath.Join(base, sub)))
}
return firstErr
@@ -13,10 +13,12 @@ func TestSharedContentGID(t *testing.T) {
}
}
// TestUserdataSkeleton_List asserts the locked skeleton subdir set.
// 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.
func TestUserdataSkeleton_List(t *testing.T) {
got := map[string]bool{}
for _, s := range UserdataSkeleton() {
for _, s := range UserdataSkeletonCarry() {
got[s] = true
}
for _, want := range []string{
@@ -41,9 +43,10 @@ func TestUserdataDir(t *testing.T) {
// is ignored — dirs + setgid still land). Runs cross-platform.
func TestEnsureUserdataSkeleton_Structure(t *testing.T) {
ns := t.TempDir()
_ = EnsureUserdataSkeleton(ns) // ignore chown error on a non-root CI host
dirs := BuildUserdataSkeleton(nil) // no catalog derived → the carry-list floor
_ = EnsureUserdataSkeleton(ns, dirs) // ignore chown error on a non-root CI host
base := UserdataDir(ns)
for _, sub := range append([]string{""}, UserdataSkeleton()...) {
for _, sub := range append([]string{""}, dirs...) {
p := filepath.Join(base, sub)
if fi, err := os.Stat(p); err != nil || !fi.IsDir() {
t.Errorf("skeleton dir missing: %s (%v)", p, err)
@@ -23,6 +23,7 @@ type hddProvider struct {
func (p *hddProvider) GetStackNeedsHDD(string) bool { return true }
func (p *hddProvider) GetStackHDDMounts(string) []string { return p.mounts }
func (p *hddProvider) GetStackHDDPath(string) string { return p.hddPath }
func (p *hddProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *hddProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return p.binds, p.hasBinds
}
+2 -2
View File
@@ -35,7 +35,7 @@ func (e *Exporter) computeFabPlan(req ExportRequest, mounts []string) fabPlan {
return fabPlan{} // legacy: byte-identical v0.130.0 capture
}
hddPath := filepath.Clean(e.provider.GetStackHDDPath(req.StackName))
fb := appbackup.ComputeFabBuckets(binds, has, hddPath)
fb := appbackup.ComputeFabBuckets(binds, has, hddPath, e.provider.GetImportRoot())
deselect := sliceSet(req.DeselectOptional)
optIn := sliceSet(req.OptInExcluded)
@@ -117,7 +117,7 @@ func (e *Exporter) fabEstimateSplit(stackName string, est *ExportEstimate, volum
return
}
hddPath := filepath.Clean(e.provider.GetStackHDDPath(stackName))
fb := appbackup.ComputeFabBuckets(binds, has, hddPath)
fb := appbackup.ComputeFabBuckets(binds, has, hddPath, e.provider.GetImportRoot())
est.HasClassification = true
toItems := func(cps []appbackup.CapturePath) ([]FabItem, int64) {
@@ -23,6 +23,7 @@ type fabProv struct {
}
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return p.binds, p.has
@@ -17,6 +17,9 @@ type ExportStackProvider interface {
GetStackHDDMounts(name string) []string
// GetStackHDDPath returns the raw HDD_PATH env var from app.yaml.
GetStackHDDPath(name string) string
// GetImportRoot returns the CANONICAL drop-zone root (R-75), on the SYSTEM drive. ${IMPORT_PATH}
// binds resolve against THIS, never against GetStackHDDPath. Empty when unresolvable.
GetImportRoot() string
// GetStackClassifiedBinds returns the app's backup-classified compose binds + whether it carries a
// (valid) backup block (Task 2). Drives the `.fab` class-scoped export plan (Task 4); a legacy app
// (false) exports the v0.130.0 full-root capture unchanged.
@@ -37,6 +37,7 @@ func (p *rtProvider) GetStackComposePath(string) (string, bool) {
}
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
func (p *rtProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return nil, false
}
@@ -29,6 +29,7 @@ func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil }
func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil }
func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] }
func (p *offbox3aProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil }
func (p *offbox3aProvider) StopStack(string) error { return nil }
func (p *offbox3aProvider) StartStack(string) error { return nil }
+1 -1
View File
@@ -46,7 +46,7 @@ func (m *Manager) offboxCaptureSet(stack string) (extra []string, warns []string
return nil, []string{fmt.Sprintf("Figyelmeztetés: a(z) %s nincs telepítve — csak a mentési egység került a távoli mentésbe.", stack)}
}
nsRoot := m.namespaceRoot(hdd)
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot)
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot, m.stackProvider.GetImportRoot())
var gaps []string
// Structurally-refused MANDATORY paths (traversal / bare drive-root / reserved backups/ zone) are
@@ -37,6 +37,7 @@ func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil }
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
func (f *fakeRecoveryProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
func (f *fakeRecoveryProvider) StopStack(string) error {
f.stopped = true
+1 -1
View File
@@ -43,7 +43,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
// Classified path.
if m.stackProvider != nil {
if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has {
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, nsRoot)
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, nsRoot, m.stackProvider.GetImportRoot())
var gaps []string
for _, sk := range cs.Skipped {
if sk.Class == appbackup.ClassMandatory {
@@ -27,6 +27,7 @@ func (f *t2rFakeProvider) GetStackComposePath(string) (string, bool) { return ""
func (f *t2rFakeProvider) ListDeployedStacks() []StackSummary { return nil }
func (f *t2rFakeProvider) GetStackHDDMounts(string) []string { return f.mounts }
func (f *t2rFakeProvider) GetStackHDDPath(string) string { return f.hdd }
func (f *t2rFakeProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (f *t2rFakeProvider) GetDockerVolumes(string) []string { return nil }
func (f *t2rFakeProvider) StopStack(name string) error {
f.stopped = append(f.stopped, name)
@@ -25,6 +25,7 @@ func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", f
func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil }
func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] }
func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd }
func (p *t2v2Provider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil }
func (p *t2v2Provider) StopStack(string) error { return nil }
func (p *t2v2Provider) StartStack(string) error { return nil }
@@ -25,6 +25,7 @@ func (f *volDumpFakeProvider) GetStackComposePath(string) (string, bool) { retur
func (f *volDumpFakeProvider) ListDeployedStacks() []StackSummary { return f.stacks }
func (f *volDumpFakeProvider) GetStackHDDMounts(string) []string { return nil }
func (f *volDumpFakeProvider) GetStackHDDPath(name string) string { return f.hdd[name] }
func (f *volDumpFakeProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (f *volDumpFakeProvider) GetDockerVolumes(name string) []string { return f.volumes[name] }
func (f *volDumpFakeProvider) StopStack(name string) error {
f.stopped = append(f.stopped, name)
+20 -3
View File
@@ -25,6 +25,15 @@ const (
TraefikImage = "traefik:v3.6.7"
CloudflaredImage = "cloudflare/cloudflared:2026.6.0"
FileBrowserImage = "gtstef/filebrowser:1.3.3-stable"
// FileBrowserImportMount is the in-container mount point NAME for the canonical drop-zone
// (R-75): the bind lands at /srv/<this>. ASCII and space-free on purpose — it appears in a
// container path, in the generated compose, and (percent-encoded) in the deep-link URL.
FileBrowserImportMount = "beolvasas"
// FileBrowserImportLabel is the Hungarian SIDEBAR name of that source. It is the display name and
// it IS the URL identity: FileBrowser Quantum keys sources by name (SPIKE P1) and the deep-link
// template is /files/{encodeURIComponent(name)}/... (SPIKE P2). Accents round-trip correctly —
// the spike verified an accented, spaced and ampersand'd source name end to end.
FileBrowserImportLabel = "Beolvasás"
// SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by
// controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest.
SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.1.0"
@@ -256,12 +265,20 @@ http:
// RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
// verbatim from internal/web/handlers.go.
func RenderFileBrowserConfig(paths []settings.StoragePath) string {
func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
var sources string
if len(paths) == 0 {
// The canonical drop-zone (R-75) is FIRST and is NOT a registered storage path — it is a separate
// bind of <system namespace>/userdata/import. Separate rather than nested inside a drive source:
// the spike proved a nested source works but gets indexed TWICE (once as its own root, once as a
// child of the parent drive), which buys nothing over a separate bind.
if importSource {
sources += fmt.Sprintf(" - path: %q\n name: %q\n config:\n defaultEnabled: true\n",
"/srv/"+FileBrowserImportMount, FileBrowserImportLabel)
}
if len(paths) == 0 && !importSource {
sources = ` - path: "/srv"
`
} else {
} else if len(paths) > 0 {
for _, sp := range paths {
mountName := filepath.Base(sp.Path)
label := sp.Label
+3 -3
View File
@@ -46,7 +46,7 @@ func allRendered(t *testing.T) []string {
}
out = append(out, RenderFileBrowserCompose("example.com", nil))
out = append(out, RenderFileBrowserCompose("example.com", []string{" - /mnt/hdd_1:/srv/hdd_1"}))
out = append(out, RenderFileBrowserConfig(nil))
out = append(out, RenderFileBrowserConfig(nil, false))
out = append(out, RenderServersTransports())
return out
}
@@ -224,13 +224,13 @@ func TestFileBrowserRender(t *testing.T) {
}
// Default config (no storage paths) → a single /srv source.
def := RenderFileBrowserConfig(nil)
def := RenderFileBrowserConfig(nil, false)
if !strings.Contains(def, `- path: "/srv"`) {
t.Errorf("empty config must default to a /srv source: %q", def)
}
// With paths → a named per-drive source.
withPaths := RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1", Label: "Media"}})
withPaths := RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1", Label: "Media"}}, false)
if !strings.Contains(withPaths, `- path: "/srv/hdd_1"`) || !strings.Contains(withPaths, `name: "Media"`) {
t.Errorf("storage path not wired into filebrowser config: %q", withPaths)
}
+21
View File
@@ -30,8 +30,19 @@ type SMBShare struct {
ReadOnly bool `json:"read_only,omitempty"` // smb.conf `read only = yes` + a :ro compose bind
Offsite bool `json:"offsite"` // [R4] true (default) → backup class mandatory; false → optional (tier-2 only)
CreatedAt string `json:"created_at"` // RFC3339
// System marks a controller-OWNED share the customer may not delete (R-75). Today that is the
// canonical drop-zone („beolvasas"), which is auto-created whenever sharing is enabled and whose
// absence would silently break the documented „drop a file in Beolvasás over the network" flow.
// Its Path is a controller-generated constant derived from config — never customer input — which
// is why it does NOT go through sharingResolvePath (that guard exists to validate the paths a
// CUSTOMER picks, a different trust class).
System bool `json:"system,omitempty"`
}
// SystemImportShareName is the fixed name of the canonical drop-zone share (R-75). ASCII and
// nbNameRe-safe on purpose: it is a NetBIOS share name and appears in \\SERVER\<name>.
const SystemImportShareName = "beolvasas"
// nbNameRe matches a NetBIOS-safe name: 115 chars, letters/digits/hyphen/underscore, not starting
// or ending with a hyphen. Deliberately stricter than SMB share-name rules (slice 1 keeps the flat
// name and the share name in the same safe space; slice 2 may relax share names).
@@ -172,9 +183,19 @@ func (s *Settings) AddSMBShare(share SMBShare) error {
}
// RemoveSMBShare deletes a share by name (case-insensitive). Config-only: never touches the folder.
//
// A System share is REFUSED here, at the store layer, so every caller inherits the rule — the web
// handler has its own check too, and that duplication is deliberate: a handler test that POSTs
// directly proves nothing about UI reachability, and a hidden button proves nothing about
// enforcement (the v0.70.1 lesson). They are separate concerns.
func (s *Settings) RemoveSMBShare(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, ex := range s.SMBShares {
if strings.EqualFold(ex.Name, name) && ex.System {
return fmt.Errorf("a(z) „%s” megosztás a rendszer része, nem törölhető", ex.Name)
}
}
var kept []SMBShare
found := false
for _, ex := range s.SMBShares {
@@ -47,12 +47,12 @@ backup:
wantPhotos := path.Join(hddPath, "userdata", "media/photos")
// Secondary: mandatory + optional, sorted by Abs.
sec := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierSecondary, hddPath)
sec := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierSecondary, hddPath, "")
if got, want := absSet(sec), []string{wantWiretest, wantPhotos}; !reflect.DeepEqual(got, want) {
t.Errorf("secondary Paths = %v, want %v", got, want)
}
// Offsite: mandatory only — the explicit-optional :ro library must NOT appear.
off := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierOffsite, hddPath)
off := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierOffsite, hddPath, "")
if got, want := absSet(off), []string{wantWiretest}; !reflect.DeepEqual(got, want) {
t.Errorf("offsite Paths = %v, want %v (optional :ro must not ship offsite)", got, want)
}
@@ -19,6 +19,11 @@ var composeVarRoots = []struct {
}{
{"${USERDATA_PATH}", appbackup.RootUserdata},
{"${HDD_PATH}", appbackup.RootHDD},
// ${IMPORT_PATH} (R-75) is the canonical drop-zone root on the SYSTEM drive. It must be listed
// BEFORE any prefix of it could match — it shares no prefix with the other two, so order is not
// load-bearing here, but the classifier works in ${VAR}-relative space and never resolves envs,
// so this entry is what makes an ingest bind classifiable at all.
{"${IMPORT_PATH}", appbackup.RootImport},
}
// ParseComposeClassifiableBinds extracts the ${HDD_PATH}/${USERDATA_PATH}-relative host binds from a
+99
View File
@@ -0,0 +1,99 @@
package stacks
import (
"fmt"
"log"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// `data_paths:` — the customer-facing folder ANNOTATION (R-75).
//
// It annotates paths that ALREADY EXIST as compose binds; it can never declare a new one. That is
// what keeps it cheap: no new filesystem-write primitive is introduced from catalog data, because
// every path it names is a path the deploy belt already creates.
// DataPathRole is the presentation role of an annotated folder.
type DataPathRole string
const (
RoleImport DataPathRole = "import" // a drop-zone: the app consumes and usually DELETES from it
RoleLibrary DataPathRole = "library" // where the customer's files live
RoleExport DataPathRole = "export" // the app writes results here for the customer to collect
)
// DataPath is one entry of the `data_paths:` block.
type DataPath struct {
Path string `yaml:"path" json:"path"` // relative to Root, must match a compose bind
Root appbackup.BindRoot `yaml:"root" json:"root"` // import | userdata | hdd
Role DataPathRole `yaml:"role" json:"role"` // import | library | export
Label string `yaml:"label" json:"label"` // Hungarian, customer-facing
}
// validRole reports whether r is a role this controller knows how to render.
func validRole(r DataPathRole) bool {
switch r {
case RoleImport, RoleLibrary, RoleExport:
return true
default:
return false
}
}
// ValidateDataPaths applies the Fork-3 ruling, and the ASYMMETRY is deliberate — a decision, not an
// inconsistency:
//
// - A MALFORMED PATH is a whole-block reject (returns an error; the caller drops the entire block
// and the app renders no folder card). Paths govern DATA HANDLING: a path that is absolute,
// escapes the root, or matches no compose bind would point the customer at a directory that is
// not the app's, so nothing from that block can be trusted. This follows the `backup:` precedent
// and reuses its refusal set (appbackup.ValidateRelPath) rather than defining a second one.
//
// - An UNKNOWN ROLE fails OPEN (that one entry is dropped with a WARN; everything else renders).
// Roles govern PRESENTATION only, and this follows the Lifecycle precedent (metadata.go): a typo
// in a catalog push must never brick a template. The cost of an unknown role is one missing UI
// affordance, never a mishandled file.
//
// Returns the surviving entries. An error means the caller must discard the WHOLE block.
func ValidateDataPaths(entries []DataPath, binds []appbackup.ComposeBind, appName string, logger *log.Logger) ([]DataPath, error) {
if len(entries) == 0 {
return nil, nil
}
present := make(map[appbackup.BindRoot]map[string]bool)
for _, b := range binds {
if present[b.Root] == nil {
present[b.Root] = make(map[string]bool)
}
present[b.Root][b.RelPath] = true
}
seen := make(map[string]bool)
out := make([]DataPath, 0, len(entries))
for _, e := range entries {
// --- path rules: whole-block reject ---
if !appbackup.ValidRoot(e.Root) {
return nil, fmt.Errorf("data_paths[%q]: unknown root %q (want import|userdata|hdd)", e.Path, e.Root)
}
if err := appbackup.ValidateRelPath(e.Root, e.Path); err != nil {
return nil, fmt.Errorf("data_paths: %w", err)
}
key := string(e.Root) + "\x00" + e.Path
if seen[key] {
return nil, fmt.Errorf("data_paths[%s/%s]: duplicate entry", e.Root, e.Path)
}
seen[key] = true
if !present[e.Root][e.Path] {
return nil, fmt.Errorf("data_paths[%s/%s]: matches no compose bind — data_paths ANNOTATES existing binds, it cannot declare new ones", e.Root, e.Path)
}
// --- role: fail OPEN ---
if !validRole(e.Role) {
if logger != nil {
logger.Printf("[WARN] [stacks] %s: unknown data_paths role %q for %s/%s — entry not surfaced (known: %s, %s, %s)",
appName, e.Role, e.Root, e.Path, RoleImport, RoleLibrary, RoleExport)
}
continue
}
out = append(out, e)
}
return out, nil
}
@@ -0,0 +1,176 @@
package stacks
import (
"io"
"log"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
func dpBinds() []appbackup.ComposeBind {
return []appbackup.ComposeBind{
{Root: appbackup.RootImport, RelPath: "paperless"},
{Root: appbackup.RootUserdata, RelPath: "media/books"},
{Root: appbackup.RootHDD, RelPath: "appdata/paperless/media"},
}
}
func quietLogger() *log.Logger { return log.New(io.Discard, "", 0) }
// Fork-3 ruling, half 1: a MALFORMED PATH is a WHOLE-BLOCK reject. Paths govern data handling.
func TestDataPaths_MalformedPathWholeBlockRejects(t *testing.T) {
for _, tc := range []struct {
name string
entry DataPath
}{
{"absolute", DataPath{Path: "/etc/passwd", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
{"traversal", DataPath{Path: "../../etc", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
{"backslash", DataPath{Path: `a\b`, Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
{"non-clean", DataPath{Path: "a//b", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
{"empty", DataPath{Path: "", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
{"matches no compose bind", DataPath{Path: "nincs-ilyen", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
{"unknown root", DataPath{Path: "paperless", Root: appbackup.BindRoot("bogus"), Role: RoleImport, Label: "x"}},
} {
// A VALID entry sits alongside it — the reject must take the whole block, not just the bad one.
entries := []DataPath{
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "ok"},
tc.entry,
}
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
if err == nil {
t.Errorf("%s: must be rejected, got kept=%v", tc.name, kept)
}
if kept != nil {
t.Errorf("%s: a whole-block reject must keep NOTHING, got %v", tc.name, kept)
}
}
}
// Fork-3 ruling, half 2: an UNKNOWN ROLE fails OPEN. Roles govern presentation only, so a typo in a
// catalog push must never brick the template (the Lifecycle precedent).
func TestDataPaths_UnknownRoleFailsOpen(t *testing.T) {
entries := []DataPath{
{Path: "paperless", Root: appbackup.RootImport, Role: DataPathRole("beolvasas"), Label: "typo role"},
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "E-könyvtár"},
}
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
if err != nil {
t.Fatalf("an unknown ROLE must not reject the block: %v", err)
}
if len(kept) != 1 || kept[0].Path != "media/books" {
t.Errorf("expected only the valid entry to survive, got %v", kept)
}
}
// The happy path across all three roots and roles.
func TestDataPaths_AllRootsAndRoles(t *testing.T) {
entries := []DataPath{
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "Beolvasandó dokumentumok"},
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "E-könyvtár"},
{Path: "appdata/paperless/media", Root: appbackup.RootHDD, Role: RoleExport, Label: "Export"},
}
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
if err != nil {
t.Fatalf("valid block rejected: %v", err)
}
if len(kept) != 3 {
t.Errorf("expected all 3 entries kept, got %d: %v", len(kept), kept)
}
}
func TestDataPaths_DuplicateRejects(t *testing.T) {
entries := []DataPath{
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "a"},
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "b"},
}
if _, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger()); err == nil {
t.Error("a duplicate (root, path) must be rejected")
}
}
// data_paths ANNOTATES; it must never be able to declare a path that is not already a compose bind.
// This is the property that keeps the design from introducing a new filesystem-write primitive.
func TestDataPaths_CannotDeclareNewPaths(t *testing.T) {
entries := []DataPath{
{Path: "uj-mappa", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "new"},
}
_, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
if err == nil {
t.Fatal("data_paths must not be able to name a path with no compose bind behind it")
}
}
// End-to-end through LoadMetadata: the catalog shape parses, validates, and survives.
func TestLoadMetadata_DataPathsRoundTrip(t *testing.T) {
compose := `services:
calibre-web:
image: x
volumes:
- ${IMPORT_PATH}/calibre:/cwa-book-ingest
- ${USERDATA_PATH}/media/books:/calibre-library
`
meta := `display_name: Calibre-Web
slug: calibre-web
backup:
userdata:
- path: media/books
class: mandatory
import:
- path: calibre
class: excluded
data_paths:
- path: calibre
root: import
role: import
label: "Beolvasandó e-könyvek"
- path: media/books
root: userdata
role: library
label: "E-könyvtár"
`
dir := writeApp(t, compose, meta)
m := LoadMetadata(dir)
if len(m.DataPaths) != 2 {
t.Fatalf("expected 2 data_paths, got %d: %+v", len(m.DataPaths), m.DataPaths)
}
if m.Backup == nil {
t.Error("the backup block must survive alongside data_paths")
}
if m.DataPaths[0].Label != "Beolvasandó e-könyvek" || m.DataPaths[0].Role != RoleImport {
t.Errorf("first entry wrong: %+v", m.DataPaths[0])
}
_ = filepath.Clean
}
// A bad data_paths block must NOT take the backup block down with it — they are validated
// independently at the same choke point.
func TestLoadMetadata_BadDataPathsKeepsBackupBlock(t *testing.T) {
compose := `services:
app:
image: x
volumes:
- ${USERDATA_PATH}/media/books:/lib
`
meta := `display_name: X
slug: x
backup:
userdata:
- path: media/books
class: mandatory
data_paths:
- path: /absolute/nope
root: userdata
role: library
label: "bad"
`
dir := writeApp(t, compose, meta)
m := LoadMetadata(dir)
if m.DataPaths != nil {
t.Errorf("malformed data_paths must be dropped, got %+v", m.DataPaths)
}
if m.Backup == nil {
t.Error("a bad data_paths block must not reject the backup block — they are independent")
}
}
+21 -38
View File
@@ -513,49 +513,32 @@ func buildPathInfo(path string) HDDPath {
// ParseComposeUserdataMounts reads a docker-compose.yml and extracts the host bind-source paths that
// reference ${USERDATA_PATH} (resolved to userdataPath) — the dirs the deploy belt must pre-create
// with the userdata convention. Same scanner shape as ParseComposeHDDMounts.
// with the userdata convention.
//
// R-75: this is now a thin RESOLVER over ParseComposeClassifiableBinds, which is the ONE authoritative
// compose-bind scanner. The two used to be byte-for-byte duplicate scanners (SPIKE §3) differing only
// in what they threw away, so a fix to one silently skipped the other; the classifier won because it
// is the richer of the two (it keeps the root and the :ro flag, both of which this function discards
// but the classification and derivation paths need).
//
// ONE deliberate behaviour drop, recorded rather than hidden: the old textual
// strings.ReplaceAll("${USERDATA_PATH}", …) + containment check also accepted a bind written as a
// LITERAL absolute path that happened to fall under userdataPath. The classifier matches the ${VAR}
// reference only. No catalog template has ever used the literal form (verified across all 53 in
// SPIKE §2 — every host token is a ${VAR}, a named volume, or the docker socket), and such a compose
// would be pinned to one machine's drive layout, so the capability was dead.
func ParseComposeUserdataMounts(composePath, userdataPath string) []string {
if userdataPath == "" {
return nil
}
data, err := os.ReadFile(composePath)
if err != nil {
return nil
var out []string
for _, b := range ParseComposeClassifiableBinds(composePath) {
if b.Root != appbackup.RootUserdata {
continue
}
out = append(out, filepath.Join(userdataPath, filepath.FromSlash(b.RelPath)))
}
var mounts []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(data)))
inVolumes := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "volumes:") {
inVolumes = true
continue
}
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
inVolumes = false
}
if !inVolumes || !strings.HasPrefix(line, "- ") {
continue
}
mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
parts := strings.SplitN(mountStr, ":", 3)
if len(parts) < 2 {
continue
}
hostPath := strings.ReplaceAll(parts[0], "${USERDATA_PATH}", userdataPath)
cleanPath := filepath.Clean(hostPath)
cleanUD := filepath.Clean(userdataPath)
// must be userdataPath itself or a subpath (clean before check — traversal-safe)
if cleanPath != cleanUD && !strings.HasPrefix(cleanPath, cleanUD+string(filepath.Separator)) {
continue
}
if !seen[cleanPath] {
seen[cleanPath] = true
mounts = append(mounts, cleanPath)
}
}
return mounts
return out
}
// ExportDataMounts returns the host directories a .fab export must capture for an app: the
+16 -5
View File
@@ -558,18 +558,29 @@ func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
}
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
cmdEnv = withUserdataPath(cmdEnv, env["HDD_PATH"])
cmdEnv = withPathVars(cmdEnv, env["HDD_PATH"], m.GetImportRoot())
return m.composeExecCustomEnv(dir, cmdEnv, args...)
}
// withUserdataPath appends USERDATA_PATH=<hdd>/userdata to a "K=V" env slice when hdd is non-empty.
// withPathVars appends the two derived path variables to a "K=V" env slice:
//
// USERDATA_PATH=<hdd>/userdata — per-app, on the app's OWN drive (when hdd is non-empty)
// IMPORT_PATH=<importRoot> — CANONICAL, on the system drive (when importRoot is non-empty)
//
// Shared by BOTH compose-env builders (stackEnv for start/redeploy, composeExecWithEnv for the initial
// deploy) so ${USERDATA_PATH} always resolves — the initial-deploy path missing it bound a bogus
// root-owned dir at the container root.
func withUserdataPath(cmdEnv []string, hdd string) []string {
// deploy) so the variables always resolve — the initial-deploy path missing USERDATA_PATH bound a
// bogus root-owned dir at the container root, and IMPORT_PATH has the identical failure mode.
//
// An unresolvable importRoot is left UNSET on purpose (the caller logs it): compose then fails loudly
// on an unresolved ${IMPORT_PATH} rather than silently falling back to a per-drive path, which would
// recreate the dead-drop-zone shape R-75 exists to remove.
func withPathVars(cmdEnv []string, hdd, importRoot string) []string {
if hdd != "" {
cmdEnv = append(cmdEnv, "USERDATA_PATH="+appbackup.UserdataDir(hdd))
}
if importRoot != "" {
cmdEnv = append(cmdEnv, "IMPORT_PATH="+importRoot)
}
return cmdEnv
}
@@ -0,0 +1,325 @@
package stacks
import (
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// R-75 Scenario B — THE regression gate for moving an ingest bind to ${IMPORT_PATH}.
//
// ValidateBackupSpec rejects an entry whose (Root, RelPath) matches NO compose bind, and the
// rejection is WHOLE-BLOCK: LoadMetadata sets Backup back to nil and the app degrades to LEGACY.
// So moving paperless's consume bind to ${IMPORT_PATH} while its backup block still said
// `userdata: import/paperless` would discard the ENTIRE block — taking
// `hdd: appdata/paperless/media class: mandatory` with it. The customer's document originals would
// silently fall back to legacy handling. That is the collateral this test exists to catch.
//
// The fixtures below are copied VERBATIM from app-catalog-felhom.eu templates/paperless-ngx/ (the
// bind lines and the whole backup block). If the catalog changes them, this test must be updated in
// the same train — that coupling is the point, and Part-8 leg 1 re-checks it against the live
// catalog on a real box.
const importPaperlessCompose = `services:
paperless-webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4
volumes:
- paperless_data:/usr/src/paperless/data
- ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media
- ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export
- ${IMPORT_PATH}/paperless:/usr/src/paperless/consume
volumes:
paperless_data:
`
const importPaperlessMeta = `display_name: Paperless-ngx
slug: paperless-ngx
category: documents
backup:
hdd:
- path: appdata/paperless/media
class: mandatory
- path: appdata/paperless/export
class: excluded
import:
- path: paperless
class: excluded
`
// writeApp lays out a stack dir with a compose file and a .felhom.yml.
func writeApp(t *testing.T, compose, meta string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
func TestScenarioB_ImportMoveKeepsClassification(t *testing.T) {
dir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
m := LoadMetadata(dir)
if m.Backup == nil {
t.Fatal("backup block was WHOLE-BLOCK REJECTED (degraded to legacy) — the import move lost the app's classification")
}
binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
classified, has := appbackup.ClassifyBinds(m.Backup, binds)
if !has {
t.Fatal("hasClassification=false — the app degraded to legacy")
}
type key struct {
root appbackup.BindRoot
rel string
}
got := map[key]appbackup.ClassifiedBind{}
for _, c := range classified {
got[key{c.Root, c.RelPath}] = c
}
want := []struct {
root appbackup.BindRoot
rel string
class appbackup.BindClass
origin appbackup.ClassOrigin
why string
}{
{appbackup.RootImport, "paperless", appbackup.ClassExcluded, appbackup.OriginExplicit,
"the moved ingest bind must classify under the import root"},
{appbackup.RootHDD, "appdata/paperless/media", appbackup.ClassMandatory, appbackup.OriginExplicit,
"THE COLLATERAL: a whole-block reject would silently drop this to legacy"},
{appbackup.RootHDD, "appdata/paperless/export", appbackup.ClassExcluded, appbackup.OriginExplicit,
"second hdd entry must survive too"},
}
for _, w := range want {
c, ok := got[key{w.root, w.rel}]
if !ok {
t.Errorf("%s/%s: bind missing entirely — %s", w.root, w.rel, w.why)
continue
}
if c.Class != w.class {
t.Errorf("%s/%s: class = %q, want %q — %s", w.root, w.rel, c.Class, w.class, w.why)
}
if c.Origin != w.origin {
t.Errorf("%s/%s: origin = %q, want %q — %s", w.root, w.rel, c.Origin, w.origin, w.why)
}
}
// The explicit WRONG outcome from the scenario: nothing may report legacy.
for _, c := range classified {
if c.Origin == appbackup.OriginLegacy {
t.Errorf("%s/%s reported origin=legacy — the block was rejected", c.Root, c.RelPath)
}
}
}
// The companion in the other direction: a STALE `userdata: import/paperless` entry against the new
// ${IMPORT_PATH} compose must be REFUSED, and refused WHOLE-BLOCK. This is the state the catalog
// would be in if Part 2 moved the compose bind but forgot the backup block — it proves the trap is
// real rather than hypothetical, and that the guard catches it rather than silently mis-classifying.
func TestScenarioB_StaleUserdataEntryIsWholeBlockRejected(t *testing.T) {
staleMeta := `display_name: Paperless-ngx
slug: paperless-ngx
backup:
hdd:
- path: appdata/paperless/media
class: mandatory
userdata:
- path: import/paperless
class: excluded
`
dir := writeApp(t, importPaperlessCompose, staleMeta)
if m := LoadMetadata(dir); m.Backup != nil {
t.Error("a stale userdata entry matching no compose bind must be whole-block rejected")
}
// And prove the consequence the scenario names, so the reject is not mistaken for harmless:
// with the block gone, the mandatory hdd path loses its class and goes legacy.
m := LoadMetadata(dir)
binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
classified, has := appbackup.ClassifyBinds(m.Backup, binds)
if has {
t.Fatal("precondition: block should be nil here")
}
for _, c := range classified {
if c.Root == appbackup.RootHDD && c.RelPath == "appdata/paperless/media" {
if c.Origin != appbackup.OriginLegacy || c.Class != "" {
t.Errorf("expected the collateral to be legacy/unclassed, got class=%q origin=%q", c.Class, c.Origin)
}
}
}
}
// calibre-web carries BOTH an import bind and a userdata library bind — the multi-root case.
func TestScenarioB_CalibreBothRoots(t *testing.T) {
compose := `services:
calibre-web:
image: crocodilestick/calibre-web-automated:v4.0.6
volumes:
- calibre_web_config:/config
- ${IMPORT_PATH}/calibre:/cwa-book-ingest
- ${USERDATA_PATH}/media/books:/calibre-library
volumes:
calibre_web_config:
`
meta := `display_name: Calibre-Web
slug: calibre-web
backup:
userdata:
- path: media/books
class: mandatory
import:
- path: calibre
class: excluded
`
dir := writeApp(t, compose, meta)
m := LoadMetadata(dir)
if m.Backup == nil {
t.Fatal("calibre-web backup block was whole-block rejected")
}
classified, has := appbackup.ClassifyBinds(m.Backup, ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml")))
if !has {
t.Fatal("calibre-web degraded to legacy")
}
seen := map[string]appbackup.ClassifiedBind{}
for _, c := range classified {
seen[string(c.Root)+"/"+c.RelPath] = c
}
if c := seen["import/calibre"]; c.Class != appbackup.ClassExcluded || c.Origin != appbackup.OriginExplicit {
t.Errorf("import/calibre: class=%q origin=%q, want excluded/explicit", c.Class, c.Origin)
}
if c := seen["userdata/media/books"]; c.Class != appbackup.ClassMandatory || c.Origin != appbackup.OriginExplicit {
t.Errorf("userdata/media/books: class=%q origin=%q, want mandatory/explicit", c.Class, c.Origin)
}
}
// The import root resolves against the SYSTEM drive, never the app's own drive. Two apps on two
// different drives must resolve their ingest folders to the SAME parent — the canonical property.
func TestImportBindResolvesToSystemDrive(t *testing.T) {
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
binds := []appbackup.ClassifiedBind{
{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootImport, RelPath: "paperless"},
Class: appbackup.ClassExcluded, Origin: appbackup.OriginExplicit},
}
for _, hdd := range []string{"/mnt/felhom-drives/hdd_1", "/mnt/felhom-drives/nvme-1tb"} {
fb := appbackup.ComputeFabBuckets(binds, true, hdd, importRoot)
if len(fb.Excluded) != 1 {
t.Fatalf("hdd=%s: expected 1 excluded bucket entry, got %d (skipped=%v)", hdd, len(fb.Excluded), fb.Skipped)
}
if got, want := fb.Excluded[0].Abs, importRoot+"/paperless"; got != want {
t.Errorf("hdd=%s: import bind resolved to %q, want %q — it must NOT follow the app's drive", hdd, got, want)
}
}
// Unresolvable import root ⇒ refused LOUDLY into Skipped, never joined onto "".
fb := appbackup.ComputeFabBuckets(binds, true, "/mnt/felhom-drives/hdd_1", "")
if len(fb.Excluded) != 0 {
t.Errorf("an unresolvable import root must not resolve: %+v", fb.Excluded)
}
if len(fb.Skipped) != 1 {
t.Fatalf("expected the bind in Skipped, got %+v", fb.Skipped)
}
}
// R-75 Scenario A — the deploy belt puts the drop-zone on the SYSTEM drive and NOWHERE ELSE.
// The wrong outcome this guards is a second, non-functional import/<app> appearing on the data
// drive: it would look exactly like a drop-zone, silently do nothing, and (import being
// class: excluded) never be backed up either.
func TestScenarioA_BeltCreatesImportOnSystemDriveOnly(t *testing.T) {
m := newMigManager(t, "")
stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
dataDrive := t.TempDir() // stands in for /mnt/felhom-drives/hdd_1
sysNS := t.TempDir() // stands in for /mnt/sys_drive/felhom-data
userdataPath := appbackup.UserdataDir(dataDrive)
importPath := appbackup.ImportDir(sysNS)
m.isMountPoint = func(string) bool { return true } // the data drive is attached
m.ensureUserdataMounts(stackDir, []string{
"HDD_PATH=" + dataDrive,
"USERDATA_PATH=" + userdataPath,
"IMPORT_PATH=" + importPath,
})
// (1) the drop-zone exists on the SYSTEM drive
want := filepath.Join(importPath, "paperless")
fi, err := os.Stat(want)
if err != nil || !fi.IsDir() {
t.Fatalf("belt did not create the canonical drop-zone %s (%v)", want, err)
}
// (2) with the userdata convention: setgid + group-rwx
if perm := fi.Mode().Perm(); perm != 0o775 || fi.Mode()&os.ModeSetgid == 0 {
t.Errorf("drop-zone mode = %v, want setgid + 0775 (2775)", fi.Mode())
}
// (3) and NOT on the data drive — the wrong outcome named in the scenario
if _, err := os.Stat(filepath.Join(userdataPath, "import")); err == nil {
t.Errorf("a second drop-zone was created on the data drive at %s — exactly the dead drop-zone R-75 removes",
filepath.Join(userdataPath, "import"))
}
}
// A detached data drive must not stop the system-drive drop-zone from being created: the two roots
// are on different devices and the drive-absent gate is about the data drive only.
func TestScenarioA_ImportBeltNotGatedByDetachedDataDrive(t *testing.T) {
m := newMigManager(t, "")
stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
dataDrive := t.TempDir()
sysNS := t.TempDir()
importPath := appbackup.ImportDir(sysNS)
m.isMountPoint = func(string) bool { return false } // drive DETACHED
m.ensureUserdataMounts(stackDir, []string{
"HDD_PATH=" + dataDrive,
"USERDATA_PATH=" + appbackup.UserdataDir(dataDrive),
"IMPORT_PATH=" + importPath,
})
if _, err := os.Stat(filepath.Join(importPath, "paperless")); err != nil {
t.Errorf("the system-drive drop-zone must be created even when the DATA drive is detached: %v", err)
}
// the userdata half stays correctly gated (nothing written onto the rootfs)
if _, err := os.Stat(appbackup.UserdataDir(dataDrive)); err == nil {
t.Error("the drive-absent gate must still suppress userdata creation on a detached drive")
}
}
// R-75: the canonical drop-zone must never migrate with an app. Migrating an app OFF the system
// drive would otherwise drag <sysNS>/userdata/import onto the destination data drive — a second,
// non-functional, unbacked drop-zone.
func TestImportRootExcludedFromMigration(t *testing.T) {
m := newMigManager(t, "")
sysNS := appbackup.NamespaceRoot(m.cfg.Paths.SystemDataPath, false)
importRoot := appbackup.ImportDir(sysNS)
// App migrating OFF the system drive: source namespace IS the system namespace.
offSystem := m.appDataSkipSet(&MigrationJob{SourceNS: sysNS, Apps: []string{"paperless-ngx"}})
if !offSystem[filepath.Clean(importRoot)] {
t.Errorf("import root %q must be pruned from a migration off the system drive; skip set = %v",
importRoot, offSystem)
}
// App migrating OFF a data drive: no import root there, nothing extra to prune.
dataNS := "/mnt/felhom-drives/hdd_1"
offData := m.appDataSkipSet(&MigrationJob{SourceNS: dataNS, Apps: []string{"paperless-ngx"}})
if offData[filepath.Clean(importRoot)] {
t.Error("a data-drive migration must not carry a system-drive skip entry")
}
}
// pathUnder must be segment-wise: a sibling sharing a name prefix is NOT contained.
func TestPathUnderIsSegmentWise(t *testing.T) {
root := filepath.Clean("/mnt/sys_drive")
if !pathUnder(root, root) {
t.Error("a path must be under itself")
}
if !pathUnder(filepath.Join(root, "felhom-data", "userdata"), root) {
t.Error("a descendant must be under the root")
}
if pathUnder(filepath.Clean("/mnt/sys_drive-evil/x"), root) {
t.Error("a name-prefix sibling must NOT be under the root")
}
}
+5 -3
View File
@@ -150,10 +150,12 @@ func (m *Manager) ensureFileBrowser(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
// Initial render: no storage mounts yet (web.SyncFileBrowserMounts fills them in on the first
// storage-path change and owns all later regeneration).
// Initial render: no storage mounts yet, and no import source either (web.SyncFileBrowserMounts
// fills both in on the first storage-path change and owns all later regeneration). Rendering the
// import source here without its bind would produce a source with no path behind it — a broken
// sidebar entry — so both halves are deliberately deferred to the same place.
compose := infra.RenderFileBrowserCompose(m.cfg.Customer.Domain, nil)
config := infra.RenderFileBrowserConfig(nil)
config := infra.RenderFileBrowserConfig(nil, false)
if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil {
return fmt.Errorf("write docker-compose.yml: %w", err)
}
+63 -5
View File
@@ -179,9 +179,63 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
}, nil
}
// ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... bind source the
// stack declares with the userdata convention, so Docker never auto-creates one as guest-root.
// GetImportRoot returns the CANONICAL drop-zone root (R-75): <system namespace root>/userdata/import.
//
// It is resolved from the SYSTEM drive, never from the app's HDD_PATH, so every app's drop-zone lands
// in one place regardless of which drive the app was deployed to. The system drive holds a felhom-data
// SUBDIR (it is not itself the namespace root — that is the inGuestDrive=false case), which is why
// NamespaceRoot is applied rather than using the configured path directly.
//
// Returns "" when the system data path is unconfigured. Callers must NOT substitute a per-drive
// fallback: that would put a folder that looks like a drop-zone on every drive while only one works.
// withPathVars leaves IMPORT_PATH unset instead, so compose fails loudly on ${IMPORT_PATH}.
//
// NOTE: the system drive is deliberately NOT a registered StoragePath (verified on both demo boxes,
// 2026-07-26), so this root is invisible to the storage UI, to buildFileBrowserPaths' per-path loop
// and to sharingResolvePath's owning-root check. Everything that must reach it does so explicitly —
// see EnsureImportRoot, the FileBrowser import bind, and the System SMB share.
func (m *Manager) GetImportRoot() string {
sys := m.cfg.Paths.SystemDataPath
if sys == "" {
m.logger.Printf("[ERROR] [stacks] IMPORT_PATH unresolvable: paths.system_data_path is empty — a drop-zone bind will fail to resolve rather than silently land on a data drive")
return ""
}
return appbackup.ImportDir(appbackup.NamespaceRoot(sys, false))
}
// ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... and ${IMPORT_PATH}/...
// bind source the stack declares with the userdata convention, so Docker never auto-creates one as
// guest-root.
//
// The two roots are gated DIFFERENTLY and that is load-bearing. ${USERDATA_PATH} is on the app's own
// data drive and is subject to the drive-absent gate; ${IMPORT_PATH} (R-75) is on the SYSTEM drive,
// which is always present, so gating it on a detached data drive would refuse to create a directory
// that has nothing to do with that drive.
func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
composePath := filepath.Join(stackDir, "docker-compose.yml")
binds := ParseComposeClassifiableBinds(composePath)
// --- import binds: system drive, never drive-gated ---
if importPath := envLookup(env, "IMPORT_PATH"); importPath != "" {
for _, b := range binds {
if b.Root != appbackup.RootImport {
continue
}
src := filepath.Join(importPath, filepath.FromSlash(b.RelPath))
if err := appbackup.EnsureUserdataDir(src); err != nil {
m.logger.Printf("[WARN] [stacks] import belt: ensure %s: %v", src, err)
}
}
} else {
for _, b := range binds {
if b.Root == appbackup.RootImport {
m.logger.Printf("[ERROR] [stacks] import belt: stack declares a ${IMPORT_PATH} bind but IMPORT_PATH is unset — compose will fail rather than bind a wrong-drive path")
break
}
}
}
// --- userdata binds: the app's own drive, drive-absent gated ---
userdataPath := envLookup(env, "USERDATA_PATH")
if userdataPath == "" {
return
@@ -195,8 +249,11 @@ func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
m.logger.Printf("[INFO] [stacks] userdata belt: drive %s not mounted — skipping ensure (held by drive gate)", hdd)
return
}
composePath := filepath.Join(stackDir, "docker-compose.yml")
for _, src := range ParseComposeUserdataMounts(composePath, userdataPath) {
for _, b := range binds {
if b.Root != appbackup.RootUserdata {
continue
}
src := filepath.Join(userdataPath, filepath.FromSlash(b.RelPath))
if err := appbackup.EnsureUserdataDir(src); err != nil {
m.logger.Printf("[WARN] [stacks] userdata belt: ensure %s: %v", src, err)
}
@@ -1071,7 +1128,8 @@ func (m *Manager) stackEnv(stackDir string) []string {
// Inject USERDATA_PATH = <namespace root>/userdata alongside HDD_PATH (v0.66.0). HDD_PATH IS
// the namespace root (the chosen StoragePath: a Model-A user drive's mount, or the SSD's
// felhom-data dir), so the catalog's ${USERDATA_PATH}/... mounts resolve under userdata/.
env = withUserdataPath(env, appCfg.Env["HDD_PATH"])
// IMPORT_PATH (R-75) rides along but is derived from the SYSTEM drive, never from HDD_PATH.
env = withPathVars(env, appCfg.Env["HDD_PATH"], m.GetImportRoot())
}
// App-email relay env (appended LAST so it wins over any app.yaml default). Returns nil unless
+29 -7
View File
@@ -51,6 +51,11 @@ type Metadata struct {
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
// DataPaths is the customer-facing folder ANNOTATION (R-75): role + Hungarian label over paths
// that must ALREADY exist as compose binds. It never declares a path. Validated in LoadMetadata
// with the Fork-3 asymmetry — a malformed PATH rejects the whole block, an unknown ROLE drops
// just that entry (see ValidateDataPaths).
DataPaths []DataPath `yaml:"data_paths,omitempty" json:"data_paths,omitempty"`
}
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
@@ -328,15 +333,32 @@ func LoadMetadata(stackDir string) Metadata {
// block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
// to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
// meta.Backup yet (Task 3/4).
if meta.Backup != nil {
if meta.Backup != nil || len(meta.DataPaths) > 0 {
composePath := filepath.Join(stackDir, "docker-compose.yml")
binds := ParseComposeClassifiableBinds(composePath)
if _, err := os.Stat(composePath); err != nil {
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, err)
meta.Backup = nil
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
meta.Backup = nil
_, composeErr := os.Stat(composePath)
if meta.Backup != nil {
if composeErr != nil {
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
meta.Backup = nil
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
meta.Backup = nil
}
}
// data_paths (R-75) rides the same choke point, with its own asymmetric rules: a malformed
// path drops the WHOLE block (nothing from it can be trusted), an unknown role drops only
// that entry (presentation, not data handling).
if len(meta.DataPaths) > 0 {
if composeErr != nil {
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
meta.DataPaths = nil
} else if kept, err := ValidateDataPaths(meta.DataPaths, binds, dirName, log.Default()); err != nil {
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: %v", stackDir, err)
meta.DataPaths = nil
} else {
meta.DataPaths = kept
}
}
}
+18 -1
View File
@@ -652,7 +652,8 @@ func (m *Manager) migCleanupAllowed(j *MigrationJob) error {
return nil
}
// appDataSkipSet returns the source appdata dirs (rsync'd separately) to skip in the merge walk.
// appDataSkipSet returns the source dirs to prune from the merge walk: the app appdata dirs (rsync'd
// separately) plus the canonical import root (R-75, never migrates).
func (m *Manager) appDataSkipSet(j *MigrationJob) map[string]bool {
skip := map[string]bool{}
for _, app := range j.Apps {
@@ -660,9 +661,25 @@ func (m *Manager) appDataSkipSet(j *MigrationJob) map[string]bool {
skip[filepath.Clean(appbackup.AppDataDir(j.SourceNS, name))] = true
}
}
// R-75: the CANONICAL drop-zone lives ONCE, on the system drive, and must never move with an app.
// This fires when an app is migrated OFF the system drive: the merge walk would otherwise see
// <sysNS>/userdata/import in the source namespace and copy the whole box's drop-zone onto the
// destination data drive — creating exactly the second, non-functional drop-zone this arc exists
// to remove (and, import being class: excluded, an unbacked one). Migrations off a DATA drive are
// unaffected: a data drive has no import root to match.
if root := m.GetImportRoot(); root != "" && pathUnder(root, j.SourceNS) {
skip[filepath.Clean(root)] = true
}
return skip
}
// pathUnder reports whether p is root or lives beneath it. Segment-wise, so a sibling directory
// sharing a name prefix can never match.
func pathUnder(p, root string) bool {
cp, cr := filepath.Clean(p), filepath.Clean(root)
return cp == cr || strings.HasPrefix(cp, cr+string(filepath.Separator))
}
// RecoverMigration resumes a crashed migration on startup (no-op if none or terminal).
func (m *Manager) RecoverMigration(ctx context.Context) {
j, err := m.loadJournal()
@@ -55,8 +55,8 @@ func TestSambaClassifiedBinds_TierMembership(t *testing.T) {
}
// Tier membership through the real helper.
offsite := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, storageRoot)
secondary := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, storageRoot)
offsite := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, storageRoot, "")
secondary := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, storageRoot, "")
if !hasRel(offsite, "shares/dokumentumok") {
t.Errorf("mandatory share must be in the OFFSITE set: %+v", offsite.Paths)
@@ -0,0 +1,81 @@
package stacks
import (
"os"
"path/filepath"
"sort"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// Catalog-derived userdata skeleton (R-75).
//
// Before this, the customer-facing tree was a hardcoded Go list, so a new catalog app with a folder
// needed a controller release. The set is now DERIVED from the catalog the controller has already
// synced to disk — using the same authoritative parser the classifier uses — merged with the
// carry-list (appbackup.UserdataSkeletonCarry) so it can only ever ADD.
//
// Scope note: this derives ${USERDATA_PATH} binds only. ${IMPORT_PATH} binds are NOT part of a
// drive's skeleton — the canonical drop-zone lives once, on the system drive, and is ensured by
// EnsureImportRoot instead.
// DeriveUserdataDirs returns the ${USERDATA_PATH}-relative dirs implied by every template in
// stacksDir. ALL catalog apps count, not just deployed ones (Fork-2 ruling): the skeleton is
// storage-path-scoped and idempotent, and the all-apps set is barely larger than the historical
// hardcoded one (SPIKE P0(a): 14 vs 14, differing by one entry each way). Deployed-only filtering
// belongs in the UI, where an empty folder would actually confuse someone.
//
// Unreadable dirs / missing composes are skipped silently — a partially-synced catalog must degrade
// to "fewer derived dirs", never to an error that blocks the skeleton (the carry-list is the floor).
// The result is sorted; BuildUserdataSkeleton sorts again after merging, so both layers are pinned.
func DeriveUserdataDirs(stacksDir string) []string {
entries, err := os.ReadDir(stacksDir)
if err != nil {
return nil
}
set := map[string]bool{}
for _, e := range entries {
if !e.IsDir() {
continue
}
composePath := filepath.Join(stacksDir, e.Name(), "docker-compose.yml")
for _, b := range ParseComposeClassifiableBinds(composePath) {
if b.Root != appbackup.RootUserdata || b.RelPath == "" {
continue
}
set[b.RelPath] = true
}
}
out := make([]string, 0, len(set))
for d := range set {
out = append(out, d)
}
sort.Strings(out)
return out
}
// UserdataSkeletonDirs is the merged, sorted set to create on a storage path: catalog-derived plus
// the carry-list. This is THE function every skeleton caller should use.
func (m *Manager) UserdataSkeletonDirs() []string {
return appbackup.BuildUserdataSkeleton(DeriveUserdataDirs(m.cfg.Paths.StacksDir))
}
// EnsureUserdataSkeleton applies the merged skeleton to a storage path's namespace root. Signature
// kept as func(string) error so it drops straight into fbPathDeps.ensureSkeleton.
func (m *Manager) EnsureUserdataSkeleton(nsRoot string) error {
return appbackup.EnsureUserdataSkeleton(nsRoot, m.UserdataSkeletonDirs())
}
// EnsureImportRoot creates the CANONICAL drop-zone root on the system drive with the userdata
// convention, so it exists (and is browsable + shareable) even before any drop-zone app is deployed.
// Idempotent; a no-op when the import root is unresolvable.
//
// It deliberately does NOT pre-create per-app subfolders: those are the deploy belt's job, so an app
// the customer never installed does not litter the drop-zone with an empty folder.
func (m *Manager) EnsureImportRoot() error {
root := m.GetImportRoot()
if root == "" {
return nil
}
return appbackup.EnsureUserdataDir(root)
}
@@ -3,6 +3,8 @@ package stacks
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
@@ -72,11 +74,12 @@ func TestEnsureUserdataMounts_CreatesBeltDirs(t *testing.T) {
_ = appbackup.SharedContentGID // keep import referenced cross-platform
}
// TestWithUserdataPath: the shared injector adds USERDATA_PATH=<hdd>/userdata when HDD_PATH is set, and
// TestWithPathVars: the shared injector adds USERDATA_PATH=<hdd>/userdata when HDD_PATH is set, and
// adds nothing when it's empty. Regression for the initial-deploy bug where ${USERDATA_PATH} resolved
// to "" and bound a bogus root-owned dir at the container root.
func TestWithUserdataPath(t *testing.T) {
got := withUserdataPath([]string{"DOMAIN=x"}, "/mnt/felhom-usb")
func TestWithPathVars(t *testing.T) {
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-usb", importRoot)
want := "USERDATA_PATH=" + appbackup.UserdataDir("/mnt/felhom-usb")
found := false
for _, e := range got {
@@ -88,9 +91,39 @@ func TestWithUserdataPath(t *testing.T) {
t.Errorf("USERDATA_PATH not injected: got %v, want %q", got, want)
}
// companion: empty HDD_PATH → no USERDATA_PATH at all
for _, e := range withUserdataPath([]string{"DOMAIN=x"}, "") {
if len(e) >= 13 && e[:13] == "USERDATA_PATH" {
for _, e := range withPathVars([]string{"DOMAIN=x"}, "", importRoot) {
if strings.HasPrefix(e, "USERDATA_PATH") {
t.Errorf("USERDATA_PATH must NOT be set when HDD_PATH is empty: %q", e)
}
}
}
// TestWithPathVars_ImportPath pins the R-75 half. IMPORT_PATH has the SAME failure mode
// USERDATA_PATH had: a site that forgets it resolves ${IMPORT_PATH} to "" and binds a bogus
// root-owned dir at the container root. And the unresolvable case must leave the variable UNSET —
// never fall back to a per-drive path, which would recreate the dead-drop-zone shape R-75 removes.
func TestWithPathVars_ImportPath(t *testing.T) {
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", importRoot)
if !slices.Contains(got, "IMPORT_PATH="+importRoot) {
t.Errorf("IMPORT_PATH not injected: got %v", got)
}
// It is CANONICAL: it must not be derived from HDD_PATH. A second app on a different drive gets
// the identical value — that is the whole point of the canonical root.
other := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/nvme-1tb", importRoot)
if !slices.Contains(other, "IMPORT_PATH="+importRoot) {
t.Errorf("IMPORT_PATH must not vary with HDD_PATH: got %v", other)
}
for _, e := range got {
if strings.HasPrefix(e, "IMPORT_PATH=") && strings.Contains(e, "felhom-drives") {
t.Errorf("IMPORT_PATH must never point at a data drive: %q", e)
}
}
// Unresolvable → UNSET (compose then fails loudly on ${IMPORT_PATH}).
for _, e := range withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", "") {
if strings.HasPrefix(e, "IMPORT_PATH") {
t.Errorf("IMPORT_PATH must NOT be set when the import root is unresolvable: %q", e)
}
}
}
@@ -30,6 +30,7 @@ func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "",
func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil }
func (p *blockProvider) GetStackHDDMounts(string) []string { return nil }
func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd }
func (p *blockProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *blockProvider) GetDockerVolumes(string) []string { return nil }
func (p *blockProvider) StopStack(string) error {
atomic.AddInt32(&p.stops, 1)
+139
View File
@@ -0,0 +1,139 @@
package web
import (
"fmt"
"path/filepath"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// „Hova tegyem a fájlokat?" — the app-page folder card (R-75).
//
// Rendered only for DEPLOYED apps that declare data_paths (Fork-2: all catalog apps get the
// skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed
// is the thing that would actually confuse someone).
// DataPathCard is one folder row on the app page.
type DataPathCard struct {
Label string // the catalog's Hungarian label
Link string // FileBrowser deep link
Consequence string // class-DRIVEN copy (never hand-written per app)
IsImport bool // drives the free-space line
FreeSpace string // system-drive headroom, import rows only ("" when unreadable)
}
// consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4).
//
// It is driven by the class, not by the role and not by a per-app string, so the promise the UI makes
// can never drift from what the backup engines actually do. `excluded` means the tier filter drops it
// at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is
// temporary and unbacked. Saying anything softer would be a false promise about their files.
func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string {
switch class {
case appbackup.ClassExcluded:
if role == stacks.RoleImport {
return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " +
"ez a mappa átmeneti, és nem készül róla biztonsági mentés."
}
return "Ez a mappa átmeneti, és nem készül róla biztonsági mentés."
case appbackup.ClassMandatory, appbackup.ClassOptional:
return "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla."
default:
// No classification (legacy app, or a bind with no backup block). Say nothing rather than
// guess — an unverified backup promise is worse than no sentence at all.
return ""
}
}
// buildDataPathCards turns an app's validated data_paths into rendered rows.
//
// classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence
// line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead.
func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard {
if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 {
return nil
}
domain := s.cfg.Customer.Domain
if domain == "" {
return nil
}
classOf := map[string]appbackup.BindClass{}
if binds, has := s.stackMgr.ClassifiedBinds(st.Name); has {
for _, b := range binds {
classOf[string(b.Root)+"\x00"+b.RelPath] = b.Class
}
}
var freeSpace string
if s.stackMgr != nil {
if root := s.stackMgr.GetImportRoot(); root != "" {
if du := system.GetDiskUsage(root); du != nil {
freeSpace = fmt.Sprintf("%.1f GB szabad", du.AvailGB)
}
}
}
cards := make([]DataPathCard, 0, len(st.Meta.DataPaths))
for _, dp := range st.Meta.DataPaths {
var link string
switch dp.Root {
case appbackup.RootImport:
link = importFolderLink(domain, dp.Path)
case appbackup.RootUserdata:
// A userdata folder lives on the app's OWN drive, so its FileBrowser source is that
// drive's sidebar entry. Resolve it from the app's HDD_PATH; skip the row if we cannot.
src := s.fbSourceNameForApp(st.Name)
if src == "" {
continue
}
link = fileBrowserLink(domain, src, dp.Path)
default:
// hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it.
// Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate
// an hdd path with a customer-facing role.
continue
}
isImport := dp.Role == stacks.RoleImport
card := DataPathCard{
Label: dp.Label,
Link: link,
Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role),
IsImport: isImport,
}
if isImport {
// The system SSD filling is a different severity from a data drive filling — it can take
// the whole guest down — and the customer has no other signal that the drop-zone is not
// bottomless.
card.FreeSpace = freeSpace
}
cards = append(cards, card)
}
return cards
}
// fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed
// on: the storage path's label when it has one, else the mount basename — exactly what
// RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree.
func (s *Server) fbSourceNameForApp(stackName string) string {
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
if appCfg == nil {
return ""
}
hdd := appCfg.Env["HDD_PATH"]
if hdd == "" {
return ""
}
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path != hdd || sp.Decommissioned {
continue
}
if sp.Label != "" {
return sp.Label
}
return filepath.Base(sp.Path)
}
return ""
}
@@ -0,0 +1,117 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// appInfoWithCards overlays the R-75 folder card onto the shared app_info fixture. It reuses
// appInfoData (lifecycle_test.go) deliberately — that helper passes a *Metadata for a reason
// documented there (value vs pointer receivers at render time); do not fork it.
func appInfoWithCards(st stacks.Stack, cards []DataPathCard) map[string]interface{} {
d := appInfoData(st)
if len(cards) > 0 {
d["DataPathCards"] = cards
}
return d
}
// SEAM-WIRING RULE: a conditional affordance ships with a render test PER BRANCH of its gate.
// Branch 1 — cards present: the block renders, with the label, the deep link and the copy.
func TestAppInfo_DataPathCardRenders(t *testing.T) {
st := stacks.Stack{Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning,
Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless-ngx"}}
cards := []DataPathCard{{
Label: "Beolvasandó dokumentumok",
Link: importFolderLink("demo-felhom.eu", "paperless"),
Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport),
IsImport: true,
FreeSpace: "44.8 GB szabad",
}}
html := renderBackupPage(t, "app_info", appInfoWithCards(st, cards))
if !strings.Contains(html, "Hova tegyem a fájlokat?") {
t.Error("the folder card heading must render")
}
if !strings.Contains(html, "Beolvasandó dokumentumok") {
t.Error("the catalog label must render")
}
// The deep link must reach the page INTACT — this is the assertion the brief asks for, and it is
// what would have caught the v0.150.0 class of bug (a key rendered where another belonged).
if !strings.Contains(html, "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless") {
t.Errorf("the FileBrowser deep link must render intact; body:\n%s", excerpt(html, "datapath"))
}
if !strings.Contains(html, "nem készül róla biztonsági mentés") {
t.Error("an excluded drop-zone must say it is unbacked")
}
if !strings.Contains(html, "44.8 GB szabad") {
t.Error("the system-drive free space must render on an import row")
}
// The copy must not promise a single click — a cold deep link goes through the FileBrowser login.
if !strings.Contains(html, "be kell jelentkezned") {
t.Error("the card must warn that a FileBrowser sign-in may be needed")
}
}
// Branch 2 — no cards: nothing renders, and in particular no empty heading.
func TestAppInfo_NoDataPathCardsRendersNothing(t *testing.T) {
st := stacks.Stack{Name: "docmost", Deployed: true, State: stacks.StateRunning,
Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}}
html := renderBackupPage(t, "app_info", appInfoWithCards(st, nil))
if strings.Contains(html, "Hova tegyem a fájlokat?") {
t.Error("an app with no data_paths must not render the folder card")
}
if strings.Contains(html, "datapath-row") {
t.Error("no folder rows must render")
}
}
// Fork-4: the consequence line is CLASS-driven, so the UI can never promise a backup the engines do
// not make. `excluded` is dropped at every tier — it must say so.
func TestConsequenceIsClassDriven(t *testing.T) {
imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport)
if !strings.Contains(imp, "törli innen") || !strings.Contains(imp, "nem készül róla biztonsági mentés") {
t.Errorf("an excluded import folder must say it is temporary AND unbacked: %q", imp)
}
for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} {
lib := consequenceFor(cls, stacks.RoleLibrary)
if !strings.Contains(lib, "Biztonsági mentés készül") {
t.Errorf("class %q must promise a backup: %q", cls, lib)
}
if strings.Contains(lib, "nem készül") {
t.Errorf("class %q must NOT say unbacked: %q", cls, lib)
}
}
// An unclassified bind says NOTHING rather than guessing — an unverified backup promise about a
// customer's files is worse than no sentence.
if got := consequenceFor("", stacks.RoleLibrary); got != "" {
t.Errorf("an unclassified path must produce no promise, got %q", got)
}
}
// A card is only built for a DEPLOYED app (Fork-2: deployed-only in the UI).
func TestBuildDataPathCards_UndeployedYieldsNothing(t *testing.T) {
s := testServer(t)
st := &stacks.Stack{Name: "paperless-ngx", Deployed: false,
Meta: stacks.Metadata{Slug: "paperless-ngx", DataPaths: []stacks.DataPath{
{Path: "paperless", Root: appbackup.RootImport, Role: stacks.RoleImport, Label: "x"},
}}}
if got := s.buildDataPathCards(st); got != nil {
t.Errorf("an undeployed app must get no folder card, got %v", got)
}
}
func excerpt(html, needle string) string {
i := strings.Index(html, needle)
if i < 0 {
return "(needle not found)"
}
end := i + 400
if end > len(html) {
end = len(html)
}
return html[i:end]
}
@@ -33,6 +33,7 @@ func (p *fabWebProvider) GetStackHDDMounts(string) []string {
return []string{appbackup.UserdataDir(p.hddPath)}
}
func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath }
func (p *fabWebProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return p.binds, true
}
@@ -0,0 +1,64 @@
package web
import (
"net/url"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// FileBrowser Quantum deep links (R-75).
//
// The template is taken VERBATIM from the shipped frontend router (SPIKE P2, read out of the
// bundle), not guessed:
//
// function Ms(n,t,…){ … let a=q2(t), s=`/files/${encodeURIComponent(n)}${a}` … }
// q2 = … .map(r=>encodeURIComponent(r)).join("/") // per-SEGMENT encoding
// function H2(n){ t=i.split("/")[2]; … } // source is the 3rd path segment
//
// So a link is constructible server-side from (sourceName, relPath) alone — no internal id, no
// index, no client-side state.
//
// A cold link (no FileBrowser session) is NOT lost: the router guard redirects to
// /login?redirect=<fullPath> and the login handler navigates back to it. The customer may still have
// to sign in, which is why the UI copy must not promise one click.
// fbSourceRoot is the FileBrowser origin for a customer domain. FileBrowser is published at
// files.<domain> by the base-infra traefik labels.
func fbSourceRoot(domain string) string {
return "https://files." + domain
}
// fileBrowserLink builds a deep link into a named FileBrowser source at relPath.
//
// ENCODING TRAP, measured in SPIKE P2 — use url.PathEscape, NEVER url.QueryEscape:
//
// "Média & könyvtár" PathEscape=M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r QueryEscape=M%C3%A9dia+%26+k%C3%B6nyvt%C3%A1r
// "a+b" PathEscape=a+b QueryEscape=a%2Bb
//
// QueryEscape encodes a space as "+", which inside a PATH segment means a literal plus and breaks
// the link. PathEscape leaves "&" unescaped, which is correct here: "&" is a legal path sub-delim,
// and html/template escapes it to "&amp;" in the href attribute, which the browser decodes back to
// "&". The two escapings compose — so do NOT pre-escape for HTML here.
//
// Source names cannot contain "/" (they come from filepath.Base or the ASCII import constant), which
// is what keeps the router's split("/")[2] round-trip intact.
func fileBrowserLink(domain, sourceName, relPath string) string {
var b strings.Builder
b.WriteString(fbSourceRoot(domain))
b.WriteString("/files/")
b.WriteString(url.PathEscape(sourceName))
for _, seg := range strings.Split(strings.Trim(relPath, "/"), "/") {
if seg == "" {
continue
}
b.WriteString("/")
b.WriteString(url.PathEscape(seg))
}
return b.String()
}
// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source.
func importFolderLink(domain, appDir string) string {
return fileBrowserLink(domain, infra.FileBrowserImportLabel, appDir)
}
@@ -0,0 +1,95 @@
package web
import (
"html/template"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// The deep-link template, pinned against the shipped FileBrowser Quantum router (SPIKE P2).
func TestFileBrowserLink_Template(t *testing.T) {
const dom = "demo-felhom.eu"
for _, tc := range []struct{ name, source, rel, want string }{
{"ascii source, one segment", "hdd_1", "media/movies",
"https://files.demo-felhom.eu/files/hdd_1/media/movies"},
{"accented source (the import label)", infra.FileBrowserImportLabel, "paperless",
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless"},
{"source root, empty relpath", infra.FileBrowserImportLabel, "",
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s"},
{"leading/trailing slashes are ignored", "hdd_1", "/media/books/",
"https://files.demo-felhom.eu/files/hdd_1/media/books"},
{"accented path segment", "hdd_1", "media/könyvek",
"https://files.demo-felhom.eu/files/hdd_1/media/k%C3%B6nyvek"},
} {
if got := fileBrowserLink(dom, tc.source, tc.rel); got != tc.want {
t.Errorf("%s:\n got %q\n want %q", tc.name, got, tc.want)
}
}
}
// THE trap, measured in SPIKE P2. QueryEscape turns a space into "+", which in a path segment is a
// literal plus and lands the customer on a folder that does not exist. This test fails if anyone
// swaps the escaper.
func TestFileBrowserLink_UsesPathEscapeNotQueryEscape(t *testing.T) {
const spaced = "Média & könyvtár"
got := fileBrowserLink("x.eu", spaced, "a b")
if strings.Contains(got, "+") {
t.Errorf("link contains '+' — QueryEscape was used somewhere; a '+' in a path segment is a literal plus, not a space: %q", got)
}
if want := url.PathEscape(spaced); !strings.Contains(got, want) {
t.Errorf("source not PathEscape'd: got %q, want it to contain %q", got, want)
}
// Guard the exact divergence the spike measured, so the two escapers can never be confused here.
if url.PathEscape(spaced) == url.QueryEscape(spaced) {
t.Fatal("fixture no longer distinguishes the two escapers — pick a name where they differ")
}
if strings.Contains(got, url.QueryEscape(spaced)) {
t.Errorf("link used QueryEscape: %q", got)
}
}
// The link is embedded in HTML. Percent-encoding and attribute-escaping must COMPOSE: PathEscape
// leaves "&" bare, html/template turns it into "&amp;", and the browser decodes it back to "&".
// Anything that double-encodes (a hand-rolled HTML escape before the template) breaks the path.
func TestFileBrowserLink_ComposesWithHTMLEscaping(t *testing.T) {
link := fileBrowserLink("x.eu", "Média & könyvtár", "docs")
var sb strings.Builder
tmpl := template.Must(template.New("a").Parse(`<a href="{{.}}">x</a>`))
if err := tmpl.Execute(&sb, link); err != nil {
t.Fatal(err)
}
out := sb.String()
if !strings.Contains(out, "&amp;") {
t.Errorf("html/template should escape the bare & in the href: %q", out)
}
// It must NOT have been percent-encoded a second time (%2526 etc. would be double-encoding).
if strings.Contains(out, "%25") {
t.Errorf("double percent-encoding detected — do not pre-escape before the template: %q", out)
}
// And no raw quote/angle escaped into the attribute.
if strings.Contains(out, `href=""`) {
t.Errorf("html/template refused the URL (would render an empty href): %q", out)
}
}
// importFolderLink targets the CANONICAL source, so a drop-zone link is identical regardless of
// which drive the app itself sits on.
func TestImportFolderLink_IsCanonical(t *testing.T) {
a := importFolderLink("demo-felhom.eu", "paperless")
b := importFolderLink("demo-felhom.eu", "calibre")
for _, l := range []string{a, b} {
if !strings.Contains(l, "/files/"+url.PathEscape(infra.FileBrowserImportLabel)+"/") {
t.Errorf("import link must go through the canonical source: %q", l)
}
if strings.Contains(l, "hdd_1") || strings.Contains(l, "felhom-drives") {
t.Errorf("import link must never name a data drive: %q", l)
}
}
if a == b {
t.Error("different apps must get different folders")
}
}
@@ -78,7 +78,7 @@ func TestFileBrowserNetworkShareIncluded(t *testing.T) {
t.Errorf("skeleton calls = %v, want exactly [%s] — a skeleton toward the NAS writes Felhom convention dirs onto the customer's own NAS", calls, drive.Path)
}
// Config has both sources, share named by its display label.
cfg := infra.RenderFileBrowserConfig(cfgPaths)
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
for _, m := range []string{`- path: "/srv/hdd_1"`, `- path: "/srv/Felhom-Share"`, `name: "Felhom Share"`} {
if !strings.Contains(cfg, m) {
t.Errorf("config missing %q:\n%s", m, cfg)
@@ -104,7 +104,7 @@ func TestFileBrowserNetworkStubExcluded(t *testing.T) {
if len(mounts) != 1 || strings.Contains(mounts[0], "Felhom-Share") {
t.Errorf("stub share leaked into mounts: %v", mounts)
}
cfg := infra.RenderFileBrowserConfig(cfgPaths)
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
if strings.Contains(cfg, "Felhom-Share") {
t.Errorf("stub share leaked into the source list:\n%s", cfg)
}
@@ -150,8 +150,8 @@ func TestFileBrowserNetworkRemoval(t *testing.T) {
oldCompose := infra.RenderFileBrowserCompose("example.hu", withMounts)
newCompose := infra.RenderFileBrowserCompose("example.hu", withoutMounts)
oldCfg := infra.RenderFileBrowserConfig(withCfg)
newCfg := infra.RenderFileBrowserConfig(withoutCfg)
oldCfg := infra.RenderFileBrowserConfig(withCfg, false)
newCfg := infra.RenderFileBrowserConfig(withoutCfg, false)
if strings.Contains(newCompose, "Felhom-Share") || strings.Contains(newCfg, "Felhom-Share") {
t.Error("removed share left a trace in the renders")
+27 -4
View File
@@ -636,6 +636,13 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["HasAppInfo"] = found.Meta.HasAppInfo()
data["EffectiveSubdomain"] = effectiveSubdomain
// „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY,
// like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0
// app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did.
if cards := s.buildDataPathCards(found); len(cards) > 0 {
data["DataPathCards"] = cards
}
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
@@ -2217,13 +2224,29 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
isMount: system.IsMountPoint,
classify: s.classifyFSPath,
ensureSkeleton: appbackup.EnsureUserdataSkeleton,
ensureSkeleton: s.ensureUserdataSkeleton,
logger: s.logger,
})
// R-75: the canonical drop-zone is an EXTRA bind, outside the registered-storage-path loop above.
// The system drive is deliberately not a registered StoragePath (it would become a customer-visible
// drive, a deploy target and a wipe candidate), so it is mounted here explicitly. Ensure the root
// first — a source whose path does not exist renders a broken sidebar entry.
importSource := false
if s.stackMgr != nil {
if importRoot := s.stackMgr.GetImportRoot(); importRoot != "" {
if err := s.stackMgr.EnsureImportRoot(); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure the import root %s: %v", importRoot, err)
}
storageMounts = append(storageMounts,
fmt.Sprintf(" - %s:/srv/%s", importRoot, infra.FileBrowserImportMount))
importSource = true
}
}
// Generate and write config.yaml (sources + sidebar entries per drive/share)
configPath := stackDir + "/config.yaml"
fbConfig := generateFileBrowserConfig(configPaths)
fbConfig := generateFileBrowserConfig(configPaths, importSource)
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
@@ -2382,6 +2405,6 @@ func generateFileBrowserCompose(domain string, storageMounts []string) string {
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
// registered storage path. Delegates to internal/infra (single source of truth).
func generateFileBrowserConfig(paths []settings.StoragePath) string {
return infra.RenderFileBrowserConfig(paths)
func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
return infra.RenderFileBrowserConfig(paths, importSource)
}
@@ -0,0 +1,220 @@
package web
import (
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-75 Scenario E — the system share's delete refusal is SERVER-SIDE.
//
// Two independent checks, tested independently on purpose (the v0.70.1 lesson): a handler test that
// POSTs directly proves nothing about UI reachability, and a render gate proves nothing about
// enforcement. Both are required; neither substitutes for the other.
func serverWithImportShare(t *testing.T) *Server {
t.Helper()
s := testServer(t)
// A real Manager so the handler's post-delete ReconcileSamba has a receiver. Sharing is left
// DISABLED, so reconcileSambaAt early-returns and no docker call is made.
s.cfg.Paths.SystemDataPath = "/mnt/sys_drive"
s.cfg.Paths.StacksDir = t.TempDir()
mgr, err := stacks.NewManager(s.cfg, s.logger)
if err != nil {
t.Fatal(err)
}
mgr.SetMigrationDeps(s.settings, func() bool { return false })
s.stackMgr = mgr
if err := s.settings.AddSMBShare(settings.SMBShare{
Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import",
System: true,
}); err != nil {
t.Fatal(err)
}
if err := s.settings.AddSMBShare(settings.SMBShare{
Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad",
}); err != nil {
t.Fatal(err)
}
return s
}
// Scenario E, enforcement half: POST the delete endpoint directly. The share must survive.
func TestScenarioE_SystemShareDeleteRefusedServerSide(t *testing.T) {
s := serverWithImportShare(t)
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/sharing/shares/delete",
strings.NewReader(url.Values{"name": {settings.SystemImportShareName}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.sharingShareDeleteHandler(rr, req)
found := false
for _, sh := range s.settings.GetSMBShares() {
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
found = true
}
}
if !found {
t.Fatal("the system share was DELETED by a direct POST — the refusal is not server-side")
}
// And a non-system share is still deletable, so this is a targeted refusal and not a broken
// endpoint that happens to refuse everything.
rr2 := httptest.NewRecorder()
req2 := httptest.NewRequest("POST", "/sharing/shares/delete",
strings.NewReader(url.Values{"name": {"csalad"}}.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.sharingShareDeleteHandler(rr2, req2)
for _, sh := range s.settings.GetSMBShares() {
if sh.Name == "csalad" {
t.Error("an ordinary share must still be deletable")
}
}
}
// The store layer refuses too, so no future caller can bypass the handler.
func TestScenarioE_StoreLayerRefusesSystemShare(t *testing.T) {
s := serverWithImportShare(t)
if err := s.settings.RemoveSMBShare(settings.SystemImportShareName); err == nil {
t.Error("RemoveSMBShare must refuse a System share")
}
if err := s.settings.RemoveSMBShare("csalad"); err != nil {
t.Errorf("RemoveSMBShare must still delete an ordinary share: %v", err)
}
}
// Scenario E, reachability half: the template must not render a delete button for a system share —
// and must still render one for an ordinary share.
func TestScenarioE_SharingTemplateOmitsSystemDeleteButton(t *testing.T) {
data := map[string]interface{}{
"Page": "sharing", "Title": "Hálózati megosztás",
"SMBEnabled": true,
// The SAME type the handler passes — see ShareRow's comment.
"SMBShares": []ShareRow{
{Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import", System: true, Available: true},
{Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad", Available: true},
},
"CSRFField": "",
}
html := renderBackupPage(t, "sharing", data)
rows := strings.Split(html, "<tr>")
var sysRow, normalRow string
for _, row := range rows {
if strings.Contains(row, settings.SystemImportShareName) {
sysRow = row
}
if strings.Contains(row, "csalad") {
normalRow = row
}
}
if sysRow == "" || normalRow == "" {
t.Fatalf("both share rows must render; sys=%v normal=%v", sysRow != "", normalRow != "")
}
if strings.Contains(sysRow, "/sharing/shares/delete") {
t.Error("the system share row must NOT carry a delete form")
}
if !strings.Contains(normalRow, "/sharing/shares/delete") {
t.Error("an ordinary share row must still carry its delete form")
}
}
// R-75 Scenario F — sharing stays OPT-IN. Deploying a drop-zone app must not put SMB on the LAN.
func TestScenarioF_SharingStaysOptIn(t *testing.T) {
s := testServer(t)
if s.settings.GetSMBSettings().Enabled {
t.Fatal("precondition: sharing must start disabled")
}
// The auto-create is wired to the ENABLE handler only; nothing in the deploy path calls it.
// Assert the state a fresh box is in: no shares at all.
if got := s.settings.GetSMBShares(); len(got) != 0 {
t.Errorf("a fresh box must have no shares before sharing is enabled, got %v", got)
}
if s.settings.GetSMBSettings().Enabled {
t.Error("sharing must not have been switched on")
}
}
// ensureImportShare is idempotent and correctly shaped.
func TestEnsureImportShare_IdempotentAndCorrect(t *testing.T) {
s := testServer(t)
// A writable stand-in for /mnt/sys_drive so EnsureImportRoot really creates the dir (the test
// user is not root, so the real path is not writable).
s.cfg.Paths.SystemDataPath = t.TempDir()
s.cfg.Paths.StacksDir = t.TempDir()
mgr, err := stacks.NewManager(s.cfg, s.logger)
if err != nil {
t.Fatal(err)
}
s.stackMgr = mgr
for i := 0; i < 3; i++ {
if err := s.ensureImportShare(); err != nil {
t.Fatalf("call %d: %v", i+1, err)
}
}
shares := s.settings.GetSMBShares()
if len(shares) != 1 {
t.Fatalf("expected exactly 1 share after 3 calls (idempotent), got %d: %v", len(shares), shares)
}
sh := shares[0]
if sh.Name != settings.SystemImportShareName {
t.Errorf("share name = %q, want %q", sh.Name, settings.SystemImportShareName)
}
if !sh.System {
t.Error("the import share must be marked System")
}
if sh.Offsite {
t.Error("the drop-zone is class `excluded` — Offsite must be false, or the UI would contradict the backup engines")
}
if sh.ReadOnly {
t.Error("a drop-zone the customer copies INTO must be writable")
}
if want := mgr.GetImportRoot(); sh.Path != want {
t.Errorf("share path = %q, want the canonical import root %q", sh.Path, want)
}
// The name must be NetBIOS-safe — it is an SMB share name.
if err := settings.ValidateSMBShareName(sh.Name); err != nil {
t.Errorf("share name is not NetBIOS-safe: %v", err)
}
}
// The brief's re-assertion: SPIKE P4 proved <root>/userdata/import is shareable against a GENERIC
// registered root. This pins the SYSTEM-root shape specifically, because ProtectedHDDPaths has a
// legacy felhom-data double-nest branch that only fires there.
//
// It documents the actual live shape, which is why ensureImportShare does not route through the
// picker guard: the system drive is NOT a registered storage path on either demo box (verified
// 2026-07-26), so sharingResolvePath — whose job is to validate CUSTOMER-supplied paths — refuses it.
// A controller-generated constant is a different trust class.
func TestImportRoot_NotReachableViaTheCustomerPicker(t *testing.T) {
s := testServer(t)
root := t.TempDir() // stands in for the system drive; deliberately NOT registered
importRoot := filepath.Join(root, "felhom-data", "userdata", "import")
if err := os.MkdirAll(importRoot, 0o755); err != nil {
t.Fatal(err)
}
// A registered data drive exists, so this is not "the registry is empty" trivially refusing.
dataDrive := t.TempDir()
if err := os.MkdirAll(filepath.Join(dataDrive, "userdata", "import"), 0o755); err != nil {
t.Fatal(err)
}
if err := s.settings.AddStoragePath(settings.StoragePath{Path: dataDrive, Label: "hdd_1", IsDefault: true}); err != nil {
t.Fatal(err)
}
if _, err := s.sharingResolvePath(importRoot); err == nil {
t.Error("the customer picker must NOT accept the unregistered system-drive import root")
}
// Control: the data drive's own userdata subtree IS pickable, so the guard is not refusing all.
if _, err := s.sharingResolvePath(filepath.Join(dataDrive, "userdata", "import")); err != nil {
t.Errorf("a registered drive's userdata/import must stay shareable: %v", err)
}
}
+70 -9
View File
@@ -115,6 +115,19 @@ func (s *Server) sharingResolveStorageRoot(raw string) (string, error) {
return "", errNotShareable
}
// ShareRow is one row of the shares table. It is a PACKAGE-LEVEL type, not a function-local struct,
// so the render test constructs the exact shape the handler passes: this template reads .System and
// .Available, and a field present in one and missing from the other is a render-time 500 that no
// handler test would catch (the template-gate class this project has hit four times).
type ShareRow struct {
Name string
Path string
ReadOnly bool
Offsite bool
System bool // controller-owned (R-75): no delete button, and the handler refuses it anyway
Available bool
}
// sharingPageData assembles the „Megosztás" page state.
func (s *Server) sharingPageData() map[string]interface{} {
data := s.settingsBaseData("sharing", "Hálózati megosztás")
@@ -134,18 +147,12 @@ func (s *Server) sharingPageData() map[string]interface{} {
data["SMBDirectAddress"] = s.sambaLANAddress()
}
type shareRow struct {
Name string
Path string
ReadOnly bool
Offsite bool
Available bool
}
var rows []shareRow
var rows []ShareRow
for _, sh := range s.settings.GetSMBShares() {
fi, err := os.Stat(sh.Path)
rows = append(rows, shareRow{
rows = append(rows, ShareRow{
Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite,
System: sh.System,
Available: err == nil && fi.IsDir(),
})
}
@@ -272,6 +279,14 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
return
}
// R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling
// sharing is the customer's decision (it puts SMB on the household LAN and demands a household
// password); deploying a drop-zone app must not trigger it. "Mandatory" here means "always present
// once sharing is on", not "turns sharing on".
if err := s.ensureImportShare(); err != nil {
s.logger.Printf("[WARN] [sharing] could not ensure the import share: %v", err)
}
// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
// regardless of whether anything actually came up.
@@ -411,6 +426,15 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
// SERVER-SIDE refusal for controller-owned shares (R-75), BEFORE any mutation. The template also
// omits the button; both are required and they prove different things — a render gate is not
// enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson).
for _, sh := range s.settings.GetSMBShares() {
if strings.EqualFold(sh.Name, name) && sh.System {
sharingRedirect(w, r, "Ez a megosztás a rendszer része, nem törölhető.")
return
}
}
if err := s.settings.RemoveSMBShare(name); err != nil {
sharingRedirect(w, r, err.Error())
return
@@ -524,3 +548,40 @@ func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) {
fmt.Fprintf(w, `{"error":"encode"}`)
}
}
// ensureImportShare creates the controller-owned drop-zone share (R-75) if it is not already there.
// Idempotent, and a no-op when the import root is unresolvable.
//
// It writes to the store DIRECTLY rather than going through sharingResolvePath: that guard validates
// paths a CUSTOMER supplied through the picker, and refuses anything outside a registered storage
// root. The system drive is deliberately not registered (registering it would make a 50 GB volume
// holding the recovery units a customer-visible drive, a deploy target and a wipe candidate), so the
// guard would refuse this path — correctly, for customer input. A controller-generated constant is a
// different trust class.
//
// Offsite is FALSE: the drop-zone is class `excluded` data, and shipping an inbox offsite would
// contradict the class the backup engines already act on.
func (s *Server) ensureImportShare() error {
if s.stackMgr == nil {
return nil
}
root := s.stackMgr.GetImportRoot()
if root == "" {
return nil
}
for _, sh := range s.settings.GetSMBShares() {
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
return nil // already present
}
}
if err := s.stackMgr.EnsureImportRoot(); err != nil {
return err
}
return s.settings.AddSMBShare(settings.SMBShare{
Name: settings.SystemImportShareName,
Path: root,
ReadOnly: false,
Offsite: false,
System: true,
})
}
+17 -1
View File
@@ -279,7 +279,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
// v0.66.0: create the full userdata skeleton with the shared-storage convention (2775 setgid,
// gid 1000) the moment a drive is registered — system drive AND additional drives. Idempotent;
// best-effort (a perms hiccup shouldn't block registration).
if err := appbackup.EnsureUserdataSkeleton(where); err != nil {
if err := s.ensureUserdataSkeleton(where); err != nil {
s.logger.Printf("[WARN] [web] userdata skeleton on %s: %v", where, err)
}
// Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath
@@ -890,3 +890,19 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}
// ensureUserdataSkeleton applies the CATALOG-DERIVED userdata skeleton (R-75) to a storage path.
//
// It replaces the direct appbackup.EnsureUserdataSkeleton call, which used a hardcoded Go list, so a
// new catalog app with a folder no longer needs a controller release. The derived set is merged with
// appbackup.UserdataSkeletonCarry (which is the old hardcoded list verbatim), so the result can only
// ever ADD — no directory this arc touches is ever removed.
//
// Falls back to the carry-list alone when the stack manager is not wired (setup mode / tests), which
// is exactly the pre-R-75 behaviour.
func (s *Server) ensureUserdataSkeleton(nsRoot string) error {
if s.stackMgr == nil {
return appbackup.EnsureUserdataSkeleton(nsRoot, appbackup.BuildUserdataSkeleton(nil))
}
return s.stackMgr.EnsureUserdataSkeleton(nsRoot)
}
@@ -71,6 +71,24 @@
onerror="this.style.display='none'">
</div>
{{if .DataPathCards}}
<div class="app-info-card" style="margin-top:1rem">
<h3>Hova tegyem a fájlokat?</h3>
<p class="form-hint">Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe.</p>
<div class="datapath-list">
{{range .DataPathCards}}
<div class="datapath-row">
<div class="datapath-head">
<a href="{{.Link}}" target="_blank" rel="noopener" class="datapath-link">{{.Label}} ↗</a>
{{if .FreeSpace}}<span class="datapath-space">{{.FreeSpace}}</span>{{end}}
</div>
{{if .Consequence}}<p class="datapath-note">{{.Consequence}}</p>{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{if and .Stack.Deployed .MigrateTargets}}
<div class="app-info-card" style="margin-top:1rem">
<h3>Áthelyezés másik tárhelyre</h3>
@@ -146,11 +146,15 @@
</form>
</td>
<td>
{{if .System}}
<span class="form-hint">rendszer</span>
{{else}}
<form method="POST" action="/sharing/shares/delete">
{{$.CSRFField}}
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-danger-outline">Törlés</button>
</form>
{{end}}
</td>
</tr>
{{end}}
@@ -1317,6 +1317,44 @@ a.stat-card:hover {
font-size: .95rem;
color: var(--text-1);
}
/* „Hova tegyem a fájlokat?" — R-75 folder rows on the app page. */
.datapath-list {
display: flex;
flex-direction: column;
gap: .75rem;
}
.datapath-row {
border-left: 3px solid var(--blue);
background: var(--blue-dim);
padding: .6rem .75rem;
border-radius: var(--radius);
}
.datapath-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: .75rem;
flex-wrap: wrap;
}
.datapath-link {
color: var(--blue-bright);
font-weight: 600;
text-decoration: none;
}
.datapath-link:hover {
text-decoration: underline;
}
.datapath-space {
color: var(--text-2);
font-size: .8rem;
white-space: nowrap;
}
.datapath-note {
margin: .35rem 0 0 0;
color: var(--text-2);
font-size: .85rem;
line-height: 1.45;
}
.app-info-list {
margin: 0;
padding-left: 1.25rem;