Files
felhom-controller/controller/internal/appbackup/captureset.go
T
admin 2958946517 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.
2026-07-26 08:12:57 +02:00

357 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package appbackup
import (
"path"
"sort"
"strings"
)
// Capture-set computation — Task 3-core of the backup-classification-redesign arc
// (felhom.eu/documentation/architecture/07-backup-architecture.md §3; tier×class matrix §2; SQ2/SQ5
// in SPIKE-backup-classification-2026-07-14.md). This is a PURE path-algebra layer: given an app's
// classified binds, a tier, and the app's live hddPath, it returns the tier-filtered,
// structurally-guarded, containment-deduped absolute capture set that the 3a (offsite) and 3b
// (tier-2) engines will capture. Deliberately INERT — no engine consumes it yet.
//
// Purity contract: no os, no exec, no logging, no filepath. Reasons for refused captures are DATA
// (SkippedPath.Reason); the engines log them (a skipped MANDATORY is a capture GAP the engines must
// surface loudly). All resolution and prefix algebra uses path.Join/path.Clean and "/" string ops —
// never filepath.* — because RelPath is defined forward-slash (classify.go) and every resolved Abs
// is an in-container Linux path; filepath on the Windows `go test` host would flip separators and
// break both expectations and the containment prefix checks.
// CaptureTier selects the tier column of §2 that the filter applies.
type CaptureTier string
const (
TierOffsite CaptureTier = "offsite" // §2: mandatory only (optional is the customer's local tier)
TierSecondary CaptureTier = "secondary" // §2: mandatory + optional
)
// CapturePath is one resolved path in the capture set. Abs is the in-container Linux absolute path;
// Root/RelPath preserve the bind's identity (the tier-2 layout and restore relpath-mirroring need it).
type CapturePath struct {
Abs string
Root BindRoot
RelPath string
Class BindClass
}
// SkippedPath is a would-be capture the tier filter selected but a structural guard refused. Reason
// is operator-English; the engines log it (a skipped mandatory path = a silent capture gap otherwise).
type SkippedPath struct {
Root BindRoot
RelPath string
Class BindClass
Reason string
}
// CaptureSet is the result of ComputeCaptureSet. HasClassification mirrors the classifier's bool;
// engines derive unit-only as (!HasClassification || len(Paths)==0). Paths is sorted by Abs.
type CaptureSet struct {
HasClassification bool
Paths []CapturePath
Skipped []SkippedPath
}
// Structural-guard reasons (distinct strings; each names the rule it enforces).
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.
// Pipeline (fixed order, §8): legacy short-circuit → tier filter (§2 columns) → structural guards →
// equal-Abs collapse (mandatory > optional) → containment dedup (keep ancestor) → sort by Abs.
//
// Tier columns (§2): TierOffsite carries mandatory only; TierSecondary carries mandatory + optional;
// excluded is silently dropped at every tier (never in Paths, never in Skipped). A legacy app
// (hasClassification=false) resolves NOTHING — {HasClassification:false} with nil Paths/Skipped —
// 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); 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, 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).
for _, cp := range uniq {
if hasStrictAncestor(cp.Abs, uniq) {
continue
}
cs.Paths = append(cs.Paths, cp)
}
// Stage 5: deterministic order.
sort.Slice(cs.Paths, func(i, j int) bool { return cs.Paths[i].Abs < cs.Paths[j].Abs })
sort.Slice(cs.Skipped, func(i, j int) bool {
if cs.Skipped[i].Root != cs.Skipped[j].Root {
return cs.Skipped[i].Root < cs.Skipped[j].Root
}
return cs.Skipped[i].RelPath < cs.Skipped[j].RelPath
})
return cs
}
// resolveGuardCollapse is the pipeline shared by ComputeCaptureSet and ComputeFabBuckets: keep-filter
// (the caller's predicate over class) → structural guards (Skipped) → resolve to Abs → equal-Abs
// 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, 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, 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, importRoot, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
})
}
byAbs := make(map[string]CapturePath, len(resolved))
for _, cp := range resolved {
if cur, ok := byAbs[cp.Abs]; ok {
byAbs[cp.Abs] = strongerCapture(cur, cp)
continue
}
byAbs[cp.Abs] = cp
}
uniq = make([]CapturePath, 0, len(byAbs))
for _, cp := range byAbs {
uniq = append(uniq, cp)
}
return uniq, skipped
}
// FabBuckets is the class-bucketed capture set for the manual `.fab` export (Task 4). Unlike
// ComputeCaptureSet it keeps ALL classes (structural guards run over every class — a traversal path is
// never plannable, opt-in or not) and does NOT collapse across containment (mandatory `media/books`
// inside excluded `media` both survive, in their own buckets). HasClassification=false ⇒ empty (the
// legacy full-root capture, unchanged).
type FabBuckets struct {
HasClassification bool
Mandatory []CapturePath
Optional []CapturePath
Excluded []CapturePath
Skipped []SkippedPath
}
// ComputeFabBuckets resolves an app's classified binds into per-class buckets for the `.fab` export
// 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, importRoot string) FabBuckets {
if !hasClassification {
return FabBuckets{HasClassification: false}
}
fb := FabBuckets{HasClassification: true}
uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(BindClass) bool { return true })
fb.Skipped = skipped
for _, cp := range uniq {
switch cp.Class {
case ClassMandatory:
fb.Mandatory = append(fb.Mandatory, cp)
case ClassOptional:
fb.Optional = append(fb.Optional, cp)
default:
fb.Excluded = append(fb.Excluded, cp)
}
}
for _, b := range []*[]CapturePath{&fb.Mandatory, &fb.Optional, &fb.Excluded} {
bk := *b
sort.Slice(bk, func(i, j int) bool { return bk[i].Abs < bk[j].Abs })
}
sort.Slice(fb.Skipped, func(i, j int) bool {
if fb.Skipped[i].Root != fb.Skipped[j].Root {
return fb.Skipped[i].Root < fb.Skipped[j].Root
}
return fb.Skipped[i].RelPath < fb.Skipped[j].RelPath
})
return fb
}
// tierKeeps applies the §2 tier column: mandatory everywhere, optional only for secondary, excluded
// never.
func tierKeeps(tier CaptureTier, class BindClass) bool {
switch class {
case ClassMandatory:
return true
case ClassOptional:
return tier == TierSecondary
default: // ClassExcluded (and any unexpected value) — never captured automatically
return false
}
}
// structuralGuard refuses a (root, relPath) that would capture an unsafe location. Evaluated after
// the tier filter. RelPath arrives path.Clean'd from the compose parser but is NOT traversal-checked
// 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, 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
}
if relPath == "backups" || strings.HasPrefix(relPath, "backups/") {
return reasonReserved, true
}
}
// RootUserdata + "" is allowed: resolves to <hddPath>/userdata, which does not nest backups/.
return "", false
}
// relPathEscapes reports whether relPath is absolute or contains a ".." path segment. Detection is
// SEGMENT-WISE on the slash-split path (".." as a whole component), so a legit dir literally named
// "a..b" passes.
func relPathEscapes(relPath string) bool {
if path.IsAbs(relPath) {
return true
}
for _, seg := range strings.Split(relPath, "/") {
if seg == ".." {
return true
}
}
return false
}
// resolveAbs maps a guarded (root, relPath) to its in-container absolute path via slash algebra.
//
// 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)
}
}
// strongerCapture picks the winner of an equal-Abs collision: mandatory beats optional; on equal
// class strength, the lexicographically-smaller (Root, RelPath) wins (determinism).
func strongerCapture(a, b CapturePath) CapturePath {
sa, sb := classStrength(a.Class), classStrength(b.Class)
if sa != sb {
if sa > sb {
return a
}
return b
}
if a.Root != b.Root {
if a.Root < b.Root {
return a
}
return b
}
if a.RelPath <= b.RelPath {
return a
}
return b
}
// classStrength ranks capture classes for the equal-Abs collapse (mandatory must never degrade).
func classStrength(c BindClass) int {
switch c {
case ClassMandatory:
return 2
case ClassOptional:
return 1
default:
return 0
}
}
// hasStrictAncestor reports whether some OTHER path in set is a strict directory ancestor of abs
// (abs == ancestor+"/"+…). Slash-aware prefix so "/x/paper" does not "contain" "/x/paperless".
func hasStrictAncestor(abs string, set []CapturePath) bool {
for _, o := range set {
if o.Abs == abs {
continue
}
if strings.HasPrefix(abs, o.Abs+"/") {
return true
}
}
return false
}
// Overlap is one absolute path claimed non-excluded by more than one app's capture set (§4.2). Apps
// is sorted.
type Overlap struct {
Abs string
Apps []string
}
// CrossAppOverlaps reports absolute paths that appear in ≥2 apps' Paths — the catalog-convention
// tripwire (§4: at most one app may class a host path non-excluded). Pure and advisory here; the
// WARN wiring lands in 3a/3b, not in 3-core. Match is EXACT-Abs only: cross-app CONTAINMENT
// (calibre-web's mandatory media/books sitting inside plex's excluded reader bind) is legitimate per
// §4 and must NOT report. Deterministic despite the map input: app names are scanned in sorted order
// and the output is sorted by Abs. Empty input / no overlap → empty (non-nil) slice.
func CrossAppOverlaps(sets map[string]CaptureSet) []Overlap {
apps := make([]string, 0, len(sets))
for app := range sets {
apps = append(apps, app)
}
sort.Strings(apps)
byAbs := make(map[string][]string)
for _, app := range apps {
seen := make(map[string]bool) // guard against an app listing the same Abs twice
for _, cp := range sets[app].Paths {
if seen[cp.Abs] {
continue
}
seen[cp.Abs] = true
byAbs[cp.Abs] = append(byAbs[cp.Abs], app)
}
}
out := make([]Overlap, 0)
for abs, owners := range byAbs {
if len(owners) < 2 {
continue
}
sorted := append([]string(nil), owners...)
sort.Strings(sorted)
out = append(out, Overlap{Abs: abs, Apps: sorted})
}
sort.Slice(out, func(i, j int) bool { return out[i].Abs < out[j].Abs })
return out
}