.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:
@@ -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