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)) } }