.fab exclusion scoping: classes in the manual export (Task 4, v0.136.0)
SQ6 over-capture FIXED for classified apps: the userdata root tar is exclude-scoped (keeps only ancestors/descendants of a SELECTED bind relpath — R1-C, the tier2Reconcile keep-rule); no selected userdata bind → no root tar (radarr state-only). New appbackup.ComputeFabBuckets (shared resolveGuardCollapse pipeline; class buckets; guards over ALL classes; no cross-bucket containment). appexport/fabplan.go: computeFabPlan + tarDirectoryExcluding + fabEstimateSplit. ExportRequest gains DeselectOptional/OptInExcluded (both start handlers — two-call-site); mandatory is a server-side floor. Manifest v1 + import UNTOUCHED; legacy apps byte-identical to v0.130.0. Estimate additive class split; export UI: locked-mandatory/optional-checkboxes/excluded-opt-in + two-number warning. All 6 §10 red-proofs verified. 6D Accept #1 now runs against this shape.
This commit is contained in:
@@ -79,37 +79,9 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Stages 1–3: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, 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 {
|
||||
@@ -130,6 +102,87 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
|
||||
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 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); bad {
|
||||
skipped = append(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,
|
||||
})
|
||||
}
|
||||
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 string) FabBuckets {
|
||||
if !hasClassification {
|
||||
return FabBuckets{HasClassification: false}
|
||||
}
|
||||
fb := FabBuckets{HasClassification: true}
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, 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 {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func bucketAbs(b []CapturePath) []string {
|
||||
out := make([]string, 0, len(b))
|
||||
for _, p := range b {
|
||||
out = append(out, p.Abs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// classified app → three buckets, resolved + Abs-sorted.
|
||||
func TestComputeFabBuckets_Classified(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/books"}, Class: ClassMandatory},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/comics"}, Class: ClassOptional},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/movies"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/app"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
if !fb.HasClassification {
|
||||
t.Fatal("HasClassification must be true")
|
||||
}
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{hdd("appdata/app"), udat("media/books")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Mandatory = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bucketAbs(fb.Optional), []string{udat("media/comics")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Optional = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bucketAbs(fb.Excluded), []string{udat("media/movies")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Excluded = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// legacy (no block) → empty buckets (the full-root capture stays out of the classified plan).
|
||||
func TestComputeFabBuckets_LegacyEmpty(t *testing.T) {
|
||||
binds := []ClassifiedBind{{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy}}
|
||||
fb := ComputeFabBuckets(binds, false, drv)
|
||||
if fb.HasClassification || fb.Mandatory != nil || fb.Optional != nil || fb.Excluded != nil {
|
||||
t.Errorf("legacy app must yield empty buckets, got %+v", fb)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E: structural guards run over ALL classes — a traversal path in an EXCLUDED bind is Skipped,
|
||||
// never plannable (opt-in or not).
|
||||
func TestComputeFabBuckets_GuardsAllClasses(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/ok"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
for _, b := range [][]CapturePath{fb.Mandatory, fb.Optional, fb.Excluded} {
|
||||
for _, p := range b {
|
||||
if p.RelPath == "../evil" {
|
||||
t.Fatal("a traversal path must never enter a bucket (guards run over all classes)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fb.Skipped) != 1 || fb.Skipped[0].RelPath != "../evil" {
|
||||
t.Errorf("traversal excluded path must be Skipped, got %+v", fb.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// no cross-bucket containment dedup: a mandatory CHILD inside an excluded PARENT both survive.
|
||||
func TestComputeFabBuckets_NoCrossBucketContainment(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/books"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{udat("media/books")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mandatory child must survive independently: Mandatory = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bucketAbs(fb.Excluded), []string{udat("media")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("excluded parent must survive: Excluded = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// equal-Abs collapse: two spellings of one path collapse, mandatory wins (into the mandatory bucket).
|
||||
func TestComputeFabBuckets_EqualAbsMandatoryWins(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{udat("media")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("collapsed path must land in Mandatory, got Mandatory=%v", got)
|
||||
}
|
||||
if len(fb.Optional) != 0 {
|
||||
t.Errorf("optional spelling must collapse away, got %v", bucketAbs(fb.Optional))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user