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).
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"path"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// absList extracts the sorted Abs slice from a CaptureSet's Paths (Paths is already Abs-sorted).
|
||||
func absList(cs CaptureSet) []string {
|
||||
out := make([]string, 0, len(cs.Paths))
|
||||
for _, p := range cs.Paths {
|
||||
out = append(out, p.Abs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// classOfAbs finds the resolved class for an Abs in a CaptureSet (empty if absent).
|
||||
func classOfAbs(cs CaptureSet, abs string) BindClass {
|
||||
for _, p := range cs.Paths {
|
||||
if p.Abs == abs {
|
||||
return p.Class
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const drv = "/mnt/drv"
|
||||
|
||||
func hdd(p string) string { return path.Join(drv, p) }
|
||||
func udat(p string) string { return path.Join(drv, "userdata", p) }
|
||||
|
||||
// --- Group A (Scenario A): classified per-tier split, immich shape ---
|
||||
|
||||
func TestComputeCaptureSet_PerTierSplit(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/immich"}, Class: ClassMandatory, Origin: OriginExplicit},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, Class: ClassOptional, Origin: OriginExplicit},
|
||||
}
|
||||
|
||||
off := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
||||
if got, want := absList(off), []string{hdd("appdata/immich")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("offsite Paths = %v, want %v (mandatory only — the :ro optional must NOT ship offsite)", got, want)
|
||||
}
|
||||
|
||||
sec := ComputeCaptureSet(binds, true, TierSecondary, drv)
|
||||
want := []string{hdd("appdata/immich"), udat("media/photos")}
|
||||
if got := absList(sec); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("secondary Paths = %v, want %v (sorted)", got, want)
|
||||
}
|
||||
// each CapturePath carries its originating identity
|
||||
for _, p := range sec.Paths {
|
||||
switch p.Abs {
|
||||
case hdd("appdata/immich"):
|
||||
if p.Root != RootHDD || p.RelPath != "appdata/immich" || p.Class != ClassMandatory {
|
||||
t.Errorf("immich CapturePath identity = %+v", p)
|
||||
}
|
||||
case udat("media/photos"):
|
||||
if p.Root != RootUserdata || p.RelPath != "media/photos" || p.Class != ClassOptional {
|
||||
t.Errorf("photos CapturePath identity = %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group B (Scenario B): legacy inertness — THE single most important test (SQ5 guard) ---
|
||||
|
||||
func TestComputeCaptureSet_LegacyInert(t *testing.T) {
|
||||
// A legacy app still has binds (the parser returns them), but with no class and origin=legacy.
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/sonarr"}, Origin: OriginLegacy},
|
||||
}
|
||||
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
|
||||
cs := ComputeCaptureSet(binds, false, tier, drv)
|
||||
if cs.HasClassification {
|
||||
t.Errorf("%s: HasClassification=true for a legacy app", tier)
|
||||
}
|
||||
if cs.Paths != nil {
|
||||
t.Errorf("%s: legacy app resolved Paths=%v — MUST be nil (unmigrated-sonarr-ships-its-TV-library regression)", tier, cs.Paths)
|
||||
}
|
||||
if cs.Skipped != nil {
|
||||
t.Errorf("%s: legacy app Skipped=%v — MUST be nil", tier, cs.Skipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group C (Scenario C): excluded is invisible — not in Paths, not in Skipped ---
|
||||
|
||||
func TestComputeCaptureSet_ExcludedInvisible(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/media"}, Class: ClassMandatory, Origin: OriginExplicit},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/export"}, Class: ClassExcluded, Origin: OriginExplicit},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "import/paperless"}, Class: ClassExcluded, Origin: OriginExplicit},
|
||||
}
|
||||
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
|
||||
cs := ComputeCaptureSet(binds, true, tier, drv)
|
||||
if got, want := absList(cs), []string{hdd("appdata/paperless/media")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s Paths = %v, want %v (excluded filtered)", tier, got, want)
|
||||
}
|
||||
if len(cs.Skipped) != 0 {
|
||||
t.Errorf("%s: excluded binds must NOT appear in Skipped, got %v", tier, cs.Skipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group D (Scenario D): structural guards + allowed bare-userdata + legit a..b name ---
|
||||
|
||||
func TestComputeCaptureSet_StructuralGuards(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d1 traversal
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d2 bare hdd root
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d4 bare userdata — ALLOWED
|
||||
}
|
||||
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
||||
|
||||
// Paths: ONLY d4's userdata root — no escaped root, no backups/ anywhere.
|
||||
if got, want := absList(cs), []string{udat("")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Paths = %v, want %v (only the allowed bare-userdata)", got, want)
|
||||
}
|
||||
for _, p := range cs.Paths {
|
||||
if p.Abs == "/mnt/evil" {
|
||||
t.Fatal("escaped-root Abs present in Paths — traversal guard failed")
|
||||
}
|
||||
if containsSeg(p.Abs, "backups") {
|
||||
t.Fatalf("reserved backups/ path present in Paths: %s", p.Abs)
|
||||
}
|
||||
}
|
||||
|
||||
// Skipped: d1,d2,d3 each with a DISTINCT reason naming its rule.
|
||||
reasons := map[string]string{} // "<root>/<rel>" -> reason
|
||||
for _, s := range cs.Skipped {
|
||||
reasons[string(s.Root)+"/"+s.RelPath] = s.Reason
|
||||
}
|
||||
if len(cs.Skipped) != 3 {
|
||||
t.Fatalf("want 3 skipped, got %d: %+v", len(cs.Skipped), cs.Skipped)
|
||||
}
|
||||
if reasons["hdd/../evil"] != reasonEscape {
|
||||
t.Errorf("../evil reason = %q, want %q", reasons["hdd/../evil"], reasonEscape)
|
||||
}
|
||||
if reasons["hdd/"] != reasonBareRoot {
|
||||
t.Errorf("bare-hdd reason = %q, want %q", reasons["hdd/"], reasonBareRoot)
|
||||
}
|
||||
if reasons["hdd/backups/primary/x"] != reasonReserved {
|
||||
t.Errorf("backups reason = %q, want %q", reasons["hdd/backups/primary/x"], reasonReserved)
|
||||
}
|
||||
// distinctness
|
||||
if reasonEscape == reasonBareRoot || reasonBareRoot == reasonReserved || reasonEscape == reasonReserved {
|
||||
t.Error("guard reasons are not distinct")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeCaptureSet_LegitDotDotName: a component literally named "a..b" is NOT traversal.
|
||||
func TestComputeCaptureSet_LegitDotDotName(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/a..b"}, Class: ClassMandatory, Origin: OriginExplicit},
|
||||
}
|
||||
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
||||
if got, want := absList(cs), []string{hdd("appdata/a..b")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Paths = %v, want %v (a..b is a legit name, not traversal)", got, want)
|
||||
}
|
||||
if len(cs.Skipped) != 0 {
|
||||
t.Errorf("a..b must not be skipped, got %v", cs.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group E (Scenario E): containment dedup + equal-Abs mandatory-wins + determinism ---
|
||||
|
||||
func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless"}, Class: ClassMandatory, Origin: OriginExplicit},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/media"}, Class: ClassMandatory, Origin: OriginExplicit}, // descendant
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional, Origin: OriginExplicit}, // Abs collides with next
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory, Origin: OriginExplicit}, // same Abs, mandatory
|
||||
}
|
||||
cs := ComputeCaptureSet(binds, true, TierSecondary, drv)
|
||||
|
||||
want := []string{hdd("appdata/paperless"), udat("media")}
|
||||
if got := absList(cs); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Paths = %v, want %v (descendant dropped; two spellings collapsed)", got, want)
|
||||
}
|
||||
// mandatory beats optional on the equal-Abs collision
|
||||
if c := classOfAbs(cs, udat("media")); c != ClassMandatory {
|
||||
t.Errorf("collapsed /userdata/media class = %q, want mandatory (mandatory must never degrade)", c)
|
||||
}
|
||||
|
||||
// determinism: recompute and compare full struct
|
||||
cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv)
|
||||
if !reflect.DeepEqual(cs, cs2) {
|
||||
t.Error("ComputeCaptureSet is non-deterministic across runs")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group F (Scenario F): cross-app overlap advisory (pure) ---
|
||||
|
||||
func TestCrossAppOverlaps(t *testing.T) {
|
||||
X, Y, Z := "/mnt/drv/x", "/mnt/drv/y", "/mnt/drv/z"
|
||||
sets := map[string]CaptureSet{
|
||||
"appA": {HasClassification: true, Paths: []CapturePath{{Abs: X}, {Abs: Y}}},
|
||||
"appB": {HasClassification: true, Paths: []CapturePath{{Abs: Y}}},
|
||||
"appC": {HasClassification: true, Paths: []CapturePath{{Abs: Z}}},
|
||||
}
|
||||
got := CrossAppOverlaps(sets)
|
||||
want := []Overlap{{Abs: Y, Apps: []string{"appA", "appB"}}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("CrossAppOverlaps = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// exact-match only: cross-app CONTAINMENT is legitimate, must NOT report.
|
||||
cont := map[string]CaptureSet{
|
||||
"plex": {Paths: []CapturePath{{Abs: "/mnt/drv/userdata/media"}}},
|
||||
"calibre-web": {Paths: []CapturePath{{Abs: "/mnt/drv/userdata/media/books"}}},
|
||||
}
|
||||
if got := CrossAppOverlaps(cont); len(got) != 0 {
|
||||
t.Errorf("containment across apps must NOT report an overlap, got %+v", got)
|
||||
}
|
||||
|
||||
// empty input → empty (non-nil) slice, not a flaky nil
|
||||
if got := CrossAppOverlaps(map[string]CaptureSet{}); got == nil || len(got) != 0 {
|
||||
t.Errorf("empty input → empty non-nil slice, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// containsSeg reports whether abs has seg as a path component (test helper).
|
||||
func containsSeg(abs, seg string) bool {
|
||||
for _, s := range splitSlash(abs) {
|
||||
if s == seg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitSlash(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, r := range s {
|
||||
if r == '/' {
|
||||
out = append(out, cur)
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(r)
|
||||
}
|
||||
return append(out, cur)
|
||||
}
|
||||
Reference in New Issue
Block a user