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:
2026-07-14 21:53:05 +02:00
parent 95f3180ab4
commit 2668ac4da3
7 changed files with 734 additions and 104 deletions
+281
View File
@@ -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)
}
@@ -0,0 +1,68 @@
package stacks
import (
"path"
"reflect"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// absSet returns the sorted Abs slice of a CaptureSet's Paths (helper for exact assertions).
func absSet(cs appbackup.CaptureSet) []string {
out := make([]string, 0, len(cs.Paths))
for _, p := range cs.Paths {
out = append(out, p.Abs)
}
return out
}
// TestCaptureSet_Wiring (Scenario G, F-S3 no-seam): a real .felhom.yml backup block + real compose,
// loaded through a REAL stacks.Manager, must produce the exact per-tier capture sets end-to-end —
// ClassifiedBinds → ComputeCaptureSet — with no fake between the parser, validator, classifier, and
// the capture computation (the wiring is where a class-constant typo would otherwise hide).
func TestCaptureSet_Wiring(t *testing.T) {
felhom := `display_name: Wiretest
backup:
userdata:
- path: media/photos
class: optional
`
// appdata/wiretest is UNLISTED + writable → default mandatory; media/photos is :ro but EXPLICIT
// optional (explicit beats the :ro reader-default).
compose := `services:
wiretest:
volumes:
- ${HDD_PATH}/appdata/wiretest:/data
- ${USERDATA_PATH}/media/photos:/external/photos:ro
`
m := newClassifyManager(t, felhom, compose)
cbs, has := m.ClassifiedBinds("app")
if !has {
t.Fatal("valid block must classify end-to-end (hasClassification=true)")
}
const hddPath = "/mnt/drv"
wantWiretest := path.Join(hddPath, "appdata/wiretest")
wantPhotos := path.Join(hddPath, "userdata", "media/photos")
// Secondary: mandatory + optional, sorted by Abs.
sec := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierSecondary, hddPath)
if got, want := absSet(sec), []string{wantWiretest, wantPhotos}; !reflect.DeepEqual(got, want) {
t.Errorf("secondary Paths = %v, want %v", got, want)
}
// Offsite: mandatory only — the explicit-optional :ro library must NOT appear.
off := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierOffsite, hddPath)
if got, want := absSet(off), []string{wantWiretest}; !reflect.DeepEqual(got, want) {
t.Errorf("offsite Paths = %v, want %v (optional :ro must not ship offsite)", got, want)
}
// classes ride through end-to-end
for _, p := range sec.Paths {
if p.Abs == wantPhotos && p.Class != appbackup.ClassOptional {
t.Errorf("media/photos class = %q, want optional (explicit beats :ro default, through the real Manager)", p.Class)
}
if p.Abs == wantWiretest && p.Class != appbackup.ClassMandatory {
t.Errorf("appdata/wiretest class = %q, want mandatory (unlisted writable default)", p.Class)
}
}
}