0649f9a3e6
Task 2 of the backup-classification-redesign arc. Ships the referential-
coupling classification as DATA + PARSER + PURE CLASSIFIER, deliberately
inert — no backup tier changes behavior. Task 3 (tier policy engine) and
Task 4 (manual .fab UI) consume it.
- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
(delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
now so Task 3 consumes a tested seam.
INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
166 lines
7.7 KiB
Go
166 lines
7.7 KiB
Go
package appbackup
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// classOf finds the resolved class+origin for a (root, relpath) in a ClassifiedBind slice.
|
|
func classOf(cbs []ClassifiedBind, root BindRoot, rel string) (BindClass, ClassOrigin, bool) {
|
|
for _, c := range cbs {
|
|
if c.Root == root && c.RelPath == rel {
|
|
return c.Class, c.Origin, true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// --- Group A: classifier ---
|
|
|
|
// TestClassify_ImmichShape is Scenario A: explicit classes resolve, and an EXPLICIT entry beats the
|
|
// :ro reader-default (media/photos is :ro but ruled optional). Companion RP-2: making the ro-default
|
|
// override explicit entries forces media/photos to excluded and fails the optional assertion.
|
|
func TestClassify_ImmichShape(t *testing.T) {
|
|
binds := []ComposeBind{
|
|
{Root: RootHDD, RelPath: "appdata/immich", ReadOnly: false},
|
|
{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, // :ro external library
|
|
}
|
|
spec := &BackupSpec{
|
|
HDD: []BindSpec{{Path: "appdata/immich", Class: ClassMandatory}},
|
|
Userdata: []BindSpec{{Path: "media/photos", Class: ClassOptional}},
|
|
}
|
|
cbs, has := ClassifyBinds(spec, binds)
|
|
if !has {
|
|
t.Fatal("hasClassification should be true with a spec present")
|
|
}
|
|
if cls, org, ok := classOf(cbs, RootHDD, "appdata/immich"); !ok || cls != ClassMandatory || org != OriginExplicit {
|
|
t.Errorf("appdata/immich = %v/%v, want mandatory/explicit", cls, org)
|
|
}
|
|
// The crux: an explicit optional beats the :ro default_ro that would otherwise force excluded.
|
|
if cls, org, ok := classOf(cbs, RootUserdata, "media/photos"); !ok || cls != ClassOptional || org != OriginExplicit {
|
|
t.Errorf("media/photos (:ro, explicit optional) = %v/%v, want optional/explicit (explicit beats ro-default)", cls, org)
|
|
}
|
|
}
|
|
|
|
// TestClassify_TwoLevelDefault is Scenario B: with a block PRESENT, an unlisted writable bind
|
|
// defaults mandatory (capture, the C6B-F1 direction) and an unlisted :ro bind defaults excluded
|
|
// (reader rule). Companion RP-3: flipping the unlisted-writable default to excluded fails the
|
|
// mandatory assertion.
|
|
func TestClassify_TwoLevelDefault(t *testing.T) {
|
|
binds := []ComposeBind{
|
|
{Root: RootHDD, RelPath: "appdata/app", ReadOnly: false}, // listed
|
|
{Root: RootUserdata, RelPath: "data/extra", ReadOnly: false}, // UNLISTED writable
|
|
{Root: RootUserdata, RelPath: "media/ro", ReadOnly: true}, // UNLISTED :ro
|
|
}
|
|
spec := &BackupSpec{HDD: []BindSpec{{Path: "appdata/app", Class: ClassMandatory}}}
|
|
cbs, has := ClassifyBinds(spec, binds)
|
|
if !has {
|
|
t.Fatal("hasClassification should be true")
|
|
}
|
|
if cls, org, _ := classOf(cbs, RootUserdata, "data/extra"); cls != ClassMandatory || org != OriginDefaultWritable {
|
|
t.Errorf("unlisted writable = %v/%v, want mandatory/default_writable (capture direction)", cls, org)
|
|
}
|
|
if cls, org, _ := classOf(cbs, RootUserdata, "media/ro"); cls != ClassExcluded || org != OriginDefaultRO {
|
|
t.Errorf("unlisted :ro = %v/%v, want excluded/default_ro (reader rule)", cls, org)
|
|
}
|
|
}
|
|
|
|
// TestClassify_NilSpecLegacy is Scenario C: a nil spec → every bind is legacy with no class, and
|
|
// hasClassification=false. This is the inertness gate at the classifier level.
|
|
func TestClassify_NilSpecLegacy(t *testing.T) {
|
|
binds := []ComposeBind{{Root: RootUserdata, RelPath: "media", ReadOnly: true}}
|
|
cbs, has := ClassifyBinds(nil, binds)
|
|
if has {
|
|
t.Error("nil spec must report hasClassification=false")
|
|
}
|
|
if len(cbs) != 1 || cbs[0].Origin != OriginLegacy || cbs[0].Class != "" {
|
|
t.Errorf("nil-spec bind = %+v, want origin=legacy, empty class", cbs[0])
|
|
}
|
|
}
|
|
|
|
// TestClassify_BareRootFallsToDefault: a bare-root bind (RelPath "") can't be matched by any explicit
|
|
// entry (empty paths are invalid), so it falls to the ro/writable default.
|
|
func TestClassify_BareRootFallsToDefault(t *testing.T) {
|
|
spec := &BackupSpec{Userdata: []BindSpec{{Path: "media/x", Class: ClassOptional}}}
|
|
cbs, _ := ClassifyBinds(spec, []ComposeBind{
|
|
{Root: RootUserdata, RelPath: "", ReadOnly: false}, // bare ${USERDATA_PATH}
|
|
{Root: RootHDD, RelPath: "", ReadOnly: true}, // bare ${HDD_PATH} :ro
|
|
})
|
|
if cls, org, _ := classOf(cbs, RootUserdata, ""); cls != ClassMandatory || org != OriginDefaultWritable {
|
|
t.Errorf("bare writable root = %v/%v, want mandatory/default_writable", cls, org)
|
|
}
|
|
if cls, org, _ := classOf(cbs, RootHDD, ""); cls != ClassExcluded || org != OriginDefaultRO {
|
|
t.Errorf("bare :ro root = %v/%v, want excluded/default_ro", cls, org)
|
|
}
|
|
}
|
|
|
|
// TestClassify_SameRelPathBothRoots: userdata/x and hdd/x are DISTINCT binds — Root is part of
|
|
// identity, so an explicit hdd entry must not classify the userdata bind.
|
|
func TestClassify_SameRelPathBothRoots(t *testing.T) {
|
|
binds := []ComposeBind{
|
|
{Root: RootUserdata, RelPath: "shared", ReadOnly: false},
|
|
{Root: RootHDD, RelPath: "shared", ReadOnly: false},
|
|
}
|
|
spec := &BackupSpec{HDD: []BindSpec{{Path: "shared", Class: ClassExcluded}}}
|
|
cbs, _ := ClassifyBinds(spec, binds)
|
|
if cls, org, _ := classOf(cbs, RootHDD, "shared"); cls != ClassExcluded || org != OriginExplicit {
|
|
t.Errorf("hdd/shared = %v/%v, want excluded/explicit", cls, org)
|
|
}
|
|
if cls, org, _ := classOf(cbs, RootUserdata, "shared"); cls != ClassMandatory || org != OriginDefaultWritable {
|
|
t.Errorf("userdata/shared = %v/%v, want mandatory/default_writable (hdd entry must NOT match it)", cls, org)
|
|
}
|
|
}
|
|
|
|
// --- Group B: validation (Scenario D) — every defect rejects the WHOLE block; error names the entry ---
|
|
|
|
func TestValidateBackupSpec_Defects(t *testing.T) {
|
|
// The compose binds the valid entries reference (so only the seeded defect is the failure).
|
|
binds := []ComposeBind{
|
|
{Root: RootUserdata, RelPath: "media/tv"},
|
|
{Root: RootHDD, RelPath: "appdata/x"},
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
spec *BackupSpec
|
|
wantFrag string // substring the error must contain (the offending entry / rule)
|
|
}{
|
|
{"unknown class", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: "keepit"}}}, "invalid class"},
|
|
{"empty class (typoed key)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: ""}}}, "invalid class"},
|
|
{"empty path", &BackupSpec{HDD: []BindSpec{{Path: "", Class: ClassMandatory}}}, "empty path"},
|
|
{"absolute path", &BackupSpec{HDD: []BindSpec{{Path: "/etc/x", Class: ClassMandatory}}}, "absolute"},
|
|
{"dotdot path", &BackupSpec{HDD: []BindSpec{{Path: "../escape", Class: ClassMandatory}}}, "escapes"},
|
|
{"backslash path", &BackupSpec{HDD: []BindSpec{{Path: "appdata\\x", Class: ClassMandatory}}}, "backslash"},
|
|
{"non-clean path", &BackupSpec{HDD: []BindSpec{{Path: "appdata/./x", Class: ClassMandatory}}}, "non-clean"},
|
|
{"duplicate path", &BackupSpec{HDD: []BindSpec{
|
|
{Path: "appdata/x", Class: ClassMandatory}, {Path: "appdata/x", Class: ClassExcluded},
|
|
}}, "duplicate"},
|
|
{"no matching bind (typo)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tvv", Class: ClassExcluded}}}, "matches no compose bind"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := ValidateBackupSpec(tc.spec, binds)
|
|
if err == nil {
|
|
t.Fatalf("expected rejection, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantFrag) {
|
|
t.Errorf("error %q must contain %q", err.Error(), tc.wantFrag)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateBackupSpec_ValidAndNil: a clean block validates, and a nil spec is vacuously valid.
|
|
func TestValidateBackupSpec_ValidAndNil(t *testing.T) {
|
|
binds := []ComposeBind{{Root: RootHDD, RelPath: "appdata/x"}, {Root: RootUserdata, RelPath: "media/tv"}}
|
|
spec := &BackupSpec{
|
|
HDD: []BindSpec{{Path: "appdata/x", Class: ClassMandatory}},
|
|
Userdata: []BindSpec{{Path: "media/tv", Class: ClassExcluded}},
|
|
}
|
|
if err := ValidateBackupSpec(spec, binds); err != nil {
|
|
t.Errorf("clean block should validate: %v", err)
|
|
}
|
|
if err := ValidateBackupSpec(nil, binds); err != nil {
|
|
t.Errorf("nil spec must be vacuously valid: %v", err)
|
|
}
|
|
}
|