cf9ce01917
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.
97 lines
4.1 KiB
Go
97 lines
4.1 KiB
Go
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))
|
|
}
|
|
}
|