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{} // "/" -> 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) }