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).
282 lines
10 KiB
Go
282 lines
10 KiB
Go
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
|
||
}
|