SPIKE: catalog-derived userdata skeleton + import surfaces (2026-07-26)
Verdict GO, with one mandatory constraint. Phase 0 HOLDS: the customer-facing path set is fully derivable from data the controller already parses (ParseComposeClassifiableBinds), and ValidateBackupSpec's refusal set already covers the path-safety class data_paths: needs -- so the annotation-only design introduces no new filesystem-write primitive. No catalog folder is reachable only via env indirection; zero templates use long `type: bind` syntax. Found off-brief: the derivation is ALREADY LIVE at deploy time (stacks/manager.go:183 ensureUserdataMounts, "the deploy belt"), proven by a clean two-box control -- media/podcasts exists on demo-felhom where audiobookshelf is deployed, not on demo-hp, and is in no skeleton. P0 GO: derived set 14 dirs vs hardcoded 14 (+media/podcasts, -documents); all three hand-verified anchors match. P1 GO: 4 sources incl. nested + accented names all index ready. P2 GO: deep-link template constructible in Go from (sourceName, relPath) alone; login redirect preserves the target. P3 GO for the feature, with a pre-existing setgid-chain break recorded. P4 GO: userdata/import is shareable, guard unchanged, no live share created. P5: before-state inventory captured read-only on both boxes. P6 GO-conditional: the naive derivation produced 20 distinct outputs from 20 identical runs -- a guaranteed FileBrowser force-recreate loop -- fixed by one sort. P7: 12 sources safe server-side. No production code, no version bump, no live mutation.
This commit is contained in:
@@ -0,0 +1,866 @@
|
||||
# SPIKE — catalog-derived userdata skeleton + import surfaces (2026-07-26)
|
||||
|
||||
**Class:** spike. Output is evidence, not shipped code. No production code was written, no version
|
||||
bumped, no live box mutated. All probes ran in `/tmp` (scratchpad) or a throwaway copy of the
|
||||
controller tree; the `felhom-controller` working tree is untouched and clean.
|
||||
|
||||
**Verdict up front: GO, with one mandatory constraint and one pre-existing defect surfaced.**
|
||||
Phase 0 holds — the customer-facing path set is fully derivable from data the controller already
|
||||
parses. P6 found the naive derivation is *totally* non-deterministic (20 distinct outputs from 20
|
||||
identical runs) and would force-recreate FileBrowser on every sync pass; the fix is one `sort`.
|
||||
P3 surfaced a pre-existing setgid-chain break that the drop-zone design must account for.
|
||||
|
||||
---
|
||||
|
||||
## 1. Confirmed baselines
|
||||
|
||||
| Repo | `main` @ commit | Notes |
|
||||
|---|---|---|
|
||||
| felhom-controller | `3b672ba74cb7a36e9e6c2203fe5a1fdbd71ae866` | tree clean, `HEAD == origin/main`, v0.171.0 |
|
||||
| felhom-agent | `dfd5d731` | not touched (no host surface in this arc) |
|
||||
| felhom.eu | `2d78c283` | this doc + the ROADMAP entry |
|
||||
| app-catalog-felhom.eu | `3067a946529615e4b39fcbc94fc6b14bb21a1c25` | 53 templates; cloned read-only to scratchpad |
|
||||
|
||||
FileBrowser image: `gtstef/filebrowser:1.3.3-stable`
|
||||
(digest `sha256:eb3733681db8757412632c61a99ad656f0d94ed6781bb2ea114b4d70babab78c`), pulled fresh.
|
||||
Every container probe used this exact tag. The startup banner confirms
|
||||
`Initializing FileBrowser Quantum (v1.3.3-stable)` — Quantum, not upstream filebrowser.
|
||||
|
||||
**Exit-code discipline.** Several commands here are pipelines, where `$?` is the *last* stage's
|
||||
status. One reading was corrupted by exactly that (`docker image inspect … | head; echo rc=$?`
|
||||
printed `rc=0` while docker had failed with `No such image`); it was caught and re-run with
|
||||
`${PIPESTATUS[0]}`. `docker pull` reported `PULL_RC=0` with a clean digest line. The P4 test run's
|
||||
`TEST_RC=0` was read via `PIPESTATUS`, and its stderr was empty. `go run` probes printed no stderr.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 0 — the design finding, confirmed at source
|
||||
|
||||
**Claim: the customer-facing path set is already derivable from data the controller parses today, so
|
||||
`data_paths:` in `.felhom.yml` can be a pure ANNOTATION over paths that must already exist as
|
||||
userdata-rooted compose binds — never a place where new paths are declared.**
|
||||
|
||||
### VERDICT: HOLDS.
|
||||
|
||||
Source citations:
|
||||
|
||||
1. **The parser already yields exactly the needed shape.**
|
||||
`controller/internal/stacks/classify_binds.go:38` `ParseComposeClassifiableBinds(composePath)
|
||||
[]appbackup.ComposeBind`, returning (`internal/appbackup/classify.go:55`):
|
||||
|
||||
```go
|
||||
type ComposeBind struct {
|
||||
Root BindRoot // "userdata" (${USERDATA_PATH}) | "hdd" (${HDD_PATH})
|
||||
RelPath string // path.Clean'd, forward-slash, relative; "" for a bare-root bind
|
||||
ReadOnly bool // true iff the mode field carries a `ro` token
|
||||
}
|
||||
```
|
||||
It is pure given the file bytes — no env resolution, no filesystem access beyond the read.
|
||||
|
||||
2. **`ValidateBackupSpec`'s refusal set is exactly the path-safety class `data_paths:` needs**
|
||||
(`internal/appbackup/classify.go:97`): unknown/empty class, empty path, backslash, absolute,
|
||||
non-`path.Clean`'d, leading `..`, duplicate `(root, path)`, and — the load-bearing one —
|
||||
*matches no compose bind*. That last rule is what makes annotation-only enforceable rather than
|
||||
merely intended: a `data_paths:` entry naming a path that is not already a compose bind is a
|
||||
whole-block reject. **Do not write a second validator.**
|
||||
|
||||
3. **Consequence: no new filesystem-write primitive is introduced from catalog data.** Every path a
|
||||
derived skeleton would create is a path the controller *already* creates today — see the deploy
|
||||
belt in §3.
|
||||
|
||||
### Completeness check (does any customer folder exist only via env indirection the parser can't see?)
|
||||
|
||||
Every host-side token in every `volumes:` short-syntax line across all 53 templates, grouped:
|
||||
|
||||
```
|
||||
3 ${USERDATA_PATH}/media
|
||||
2 ${USERDATA_PATH}/downloads
|
||||
1 ${USERDATA_PATH}/roms
|
||||
1 ${USERDATA_PATH}/media/tv
|
||||
1 ${USERDATA_PATH}/media/podcasts
|
||||
1 ${USERDATA_PATH}/media/photos
|
||||
1 ${USERDATA_PATH}/media/music
|
||||
1 ${USERDATA_PATH}/media/movies
|
||||
1 ${USERDATA_PATH}/media/comics
|
||||
1 ${USERDATA_PATH}/media/books
|
||||
1 ${USERDATA_PATH}/media/audiobooks
|
||||
1 ${USERDATA_PATH}/import/paperless
|
||||
1 ${USERDATA_PATH}/import/calibre
|
||||
1 ${HDD_PATH}/appdata/romm/resources
|
||||
1 ${HDD_PATH}/appdata/paperless/media
|
||||
1 ${HDD_PATH}/appdata/paperless/export
|
||||
1 ${HDD_PATH}/appdata/nextcloud
|
||||
1 ${HDD_PATH}/appdata/immich
|
||||
1 /var/run/docker.sock
|
||||
… (all remaining ~90 tokens are NAMED DOCKER VOLUMES, e.g. `immich_postgres_data`)
|
||||
```
|
||||
|
||||
Every non-`${VAR}` host token is either a named docker volume (no leading `/`, not a host path) or
|
||||
`/var/run/docker.sock`. **There is no customer folder reachable only through env indirection.**
|
||||
|
||||
Note for the impl task: `${HDD_PATH}/appdata/paperless/export` is an *export* dir that lives under
|
||||
`appdata/`, i.e. outside the customer-browsable tree. If the role vocabulary grows an `export` role,
|
||||
this path is NOT a candidate — it is not userdata-rooted and `appdata/` is share-deny-listed.
|
||||
|
||||
---
|
||||
|
||||
## 3. A finding that reshapes the design: the derivation is ALREADY LIVE, at deploy time
|
||||
|
||||
Not on the brief, found while reading for P5. `controller/internal/stacks/manager.go:183`:
|
||||
|
||||
```go
|
||||
// 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.
|
||||
func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
|
||||
…
|
||||
for _, src := range ParseComposeUserdataMounts(composePath, userdataPath) {
|
||||
if err := appbackup.EnsureUserdataDir(src); err != nil { … }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So the controller **already derives userdata dirs from compose binds and applies the 2775/GID-1000
|
||||
convention** — lazily, per app, at deploy. `UserdataSkeleton()` is only the *eager, all-apps-upfront*
|
||||
half of the same idea.
|
||||
|
||||
`ParseComposeUserdataMounts` (`internal/stacks/delete.go:517`) is structurally identical to
|
||||
`ParseComposeClassifiableBinds` (same `volumes:` scanner, same `SplitN(":",3)`, same quote-trim); it
|
||||
differs only in resolving to absolute paths and discarding `:ro`.
|
||||
|
||||
**This was confirmed by a clean natural experiment across the two demo boxes** — see P5. `media/podcasts`
|
||||
is NOT in `UserdataSkeleton()`. It exists on demo-felhom (where audiobookshelf is deployed) and does
|
||||
NOT exist on demo-hp (where it is not). Nothing but the deploy belt can have created it.
|
||||
|
||||
Consequence for the spec: the mechanism is proven in production, and design fork #2 ("all catalog
|
||||
apps or only deployed ones?") already has a shipped precedent — the belt does *deployed-only*.
|
||||
|
||||
---
|
||||
|
||||
## P0 — derivation output set (offline, read-only)
|
||||
|
||||
**Question:** what does `ParseComposeClassifiableBinds` yield across all 53 templates, and how does
|
||||
that compare to the hardcoded skeleton?
|
||||
|
||||
**Command:** throwaway Go program (`spikep0/main.go`) inside a **copy** of the controller module
|
||||
(`internal/…` packages are import-restricted, so an out-of-module `/tmp` program cannot reach them —
|
||||
the repo tree itself was never modified):
|
||||
|
||||
```
|
||||
cp -a felhom-controller/controller /tmp/…/scratchpad/ctrl
|
||||
cd /tmp/…/scratchpad/ctrl && go run ./spikep0 /tmp/…/scratchpad/catalog/templates
|
||||
```
|
||||
|
||||
**Raw output:**
|
||||
|
||||
```
|
||||
=== TABLE: app -> userdata RelPath -> ReadOnly -> class(origin) ===
|
||||
audiobookshelf media/audiobooks rw optional(explicit)
|
||||
audiobookshelf media/podcasts rw excluded(explicit)
|
||||
calibre-web import/calibre rw excluded(explicit)
|
||||
calibre-web media/books rw mandatory(explicit)
|
||||
emby media ro excluded(explicit)
|
||||
immich media/photos ro optional(explicit)
|
||||
jellyfin media ro excluded(explicit)
|
||||
komga media/comics rw optional(explicit)
|
||||
navidrome media/music ro excluded(explicit)
|
||||
paperless-ngx import/paperless rw excluded(explicit)
|
||||
plex media ro excluded(explicit)
|
||||
radarr media/movies rw excluded(explicit)
|
||||
radarr downloads rw excluded(explicit)
|
||||
romm roms rw optional(explicit)
|
||||
sonarr media/tv rw excluded(explicit)
|
||||
sonarr downloads rw excluded(explicit)
|
||||
|
||||
=== apps with NO userdata bind (41) ===
|
||||
actualbudget adventurelog bentopdf bookstack calcom claper code-server crafty-controller docmost
|
||||
ghost gitea glance gokapi grafana gramps-web home-assistant homebox homepage kimai mealie n8n
|
||||
nextcloud onlyoffice opengist outline papra plant-it privatebin rallly recipe-importer seerr
|
||||
sparkyfitness tandoor termix uptime-kuma vaultwarden vikunja wanderer wger wishlist zipline
|
||||
|
||||
=== apps whose compose contains 'type: bind' long syntax (0) ===
|
||||
[]
|
||||
|
||||
=== (a) DERIVED SET — 14 distinct dirs (leaf binds expanded to ancestor chain) ===
|
||||
downloads <- radarr,sonarr
|
||||
import (implied parent)
|
||||
import/calibre <- calibre-web
|
||||
import/paperless <- paperless-ngx
|
||||
media <- emby,jellyfin,plex
|
||||
media/audiobooks <- audiobookshelf
|
||||
media/books <- calibre-web
|
||||
media/comics <- komga
|
||||
media/movies <- radarr
|
||||
media/music <- navidrome
|
||||
media/photos <- immich
|
||||
media/podcasts <- audiobookshelf
|
||||
media/tv <- sonarr
|
||||
roms <- romm
|
||||
|
||||
=== (a) DIFF vs hardcoded UserdataSkeleton() (14 entries) ===
|
||||
ADDED by derivation (1): [media/podcasts]
|
||||
NO LONGER IMPLIED (1): [documents]
|
||||
|
||||
=== (b) import-only (1): [paperless-ngx]
|
||||
=== (b) library-only (10): [audiobookshelf emby immich jellyfin komga navidrome plex radarr romm sonarr]
|
||||
=== (b) BOTH (1): [calibre-web]
|
||||
|
||||
=== (b) ANCHORS ===
|
||||
paperless-ngx [import/paperless[rw,excluded]]
|
||||
calibre-web [import/calibre[rw,excluded] media/books[rw,mandatory]]
|
||||
romm [roms[rw,optional]]
|
||||
```
|
||||
|
||||
### (a) The diff table
|
||||
|
||||
The derived set and the hardcoded skeleton are both 14 entries and differ by exactly one each way:
|
||||
|
||||
| | entry | why |
|
||||
|---|---|---|
|
||||
| **ADDED by derivation** | `media/podcasts` | audiobookshelf's second bind. Already live on demo-felhom via the deploy belt (§3) — so this is not a new directory, it is the skeleton catching up to reality. |
|
||||
| **NO LONGER IMPLIED** | `documents` | No catalog app binds it. It exists on **both** demo boxes. |
|
||||
|
||||
`documents` is the entire removal risk in this arc, and it is a customer-visible folder that may hold
|
||||
customer files. The impl task's **zero-removals** invariant covers it; it must be carried as an
|
||||
explicit non-derived entry, not silently dropped.
|
||||
|
||||
### (b) Roles — all three hand-verified anchors match exactly
|
||||
|
||||
| anchor | expected | parser said | ✓ |
|
||||
|---|---|---|---|
|
||||
| paperless-ngx | `import/paperless` only | `import/paperless[rw,excluded]` | ✓ |
|
||||
| calibre-web | `import/calibre` **and** `media/books` | both, and only those | ✓ |
|
||||
| romm | `roms` only, no import path | `roms[rw,optional]` | ✓ |
|
||||
|
||||
Only **2 of 53** apps have an `import/*` path at all (paperless-ngx, calibre-web). This is a much
|
||||
smaller drop-zone surface than the feature framing implies — see design fork #2.
|
||||
|
||||
### (c) Binds the parser misses
|
||||
|
||||
**Zero.** No template uses long `type: bind` syntax (the parser's one documented blind spot):
|
||||
the string `type: bind` does not occur in any of the 53 compose files. Combined with the Phase 0
|
||||
completeness scan, the parser sees every userdata-rooted host bind in the catalog.
|
||||
|
||||
### GO / NO-GO: **GO.**
|
||||
|
||||
---
|
||||
|
||||
## P1 — FileBrowser Quantum source semantics
|
||||
|
||||
**Question:** with four sources — one nested inside another, plus accented/spaced/ampersand names —
|
||||
does it start, do all four appear, does the nested one corrupt the index?
|
||||
|
||||
**Command:** throwaway container on DooPlex (`~/fbspike-2026-07-26`), pinned image, production
|
||||
entrypoint wrapper (`sh -c "umask 002; exec /home/filebrowser/filebrowser"`), config shaped like
|
||||
`infra.RenderFileBrowserConfig` output, port 18080. Sources `/srv/a` (`hdd_1`), `/srv/a/import`
|
||||
(`Beolvasás`, **nested inside `/srv/a`**), `/srv/b-import` (`Beolvasas`), `/srv/c`
|
||||
(`Média & könyvtár`).
|
||||
|
||||
**Raw output (startup log, trimmed to the relevant lines):**
|
||||
|
||||
```
|
||||
[INFO ] Initializing FileBrowser Quantum (v1.3.3-stable)
|
||||
[INFO ] Using Config file : /home/filebrowser/config.yaml
|
||||
[INFO ] Auth Methods : [password]
|
||||
[INFO ] Sources : [hdd_1: /srv/a Beolvasás: /srv/a/import Beolvasas: /srv/b-import Média & könyvtár: /srv/c]
|
||||
[INFO ] initializing index: [Beolvasás]
|
||||
[INFO ] initializing index: [Média & könyvtár]
|
||||
[DEBUG] Created 1 scanners for [Beolvasás] (1 root + 0 children)
|
||||
[INFO ] initializing index: [Beolvasas]
|
||||
[DEBUG] Created 2 scanners for [Média & könyvtár] (1 root + 1 children)
|
||||
[DEBUG] Created 2 scanners for [Beolvasas] (1 root + 1 children)
|
||||
[INFO ] initializing index: [hdd_1]
|
||||
[DEBUG] Created 3 scanners for [hdd_1] (1 root + 2 children)
|
||||
[INFO ] Running at : http://0.0.0.0/
|
||||
```
|
||||
|
||||
Container status: `Up 6 seconds (healthy)`. **No warning or error relating to the nested source, the
|
||||
accents, the space, or the ampersand.** The only WARN in the whole log is the benign
|
||||
`database file could not be found` on first boot.
|
||||
|
||||
Sidebar contents, read from the endpoints the UI itself calls:
|
||||
|
||||
```
|
||||
GET /api/users?id=self -> "scopes": [
|
||||
{"name": "hdd_1", "scope": "/"},
|
||||
{"name": "Beolvasás", "scope": "/"},
|
||||
{"name": "Beolvasas", "scope": "/"},
|
||||
{"name": "Média & könyvtár", "scope": "/"} ]
|
||||
|
||||
GET /api/settings/sources -> all four present, every one "status": "ready"
|
||||
Beolvasas numDirs=1 numFiles=1
|
||||
Beolvasás numDirs=0 numFiles=1
|
||||
Média & könyvtár numDirs=1 numFiles=1
|
||||
hdd_1 numDirs=2 numFiles=2
|
||||
```
|
||||
|
||||
**Findings:**
|
||||
- All four render. Accented, spaced and `&` names round-trip correctly through the config, the
|
||||
index and the API.
|
||||
- **The nested source is indexed TWICE** — `hdd_1` reports `numDirs=2 numFiles=2`, which includes
|
||||
`/srv/a/import` and its file, *and* `Beolvasás` indexes the same file again. Two independent
|
||||
scanner sets over the same bytes. No corruption, no error, but duplicated index cost and the same
|
||||
file reachable under two sidebar entries.
|
||||
- **Source names are the identity key.** `/api/settings/sources` returns a map keyed by name, and
|
||||
the deep-link URL (P2) is built from the name. Names must therefore be unique — a per-drive
|
||||
fan-out that produced two `Beolvasás` entries would collide.
|
||||
|
||||
### GO / NO-GO: **GO for the separate-bind route** (`/srv/b-import` renders and browses cleanly).
|
||||
Nested works too (informational, as the brief specified) but is still not recommended: double
|
||||
indexing and the duplicate-path confusion buy nothing over a separate bind.
|
||||
|
||||
---
|
||||
|
||||
## P2 — deep-link URL shape
|
||||
|
||||
**Question:** is a per-app deep link constructible in Go from `(sourceName, relPath)` alone?
|
||||
|
||||
No browser exists on DooPlex, so instead of reading an address bar I read **the router source
|
||||
itself**, which is strictly more authoritative, and then verified the resulting URLs against the
|
||||
server. The frontend bundle is served brotli/gzip-compressed (`curl` without `--compressed` yields
|
||||
binary — an early read was garbage until that was noticed).
|
||||
|
||||
**Raw output — the URL builder, from `index-CdQ4hx-V.js`:**
|
||||
|
||||
```js
|
||||
// builder: n = source name, t = path
|
||||
function Ms(n,t,i,r=!1){ … let a=q2(t), s=`/files/${encodeURIComponent(n)}${a}`; … }
|
||||
|
||||
// path encoder q2: per-segment encodeURIComponent
|
||||
… .map(r=>encodeURIComponent(r)).join("/").replace("//","/")
|
||||
|
||||
// parser (round-trip): source is the 3rd path segment
|
||||
function H2(n){ let t,i=n; t=i.split("/")[2], i=g0(i,`/files/${t}`); return {source:t,path:i} }
|
||||
```
|
||||
|
||||
### The template
|
||||
|
||||
```
|
||||
{baseURL}files/{encodeURIComponent(sourceName)}/{each relPath segment, encodeURIComponent'd, joined by "/"}
|
||||
```
|
||||
|
||||
It depends on nothing but the source name and the path — **no internal id, no index, no client-side
|
||||
state.** Constructible server-side in Go.
|
||||
|
||||
**Verification (with a valid session cookie):**
|
||||
|
||||
```
|
||||
=== SPA route, WITH session ===
|
||||
/files/Beolvasas/paperless -> 200
|
||||
/files/Beolvas%C3%A1s -> 200
|
||||
/files/M%C3%A9dia%20%26%20k%C3%B6nyvt%C3%A1r/k%C3%B6nyvek -> 200
|
||||
|
||||
=== underlying API resource resolution, WITH session ===
|
||||
source=Beolvasas -> 200
|
||||
source=Beolvasás -> 200
|
||||
source=Média & könyvtár -> 200
|
||||
deep path b-import/paperless -> {"name":"paperless","size":4096,…,"type":"directory",
|
||||
"files":[{"name":"deep-marker.txt",…
|
||||
```
|
||||
|
||||
**Honest caveat on those 200s:** `/files/...` is a client-side SPA route, so the server returns the
|
||||
same shell for *any* such path — the body is byte-identical whether the path is valid or not. The
|
||||
`200` alone proves nothing. The real evidence is (a) the router source above, and (b) the
|
||||
`/api/resources` calls, which do server-side resolution and returned the actual directory listing.
|
||||
|
||||
**Cold, no session:**
|
||||
|
||||
```
|
||||
=== COLD (no session) ===
|
||||
route /files/Beolvasas/paperless -> 200 (SPA shell; the gate is client-side)
|
||||
route /files/Beolvas%C3%A1s -> 200 (SPA shell)
|
||||
api resources (cold) -> 401
|
||||
```
|
||||
|
||||
**The login redirect DOES preserve the target path.** From the router guard and the login component:
|
||||
|
||||
```js
|
||||
// guard: unauthenticated -> /login carrying the FULL path
|
||||
if(!(_e.isLoggedIn()||n.matched.some(l=>l.meta.optionalAuth))){
|
||||
se.setCurrentUser(null), i({path:"/login",query:{redirect:n.fullPath}}); return }
|
||||
|
||||
// login submit: navigate to the preserved target, else the generic root
|
||||
async submit(n){ … let t=U.route.query.redirect;
|
||||
(t===""||t===void 0||t===null)&&(t="/files/"); … }
|
||||
```
|
||||
|
||||
`n.fullPath` is the already-encoded path, so a cold deep link survives the login round-trip.
|
||||
|
||||
### The encoding trap — real, and quantified
|
||||
|
||||
The URL is embedded in HTML, so it needs percent-encoding *and* HTML-escaping. They compose safely,
|
||||
**but Go's `url.PathEscape` is NOT equivalent to `encodeURIComponent`:**
|
||||
|
||||
```
|
||||
"O'Brien's" PathEscape="O%27Brien%27s" QueryEscape="O%27Brien%27s"
|
||||
"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"
|
||||
"Beolvasás" PathEscape="Beolvas%C3%A1s" QueryEscape="Beolvas%C3%A1s"
|
||||
"a+b" PathEscape="a+b" QueryEscape="a%2Bb"
|
||||
```
|
||||
|
||||
- `url.PathEscape` leaves `&` **unescaped**. In an `href` attribute Go's `html/template` then emits
|
||||
`&`, which the browser decodes back to a literal `&`. A bare `&` is a legal path sub-delim and
|
||||
the server accepted it, so this is correct — but it only works because the two escapings compose.
|
||||
- `url.QueryEscape` is **wrong here**: it encodes space as `+`, which in a *path* segment means a
|
||||
literal plus, not a space.
|
||||
- **Recommendation: `url.PathEscape` per segment**, and let `html/template` do the attribute
|
||||
escaping. Do not hand-roll, and do not use `QueryEscape`.
|
||||
- Source names cannot contain `/` today (they come from `filepath.Base`), which keeps the
|
||||
`split("/")[2]` round-trip safe. Worth stating as an invariant in the impl task.
|
||||
|
||||
### GO / NO-GO: **GO.** The template is constructible from `(sourceName, relPath)` alone.
|
||||
|
||||
---
|
||||
|
||||
## P3 — upload identity through a new bind
|
||||
|
||||
**Question:** what uid/gid/mode do a file and a folder created through the UI land with, and does
|
||||
setgid inherit?
|
||||
|
||||
The first run was inconclusive by construction: the probe dir's group was 1000 and the container's
|
||||
process gid is also 1000, so "inherited the parent's group" and "used the process gid" are
|
||||
indistinguishable. Re-run with the parent group set to **100**, which separates them.
|
||||
|
||||
**Command:** the exact endpoints the UI invokes (extracted from the bundle:
|
||||
`POST /api/resources?path=&source=&override=` with the raw body, and the same with `&isDir=true`).
|
||||
|
||||
**Raw output:**
|
||||
|
||||
```
|
||||
=== PID1 umask (the wrapped entrypoint) ===
|
||||
Umask: 0002
|
||||
=== mimic production convention on the probe dir: 2775, group 1000 ===
|
||||
PARENT: 2775 1000:100 /home/kisfenyo/fbspike-2026-07-26/srv/b-import
|
||||
=== upload FILE === upload_http=200
|
||||
=== create FOLDER === mkdir_http=200
|
||||
=== HOST stat (mode owner:group) ===
|
||||
2775 1000:100 …/srv/b-import
|
||||
644 1000:100 …/srv/b-import/p3-uploaded.txt
|
||||
755 1000:100 …/srv/b-import/p3-uifolder
|
||||
```
|
||||
|
||||
Container identity: `uid=1000(filebrowser) gid=1000(filebrowser)`. PID1 umask is `0002` — the
|
||||
entrypoint wrapper works as documented at `infra.go:156`.
|
||||
|
||||
**Two separate results, and they point opposite ways:**
|
||||
|
||||
1. **Group inheritance WORKS.** Both the file and the folder landed group **100** — the parent's
|
||||
group, not the process's gid 1000. Setgid on the parent does its job. For production this means
|
||||
uploads land group 1000 as intended. ✓
|
||||
|
||||
2. **The MODE does not honour `umask 002`, and the setgid bit is not propagated.** File `644` (not
|
||||
`664`), folder `755` (not `2775`). Since PID1's umask really is `0002`, FileBrowser must be
|
||||
creating with an explicit `0644`/`0755` — the umask wrapper is **inert for these paths**. The
|
||||
brief asked me to confirm the umask produces the result rather than assume it; it does not.
|
||||
|
||||
### The consequence: a UI-created folder breaks the setgid chain one level down
|
||||
|
||||
```
|
||||
=== nested folder created through the UI, inside the UI-created folder ===
|
||||
755 1000:100 …/p3-uifolder <- parent group 100, NO setgid
|
||||
755 1000:1000 …/p3-uifolder/nested <- group 1000 = the PROCESS gid. Chain broken.
|
||||
|
||||
=== a gid-1000 process writing into the UI-made folder ===
|
||||
write OK
|
||||
644 1000:1000 …/p3-uifolder/from-app.txt <- group 1000, NOT the inherited 100
|
||||
|
||||
=== control: the same write into the CONVENTION dir (2775 setgid) ===
|
||||
644 1000:100 …/from-app-root.txt <- group 100 inherited. Convention intact.
|
||||
```
|
||||
|
||||
The control is what makes this conclusive: identical write, identical process, opposite outcome —
|
||||
the only difference is whether the target dir carries setgid.
|
||||
|
||||
**Is this breaking today?** No — it is **latent**. Every catalog app that touches userdata and
|
||||
declares an identity declares uid/gid **1000**, the same uid FileBrowser runs as, so owner
|
||||
permissions cover everything:
|
||||
|
||||
```
|
||||
audiobookshelf <none> calibre-web PUID=1000 PGID=1000
|
||||
emby UID=1000 GID=1000 immich <none>
|
||||
jellyfin <none> komga <none>
|
||||
navidrome <none> paperless-ngx USERMAP_UID=1000 USERMAP_GID=1000
|
||||
plex <none> radarr PUID=1000 PGID=1000
|
||||
romm <none> sonarr PUID=1000 PGID=1000
|
||||
```
|
||||
|
||||
For the paperless drop-zone specifically the feature works: paperless runs uid 1000, files land uid
|
||||
1000, and **deletion needs write on the containing directory**, which `import/paperless` has (2775).
|
||||
The bite comes the day a content app runs as a *different non-root* uid with gid 1000 — then a
|
||||
customer-created subfolder inside the drop zone becomes unwritable to it, silently.
|
||||
|
||||
**And it is already visible in production** — see P5: `import/calibre` on demo-felhom is live at
|
||||
`755 1000:1000` instead of `2775 root:1000`.
|
||||
|
||||
### GO / NO-GO: **GO for the stated feature** (paperless can consume and delete), **with the
|
||||
setgid-chain break recorded as a pre-existing defect the impl task must decide about.** It is not
|
||||
introduced by this arc and should not be fixed inside it — see Observations.
|
||||
|
||||
---
|
||||
|
||||
## P4 — share-guard acceptance (throwaway test, no live mutation)
|
||||
|
||||
**Question:** does the real `sharingResolvePath` accept `userdata/import` and still refuse
|
||||
everything it must?
|
||||
|
||||
**Hard boundary respected:** Megosztás was NOT enabled on demo-felhom or demo-hp, and no share was
|
||||
created anywhere. This probe is a Go test against a `t.TempDir()` fake root, run in the **copied**
|
||||
controller tree (the guard is an unexported method on `*web.Server`, so the test must be in-package;
|
||||
putting it in the copy kept the real repo clean).
|
||||
|
||||
**Command:** `go test ./internal/web/ -run 'TestP4' -v`
|
||||
|
||||
**Raw output:**
|
||||
|
||||
```
|
||||
=== RUN TestP4_DenyRootsDoNotCoverUserdataImport
|
||||
spike_p4_test.go:46: SharingDeniedRoots("/mnt/hdd_1") = [/mnt/hdd_1/appdata /mnt/hdd_1/backups
|
||||
/mnt/hdd_1/felhom-data /mnt/hdd_1/felhom-data/appdata /mnt/hdd_1/felhom-data/backups]
|
||||
--- PASS: TestP4_DenyRootsDoNotCoverUserdataImport (0.00s)
|
||||
=== RUN TestP4_ImportAcceptedRestRefused
|
||||
--- PASS: TestP4_ImportAcceptedRestRefused (0.00s)
|
||||
=== RUN TestP4_SymlinkInsideImportEscapes
|
||||
--- PASS: TestP4_SymlinkInsideImportEscapes (0.00s)
|
||||
PASS
|
||||
ok gitea.dooplex.hu/admin/felhom-controller/internal/web 0.009s
|
||||
TEST_RC=0
|
||||
```
|
||||
|
||||
The deny-list is confirmed as exactly the five entries the brief quoted — `userdata/import` is **not**
|
||||
among them. Asserted and passing:
|
||||
|
||||
| case | expected | result |
|
||||
|---|---|---|
|
||||
| `<root>/userdata/import` | ACCEPTED | ✓ |
|
||||
| `<root>/userdata/import/paperless` | ACCEPTED | ✓ |
|
||||
| `<root>/userdata/media/books` | ACCEPTED | ✓ |
|
||||
| `<root>/appdata/paperless` | refused | ✓ |
|
||||
| `<root>` itself | refused | ✓ |
|
||||
| relative path | refused | ✓ |
|
||||
| `<root>/userdata/../../etc` | refused | ✓ |
|
||||
| symlink inside `userdata/import` → outside `<root>` | refused | ✓ |
|
||||
|
||||
Both directions live in the same test, so it cannot pass on an always-accept or an always-deny guard.
|
||||
|
||||
### GO / NO-GO: **GO.** `userdata/import` is shareable with no guard change.
|
||||
|
||||
---
|
||||
|
||||
## P5 — live on-disk inventory (READ-ONLY, both boxes)
|
||||
|
||||
**Question:** the before-state the impl task's zero-removals invariant will be proven against.
|
||||
|
||||
Read-only: `find … -type d -printf`. No `mkdir`, no `chmod`, no `chown`, no service touched.
|
||||
ASCII-only patterns throughout (the accented-grep trap). demo-hp has no baked key — access was the
|
||||
G1 break-glass root password from the hub `host_recovery` vault; **the hub.db copy and the plaintext
|
||||
were both `shred -u`'d immediately after use.**
|
||||
|
||||
### demo-felhom, guest 9201 — `/mnt/felhom-drives/hdd_1/userdata`
|
||||
|
||||
```
|
||||
2775 1000:1000 /mnt/felhom-drives/hdd_1/userdata/import/paperless
|
||||
2775 1000:1000 /mnt/felhom-drives/hdd_1/userdata/media/books
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/documents
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/downloads
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/import
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media/audiobooks
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media/comics
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media/movies
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media/music
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media/photos
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media/podcasts
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/media/tv
|
||||
2775 root:1000 /mnt/felhom-drives/hdd_1/userdata/roms
|
||||
755 1000:1000 /mnt/felhom-drives/hdd_1/userdata/import/calibre
|
||||
### /mnt/sys_drive/felhom-data/userdata (no output — does not exist)
|
||||
```
|
||||
|
||||
Deployed there: `bookstack bookstack-db calibre-web cloudflared docmost docmost-postgres
|
||||
docmost-redis felhom-controller felhom-samba filebrowser immich-machine-learning immich-postgres
|
||||
immich-redis immich-server traefik`
|
||||
|
||||
### demo-hp, guest 9201 — `/mnt/felhom-drives/nvme-1tb/userdata`
|
||||
|
||||
```
|
||||
2775 1000:1000 /mnt/felhom-drives/nvme-1tb/userdata/import/paperless
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/documents
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/downloads
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/import
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/import/calibre
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media/audiobooks
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media/books
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media/comics
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media/movies
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media/music
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media/photos
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/media/tv
|
||||
2775 root:1000 /mnt/felhom-drives/nvme-1tb/userdata/roms
|
||||
### /mnt/felhom-drives/Felhom-Share/userdata (no output — the NAS, correctly never written to)
|
||||
### /mnt/sys_drive/felhom-data/userdata (no output — does not exist)
|
||||
```
|
||||
|
||||
Deployed there: `cloudflared felhom-controller filebrowser paperless-postgres paperless-redis
|
||||
paperless-webserver traefik`
|
||||
|
||||
### What the two boxes prove together
|
||||
|
||||
| observation | demo-felhom | demo-hp | reading |
|
||||
|---|---|---|---|
|
||||
| dir count under `userdata/` | 15 | 14 | — |
|
||||
| `media/podcasts` | **present**, 2775 root:1000 | **absent** | Not in `UserdataSkeleton()`. Present exactly where audiobookshelf is deployed → **created by the deploy belt (§3)**. This is the clean control. |
|
||||
| `documents` | present | present | In the skeleton, implied by no app. The one removal risk. |
|
||||
| `import/calibre` | **755 1000:1000** | 2775 root:1000 | calibre-web is deployed on demo-felhom and not on demo-hp. |
|
||||
| `import/paperless` | 2775 1000:1000 | 2775 1000:1000 | owner 1000 = touched by the app; mode intact. |
|
||||
| `Felhom-Share` (NAS) | n/a | no `userdata/` | R-67 holds: Felhom conventions are never written onto a customer NAS. |
|
||||
|
||||
**The `import/calibre` anomaly.** On demo-felhom it is `755 1000:1000` where every sibling is
|
||||
`2775 root:1000`. The sharpest control is *within the same app*: calibre-web's other bind,
|
||||
`media/books`, is `2775 1000:1000` on the same box, same deploy, same belt. Both binds are plain
|
||||
short syntax and both parsers pick up both (I checked `ParseComposeUserdataMounts` against
|
||||
calibre-web's compose specifically — there is no parser asymmetry). The difference between them is
|
||||
that `/cwa-book-ingest` is the app's *managed ingest directory* while `/calibre-library` is not,
|
||||
which points at the app rewriting the mode of its own drop zone after the belt sets it. I did not
|
||||
confirm that causally, and I am not asserting it — but it matches P3's shape exactly, and it means
|
||||
**a drop-zone directory's mode is not stable against the consuming app.** The impl task should verify
|
||||
before designing anything that depends on `import/*` staying 2775.
|
||||
|
||||
---
|
||||
|
||||
## P6 — config churn / force-recreate storm risk
|
||||
|
||||
**Question 1: does the catalog sync currently trigger `SyncFileBrowserMounts`?**
|
||||
|
||||
**No.** `cmd/controller/main.go:215`:
|
||||
|
||||
```go
|
||||
syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks, func(updated []string) {
|
||||
stackMgr.InjectMissingFields(updated)
|
||||
})
|
||||
```
|
||||
|
||||
The post-sync callbacks are `ScanStacks` and `InjectMissingFields` — **neither touches FileBrowser.**
|
||||
Moreover the rescan is itself gated (`internal/sync/sync.go:215`):
|
||||
|
||||
```go
|
||||
// Step 3: Trigger rescan if anything changed
|
||||
if len(newApps) > 0 || len(updated) > 0 { … s.rescanFn() … }
|
||||
```
|
||||
|
||||
So today the 15-minute cycle (`sync.go:95`, default `15 * time.Minute`) is a no-op for FileBrowser
|
||||
even when the catalog *does* change. The ~14 `SyncFileBrowserMounts` call sites are all
|
||||
storage/network-share lifecycle events, plus startup:
|
||||
|
||||
```
|
||||
internal/web/storage_handlers.go:292,306,550,731,889
|
||||
internal/web/server.go:234 (Reset), 236
|
||||
internal/web/netstorage_job.go:287 internal/web/netstorage_handlers.go:330
|
||||
internal/web/handlers.go:2056,2102
|
||||
internal/web/intermediary.go:289,307,425,504,548
|
||||
```
|
||||
|
||||
**Reading:** the 15-minute restart storm only materialises **if the impl task wires the catalog cycle
|
||||
into the FileBrowser sync.** That is a design choice still open, not an existing hazard.
|
||||
|
||||
**Question 2: is a derived source list deterministic?**
|
||||
|
||||
`fbNeedsRecreate` (`handlers.go:2374`) force-recreates on **any** byte difference:
|
||||
|
||||
```go
|
||||
func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
|
||||
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
|
||||
}
|
||||
```
|
||||
|
||||
Probe: generate the derived config **20 times from identical input**, hash each, count distinct
|
||||
outputs. Three variants — the way an implementer would most likely write it (accumulate into a map,
|
||||
range over the map), the same with one `sort.Strings`, and today's fixed-slice renderer as a control.
|
||||
|
||||
**Raw output:**
|
||||
|
||||
```
|
||||
derive-naive (map) 1/20 identical to the first generation; 20 distinct output(s)
|
||||
derive-sorted 20/20 identical to the first generation; 1 distinct output(s)
|
||||
today (fixed slice) 20/20 identical to the first generation; 1 distinct output(s)
|
||||
```
|
||||
|
||||
### The determinism number: **1 / 20 for the naive derivation — 20 distinct outputs from 20 identical runs.**
|
||||
### **20 / 20 with `sort.Strings`. 20 / 20 for today's code (control — the regression baseline is sound).**
|
||||
|
||||
Not "sometimes differs": **every single generation differed.** With 14 entries, Go's randomised map
|
||||
iteration essentially never repeats an order. Wired into `fbNeedsRecreate`, that is a guaranteed
|
||||
force-recreate of the customer's file browser on **every** sync pass — a v0.151-class reload loop,
|
||||
fleet-wide, and it would fire on all ~14 storage-event call sites even without the catalog cycle.
|
||||
|
||||
**The fix is one line.** The spec must state it as a hard requirement with a red-proof, not leave it
|
||||
to the implementer's care: *the derived source/dir list is sorted before rendering, and a test
|
||||
asserts N identical generations.* This is the "no silent non-determinism" instance of a standing
|
||||
project gotcha.
|
||||
|
||||
### GO / NO-GO: **GO, conditional.** The risk is real and total, and it is fully mitigated by an
|
||||
explicit sort + a determinism test. The spec is not blocked; it gains a mandatory clause.
|
||||
|
||||
---
|
||||
|
||||
## P7 — sidebar scale
|
||||
|
||||
**Question:** at 8 and 12 sources, is the sidebar still usable? Where does it degrade?
|
||||
|
||||
**Command:** same pinned image, regenerated configs, 8 then 12 sources with realistic Hungarian
|
||||
names including an apostrophe stress case (`O'Brien's`).
|
||||
|
||||
**Raw output:**
|
||||
|
||||
```
|
||||
--- N=8 status: Up 8 seconds (healthy)
|
||||
--- warnings/errors in log:
|
||||
2026/07/26 04:49:27 [WARN ] database file could not be found. … (benign first-boot)
|
||||
--- sources ready:
|
||||
count= 8
|
||||
{'Beolvasas':'ready','Beolvasás':'ready','Filmek':'ready','Fényképek':'ready',
|
||||
'Média & könyvtár':'ready','Sorozatok':'ready','Zene':'ready','hdd_1':'ready'}
|
||||
|
||||
--- N=12 status: Up 8 seconds (healthy)
|
||||
--- warnings/errors in log:
|
||||
2026/07/26 04:49:37 [WARN ] database file could not be found. … (benign first-boot)
|
||||
--- sources ready:
|
||||
count= 12
|
||||
{'Beolvasas':'ready','Beolvasás':'ready','Dokumentumok':'ready','Filmek':'ready',
|
||||
'Fényképek':'ready','Játékok':'ready','Könyvek':'ready','Média & könyvtár':'ready',
|
||||
"O'Brien's":'ready','Sorozatok':'ready','Zene':'ready','hdd_1':'ready'}
|
||||
```
|
||||
|
||||
All 12 index and reach `ready`; startup stays ~8 s to healthy; no new warnings at either count.
|
||||
`O'Brien's` resolves (`/api/resources` → 200), confirming the apostrophe path from P2 end to end.
|
||||
|
||||
**Honest limit of this probe:** "usable" in the *visual* sense (scroll length, truncation, whether 12
|
||||
entries crowd the sidebar) cannot be measured without a browser, and I did not measure it. What is
|
||||
established is that the **server side does not degrade** from 4 → 12 sources: no failures, no
|
||||
warnings, no startup-time cliff, every source ready. Visual usability at 12 remains a manual
|
||||
click-through question for the operator.
|
||||
|
||||
### Bound for the spec: **12 sources is safe server-side.** Per-drive × per-role fan-out should be
|
||||
budgeted against that ceiling, and a per-drive × per-role scheme on a 2-drive box with 3 roles
|
||||
(6 + 2 drive roots = 8) sits comfortably inside it.
|
||||
|
||||
---
|
||||
|
||||
## Design forks — evidence + recommendation, awaiting operator ruling
|
||||
|
||||
### Fork 1 — per-drive vs canonical import root
|
||||
|
||||
Sources are per registered storage path, so two drives = two `import/` trees, two sidebar entries,
|
||||
two candidate shares. The per-app deep link is unambiguous either way (it derives from that app's own
|
||||
`HDD_PATH`); the *share* and the *sidebar* are not.
|
||||
|
||||
**Evidence:** P1 — source names are the identity key and must be unique, so a per-drive scheme must
|
||||
qualify the name (`Beolvasás — hdd_1`), which is exactly the clutter fork 1 is about. P7 — 12 sources
|
||||
is safe server-side, so per-drive is not blocked by scale. P5 — both demo boxes have exactly one
|
||||
drive today, so the ambiguity is not yet observable in the field.
|
||||
|
||||
**Recommendation:** **per-drive**, with the drive name in the source label. It is the only option that
|
||||
never lies: a canonical root would have to pick one drive, and an app on the other drive would deep-link
|
||||
into a folder that is not the one its compose bind points at. Accept the extra sidebar entries; P7
|
||||
shows they cost nothing server-side.
|
||||
|
||||
### Fork 2 — create dirs for all catalog apps, or only deployed ones?
|
||||
|
||||
**Evidence:** P0(a) — the whole catalog implies **14** dirs, i.e. the all-apps set is barely larger
|
||||
than today's hardcoded 14 (diff: +`media/podcasts`, −`documents`). Only **2 of 53** apps have an
|
||||
`import/*` path at all. §3 — the deploy belt already does **deployed-only** and is proven in
|
||||
production. P5 — the two boxes differ by exactly one dir, and that difference is the belt's work.
|
||||
|
||||
**Recommendation:** **all catalog apps for the skeleton, deployed-only for anything the customer is
|
||||
told about.** The numbers make the "litters the drop-zone with empty folders" objection nearly moot —
|
||||
the all-apps import set is two folders — while all-apps keeps the skeleton idempotent and
|
||||
storage-path-scoped exactly as it is today. Surface only deployed apps' folders in the UI; that is
|
||||
where emptiness would actually confuse someone.
|
||||
|
||||
### Fork 3 — role vocabulary, and how an unknown role fails
|
||||
|
||||
`import` / `library` / `export`, and whether an unknown role fails OPEN (not surfaced) or is a
|
||||
whole-block reject.
|
||||
|
||||
**Evidence:** two existing precedents, and they point opposite ways *for good reasons*.
|
||||
`Lifecycle` (`metadata.go:31`) fails **OPEN** — "An UNKNOWN value degrades to available with one WARN
|
||||
— a typo in a catalog push must never brick a template." `Backup` (`metadata.go:53`) is a
|
||||
**whole-block reject** — an invalid class sets the block back to nil and logs one ERROR, degrading to
|
||||
legacy. The distinguishing principle: lifecycle governs *presentation*, backup governs *data
|
||||
handling*.
|
||||
|
||||
**Recommendation:** **fail OPEN to "not surfaced", following the R-57 lifecycle precedent.** `data_paths:`
|
||||
is annotation over paths that already exist and are already created by the belt; an unknown role
|
||||
costs a missing UI affordance, never a lost or mishandled file. Reserve whole-block reject for the
|
||||
path-validity rules, which `ValidateBackupSpec`'s refusal set already provides — so a *malformed path*
|
||||
rejects the block while an *unknown role* just doesn't render. Note the asymmetry explicitly in the
|
||||
spec so it reads as a decision rather than an inconsistency.
|
||||
|
||||
### Fork 4 — the backup-class collision
|
||||
|
||||
**Evidence, from P0 — the exact classes the derived paths carry:**
|
||||
|
||||
| path | class | origin |
|
||||
|---|---|---|
|
||||
| `import/paperless` | **excluded** | explicit |
|
||||
| `import/calibre` | **excluded** | explicit |
|
||||
| `media/books` | mandatory | explicit |
|
||||
| `media/audiobooks`, `media/comics`, `media/photos`, `roms` | optional | explicit |
|
||||
| `media`, `media/movies`, `media/music`, `media/tv`, `downloads`, `media/podcasts` | excluded | explicit |
|
||||
|
||||
**Both** import paths are `class: excluded` — never shipped offsite, opt-in only for a manual `.fab`.
|
||||
Every class in the catalog is `explicit`, so the copy can be data-driven with no fallback branch.
|
||||
|
||||
**Recommendation:** drive the UI copy from the class, not by hand. Any surface that says "put your
|
||||
files here" for an `excluded` path must also say the folder is temporary and unbacked — for
|
||||
`import/paperless` the catalog's own words are "consume inbox — deleted after ingest by contract".
|
||||
Because every path carries an explicit class, a two-way mapping (`excluded` → temporary-and-unbacked
|
||||
notice; otherwise → normal) covers the entire catalog with no default case.
|
||||
|
||||
---
|
||||
|
||||
## Observations — noticed, deliberately not acted on
|
||||
|
||||
1. **FileBrowser Quantum 1.3.3 ignores `umask 002` for created files and folders** (P3): files land
|
||||
`0644`, folders `0755`, and the setgid bit is not propagated even though PID1's umask really is
|
||||
`0002`. The comment at `infra.go:156` explains why the wrapper exists and is correct about the
|
||||
image not honouring `-e UMASK`; what it does not say is that the wrapper does not achieve the
|
||||
intended result for these paths either. Latent today (every userdata-touching app runs uid 1000),
|
||||
real the day one does not. **Deserves its own item — not this arc's to fix.**
|
||||
|
||||
2. **`import/calibre` is live at `755 1000:1000` on demo-felhom** (P5) where every sibling is
|
||||
`2775 root:1000`, with `media/books` on the same box, same app, same deploy at `2775` as the
|
||||
control. Consistent with the consuming app rewriting its own ingest dir's mode. Not confirmed
|
||||
causally; flagged for the impl task to verify before depending on `import/*` staying 2775.
|
||||
|
||||
3. **The nested FileBrowser source is indexed twice** (P1): `hdd_1` scans `/srv/a/import` as a child
|
||||
*and* `Beolvasás` scans it as a root. Harmless at this size, wasteful at library scale. An argument
|
||||
for the separate-bind route beyond the one the brief already made.
|
||||
|
||||
4. **`documents` is in the skeleton and implied by no app** (P0/P5). It exists on both boxes and may
|
||||
hold customer files. It is the only entry the derivation would drop, and the whole reason the
|
||||
zero-removals invariant needs to be stated rather than assumed.
|
||||
|
||||
5. **FileBrowser's login is not what the API shape suggests** — `POST /api/auth/login?username=<u>`
|
||||
with the password in an **`X-Password` header** (URL-encoded), not a JSON body. A JSON body is
|
||||
accepted by the parser and silently yields an empty password, so every attempt fails with a
|
||||
*bcrypt mismatch*, which reads exactly like a wrong password. Cost real time here. Also:
|
||||
`auth.adminUsername` / `auth.adminPassword` exist as config keys, and the `set`/`set user` CLI
|
||||
subcommands in 1.3.3 contradict their own `--help`. Worth a line in the impl task if anything ever
|
||||
needs to drive FileBrowser programmatically.
|
||||
|
||||
6. **`find / -xdev` will not find `userdata`** on a guest — the storage roots are separate mounts, so
|
||||
`-xdev` stops at the boundary and returns nothing, which reads identically to "the tree is gone".
|
||||
Same false-negative class as the accented-grep trap.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
- Probe containers `fbspike` / `fbmany` removed.
|
||||
- The hub.db copy and the demo-hp break-glass plaintext were `shred -u`'d.
|
||||
- Nothing was created, chmod'ed or chowned under any live `userdata/` tree; Megosztás was not enabled
|
||||
and no share was created on either box; no live `config.yaml` or `docker-compose.yml` was edited.
|
||||
- Probe sources live in the session scratchpad and `~/fbspike-2026-07-26`; nothing was committed to
|
||||
`felhom-controller`, whose tree is clean at `3b672ba7`.
|
||||
Reference in New Issue
Block a user