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 1–3: 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 /userdata/import, which nests no // backups/ tree (backups live at /backups, a sibling of userdata). if root == RootHDD { if relPath == "" { return reasonBareRoot, true // bare ${HDD_PATH} would nest /backups into the capture } if relPath == "backups" || strings.HasPrefix(relPath, "backups/") { return reasonReserved, true } } // RootUserdata + "" is allowed: resolves to /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 }