Files
felhom-controller/controller/internal/appbackup/captureset.go
T
admin 2668ac4da3 Capture-set computation (INERT; Task 3-core, v0.133.0)
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).
2026-07-14 21:53:05 +02:00

282 lines
10 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"
)
// 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). 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 {
if !hasClassification {
return CaptureSet{HasClassification: false}
}
cs := CaptureSet{HasClassification: true}
// Stage 1+2: tier filter, then structural guards. Survivors are resolved to CapturePath.
var resolved []CapturePath
for _, b := range binds {
if !tierKeeps(tier, b.Class) {
continue // excluded (any tier) or optional@offsite — silently filtered, not skipped
}
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
cs.Skipped = append(cs.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,
})
}
// Stage 3: equal-Abs collapse — one entry per Abs, mandatory beats optional (mandatory semantics
// must never degrade). Deterministic on ties: keep the lexicographically-smaller (Root, RelPath).
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)
}
// 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
}
// 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 string) (reason string, bad bool) {
if relPathEscapes(relPath) {
return reasonEscape, true
}
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.
func resolveAbs(hddPath string, root BindRoot, relPath string) string {
if root == RootUserdata {
return path.Join(hddPath, "userdata", relPath)
}
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
}