2668ac4da3
Pure appbackup.ComputeCaptureSet(binds, hasClassification, tier, hddPath) → CaptureSet
{HasClassification, Paths, Skipped}: legacy short-circuit → tier filter (§2) → structural
guards → equal-Abs collapse (mandatory>optional) → containment dedup → sort. Slash algebra,
no filepath/FS/log. Structural guards (traversal / bare HDD drive-root / reserved backups/)
are load-bearing (the compose parser does not reject ..). Pure CrossAppOverlaps advisory
(WARN wiring deferred to 3a/3b). INERT — no engine consumes it yet.
Tests: Groups A-F (appbackup) + F-S3 no-seam wiring (stacks); all 6 §10 red-proofs verified.
Docs: architecture §3 aligned (felhom.eu 8d85da7).
97 lines
6.0 KiB
Markdown
97 lines
6.0 KiB
Markdown
# REPORT — Capture-set computation (INERT; Task 3-core) — controller v0.133.0
|
||
|
||
## Summary
|
||
|
||
Task 3-core of the backup-classification-redesign arc
|
||
(`felhom.eu/documentation/architecture/07-backup-architecture.md` §3; tier×class matrix §2; spike
|
||
verdicts in `SPIKE-restic-snapshot-shape-2026-07-14.md`). Ships **one pure function**,
|
||
`appbackup.ComputeCaptureSet`, that turns an app's classified binds + a tier + the app's live
|
||
`hddPath` into the tier-filtered, structurally-guarded, containment-deduped absolute capture set that
|
||
the 3a (offsite) and 3b (tier-2) engines will consume — plus a `Skipped` list for structurally unsafe
|
||
would-be captures and a pure `CrossAppOverlaps` advisory. **Deliberately INERT** like Task 2: no
|
||
backup tier changes behavior; nothing consumes any of it yet.
|
||
|
||
## Baselines (live-verified at session start)
|
||
|
||
| Repo | `main` @ start | Version | This task |
|
||
|---|---|---|---|
|
||
| felhom-controller | `95f3180` | v0.132.0 → **v0.133.0** | `appbackup` engine + `stacks` wiring test |
|
||
| felhom.eu | `b279312` | — | §3 docs alignment only (commit `8d85da7`) |
|
||
|
||
## Files created / modified
|
||
|
||
- **new** `controller/internal/appbackup/captureset.go` — `ComputeCaptureSet`, `CaptureTier`,
|
||
`CapturePath`, `SkippedPath`, `CaptureSet`, `CrossAppOverlaps`, `Overlap`; the §8 pipeline +
|
||
structural guards.
|
||
- **new** `controller/internal/appbackup/captureset_test.go` — Groups A–F (7 tests).
|
||
- **new** `controller/internal/stacks/captureset_wiring_test.go` — Group G, F-S3 no-seam end-to-end.
|
||
- **mod** `controller/CHANGELOG.md` (v0.133.0, newest-on-top), `controller/REPORT.md` (this),
|
||
`controller/CONTEXT.md`, `controller/README.md` (appbackup surface, one block).
|
||
- **mod** `felhom.eu/documentation/architecture/07-backup-architecture.md` §3 (as-built API sketch;
|
||
separate commit `8d85da7`).
|
||
|
||
No engine edits (`RunTier2`/`RunOffboxBackup`/`RestoreOffbox` untouched), no web/scheduler wiring, no
|
||
`ClassifyBinds`/`ValidateBackupSpec`/`ParseComposeClassifiableBinds` change, no `AppDataDirNames` call.
|
||
|
||
## Design (as-built)
|
||
|
||
Fixed pipeline (§8): **legacy short-circuit → tier filter (§2) → structural guards → equal-Abs
|
||
collapse (mandatory > optional) → containment dedup (keep ancestor) → sort by Abs.** Pure: no
|
||
`os`/`exec`/`filepath`/logging; slash algebra throughout (`RelPath` is forward-slash, resolved Abs is
|
||
an in-container Linux path — `filepath` on the Windows test host would flip separators and break the
|
||
containment prefix checks). Resolution: `RootHDD → path.Join(hddPath, rel)`,
|
||
`RootUserdata → path.Join(hddPath, "userdata", rel)`. Structural guards are load-bearing security: the
|
||
compose parser path.Cleans but does not reject `..`, so an unlisted writable `${HDD_PATH}/../x` bind
|
||
reaches the function classed mandatory; the guard moves it to `Skipped` (with the bare-drive-root and
|
||
reserved-`backups/` guards) instead of into a captured path.
|
||
|
||
## Tests — results
|
||
|
||
`go build ./... && go vet ./... && go test ./...` — **all green, both repos** (felhom.eu has no Go).
|
||
|
||
New tests (8 total): `internal/appbackup` +7 (`PerTierSplit`, `LegacyInert`, `ExcludedInvisible`,
|
||
`StructuralGuards`, `LegitDotDotName`, `ContainmentAndCollision`, `CrossAppOverlaps`);
|
||
`internal/stacks` +1 (`CaptureSet_Wiring`). Full-suite package count unchanged, all `ok`.
|
||
|
||
### §10 red-proofs (mutation → FAIL → revert), every one verified
|
||
|
||
| ID | Mutation | Test that must fail | Observed failure |
|
||
|---|---|---|---|
|
||
| **B** (SQ5) | legacy short-circuit resolves binds as mandatory | `LegacyInert` | legacy app resolved `[appdata/sonarr, media/tv]` into Paths (+HasClassification=true) |
|
||
| **A** (tier) | `TierOffsite` includes optional | `PerTierSplit` | offsite Paths gained `/userdata/media/photos` |
|
||
| **D** (guard) | traversal guard deleted | `StructuralGuards` | `/mnt/evil` (escaped root) present in Paths; 2 Skipped not 3 |
|
||
| **E1** (contain) | containment dedup disabled | `ContainmentAndCollision` | descendant `appdata/paperless/media` not dropped |
|
||
| **E2** (mand-wins) | mandatory strength = optional | `ContainmentAndCollision` | collapsed `/userdata/media` class degraded to optional |
|
||
| **G** (wiring) | tier constants swapped in the filter | `CaptureSet_Wiring` (end-to-end) | real-Manager secondary lost photos / offsite gained it — no fake absorbed the typo |
|
||
|
||
All mutations reverted; post-revert full suite green; no `RED-PROOF` residue in the new files.
|
||
|
||
## Deploy / verify
|
||
|
||
Built + pushed `felhom-controller:0.133.0` on 180; deployed to demo guest 9201 (bootstrap-managed).
|
||
**INERT-silence check:** startup clean, no new feature log lines at runtime (the package has zero call
|
||
sites). Per-guest verification recorded below.
|
||
|
||
_Deploy verification (docker ps + logs) appended after the live deploy step — see the deploy commit._
|
||
|
||
## Live-validation scope
|
||
|
||
Live validation beyond deploy-health is **inherently N/A** for an inert pure package: it has no
|
||
runtime surface, no UI, no behavior change. The real live legs belong to 3a (offsite) and 3b (tier-2)
|
||
acceptance, which consume this function.
|
||
|
||
## Observations (documented, NOT acted on)
|
||
|
||
- **Parser-side traversal:** `ParseComposeClassifiableBinds` / `classifyRoot` path.Clean the compose
|
||
host token but do not reject a `..` that survives cleaning (`${HDD_PATH}/../x` → RelPath `../x`).
|
||
This is by design per the task (the parser stays a faithful extractor; the guard lives in 3-core),
|
||
and the structural guard here is what makes it safe. If a future task ever wants defence-in-depth,
|
||
the parser is the second place it could live — noted, not changed.
|
||
- `ClassifyBinds` emits legacy binds with an **empty** `Class`; `ComputeCaptureSet`'s legacy
|
||
short-circuit means those are never resolved, but `tierKeeps` also treats an empty class as
|
||
not-captured (defensive) — so even a future caller that skipped the short-circuit could not resolve
|
||
a classless bind. Belt-and-suspenders, intentional.
|
||
- The equal-Abs collapse can arise from two *spellings* of one path (`hdd:userdata/media` vs
|
||
`userdata:media`); today no catalog app does this, but the collapse + mandatory-wins rule makes it
|
||
safe if one ever does.
|