2958946517
${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.
357 lines
14 KiB
Go
357 lines
14 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"
|
||
// 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 <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
|
||
}
|