package appbackup import ( "fmt" "path" "strings" ) // Backup classification (referential coupling) — Task 2 of the backup-classification-redesign arc // (felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md). This file is the SCHEMA // + PURE CLASSIFIER only; it is deliberately INERT — no backup tier consumes it yet. Task 3 (tier // policy engine) and Task 4 (manual .fab UI) are the consumers. Classes describe how a bind couples // to the app's referential state: // // - mandatory: COUPLED — restoring the app WITHOUT this bind yields a broken (not merely empty) // app, because the DB/state references the content (SQ3: immich DB-only restore = broken). // - optional: DECOUPLED-precious — absent ⇒ empty-not-broken, but the content is user-precious // (not re-downloadable): an external photo library, a curated comic/ROM set. // - excluded: DECOUPLED-bulk/transient — re-downloadable media, scraper caches, ingest inboxes, // transient export/download dirs; never shipped offsite, opt-in only for a manual .fab. // BindClass is the referential-coupling class of a single host bind. type BindClass string const ( ClassMandatory BindClass = "mandatory" // COUPLED: restore-without is broken, not empty (SQ3) ClassOptional BindClass = "optional" // DECOUPLED-precious: empty-not-broken, not re-downloadable ClassExcluded BindClass = "excluded" // DECOUPLED-bulk/transient: never offsite, .fab opt-in ) // BindRoot names the deploy-time variable a bind's host path is relative to. type BindRoot string const ( RootUserdata BindRoot = "userdata" // relative to ${USERDATA_PATH} RootHDD BindRoot = "hdd" // relative to ${HDD_PATH} // RootImport is relative to ${IMPORT_PATH} — the CANONICAL drop-zone root (R-75). Unlike the // other two it does NOT resolve against the app's own drive: it lives on the system drive's // namespace, so every app's ingest folder is in one place. Resolvers therefore need the import // root passed in separately; they cannot derive it from hddPath. RootImport BindRoot = "import" ) // BackupSpec is the .felhom.yml `backup:` block. Paths are forward-slash, relative, path.Clean'd. type BackupSpec struct { Userdata []BindSpec `yaml:"userdata,omitempty" json:"userdata,omitempty"` HDD []BindSpec `yaml:"hdd,omitempty" json:"hdd,omitempty"` // Import classifies ${IMPORT_PATH}-relative binds (R-75). An app whose ingest bind moved from // ${USERDATA_PATH}/import/ to ${IMPORT_PATH}/ MUST move its backup entry here in the // same change: ValidateBackupSpec rejects an entry matching no compose bind, and the rejection is // WHOLE-BLOCK, so a stale `userdata: import/` would discard the app's OTHER classifications // (e.g. an hdd appdata path classed mandatory) and silently degrade it to legacy. Import []BindSpec `yaml:"import,omitempty" json:"import,omitempty"` } // BindSpec is one classified entry in a BackupSpec. type BindSpec struct { Path string `yaml:"path" json:"path"` Class BindClass `yaml:"class" json:"class"` } // ComposeBind is a ${VAR}-relative host bind extracted from docker-compose.yml (Part 2 parser). It // lives in relative ${VAR} space (NOT resolved to an absolute path) and carries the :ro flag, both of // which the classifier needs — this is why the classifier does NOT reuse ParseComposeHDDMounts (which // resolves absolutes and drops the mode). type ComposeBind struct { Root BindRoot RelPath string // path.Clean'd, forward-slash, relative; "" for a bare-root bind (${VAR} itself) ReadOnly bool } // ClassOrigin records HOW a bind's class was decided — for logs/UI and to prove the precedence rule. type ClassOrigin string const ( OriginExplicit ClassOrigin = "explicit" // matched an entry in the backup block OriginDefaultWritable ClassOrigin = "default_writable" // unlisted + writable → mandatory (capture) OriginDefaultRO ClassOrigin = "default_ro" // unlisted + :ro → excluded (reader rule) OriginLegacy ClassOrigin = "legacy" // no backup block at all → no class semantics ) // ClassifiedBind pairs a compose bind with its resolved class + origin. type ClassifiedBind struct { ComposeBind Class BindClass Origin ClassOrigin } // validClass reports whether c is one of the three known classes (empty is INVALID — a typoed // `clas:` key makes yaml.v3 silently leave Class "", which must be rejected, not defaulted). func validClass(c BindClass) bool { switch c { case ClassMandatory, ClassOptional, ClassExcluded: return true default: return false } } // ValidateRelPath is THE path-safety refusal set for every ${VAR}-relative catalog path — the // `backup:` block and `data_paths:` both run through it, so there is exactly ONE definition of what // a safe relative path is. Refuses: empty, backslash, absolute, non-path.Clean'd, and any leading // ".." escape. It deliberately does NOT check "matches a compose bind" — that rule needs the bind // list and differs per caller (whole-block reject for backup:, per-entry for data_paths:). func ValidateRelPath(root BindRoot, p string) error { where := fmt.Sprintf("%s[%q]", root, p) if p == "" { return fmt.Errorf("%s: empty path", where) } if strings.ContainsRune(p, '\\') { return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where) } if path.IsAbs(p) { return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root) } if p != path.Clean(p) { return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(p)) } // path.Clean has run — ".." can only survive as a leading "../" segment. if p == ".." || strings.HasPrefix(p, "../") { return fmt.Errorf("%s: path escapes the root (..)", where) } return nil } // ValidRoot reports whether r is one of the three known bind roots. func ValidRoot(r BindRoot) bool { switch r { case RootUserdata, RootHDD, RootImport: return true default: return false } } // ValidateBackupSpec checks a parsed backup block against the app's actual compose binds and returns // the FIRST defect (whole-block semantics — the caller rejects the ENTIRE block on any error, so the // app degrades to legacy rather than partially classifying). A nil spec is vacuously valid (legacy). // // Rejects: unknown/empty class; empty path; a path that is not already path.Clean'd, or is absolute, // or contains "..", or contains a backslash; a duplicate (root, path); an entry whose (root, path) // matches NO compose bind (a typo/stale entry must not silently shift the real bind onto the // mandatory default). Match is exact (Root, RelPath) equality. func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error { if spec == nil { return nil } present := make(map[BindRoot]map[string]bool) for _, b := range binds { if present[b.Root] == nil { present[b.Root] = make(map[string]bool) } present[b.Root][b.RelPath] = true } seen := make(map[string]bool) // "\x00" check := func(root BindRoot, list []BindSpec) error { for _, e := range list { where := fmt.Sprintf("%s[%q]", root, e.Path) if !validClass(e.Class) { return fmt.Errorf("%s: invalid class %q (want mandatory|optional|excluded)", where, e.Class) } if err := ValidateRelPath(root, e.Path); err != nil { return err } key := string(root) + "\x00" + e.Path if seen[key] { return fmt.Errorf("%s: duplicate path in the backup block", where) } seen[key] = true if !present[root][e.Path] { return fmt.Errorf("%s: matches no compose bind (stale or typoed path)", where) } } return nil } if err := check(RootUserdata, spec.Userdata); err != nil { return err } if err := check(RootHDD, spec.HDD); err != nil { return err } return check(RootImport, spec.Import) } // ClassifyBinds resolves every compose bind to a class + origin, applying the two-level default. The // second return reports whether the app carries a backup block at all. // // - spec == nil → every bind is emitted with Origin=legacy and an EMPTY Class (no class semantics), // and hasClassification=false. This is the block-ABSENT branch: nothing downstream may change // behavior for it (SQ5 two-level default — no block means today's per-tier legacy behavior). // - spec present → an explicit block entry ALWAYS wins, regardless of the bind's :ro flag (an // explicit `optional` on immich's :ro external library beats the reader default). An UNLISTED // bind defaults by mode: writable → mandatory (default_writable — the C6B-F1 direction: capture // rather than silently drop), read-only → excluded (default_ro — reader rule, SQ2). // // Pure. Assumes a validated spec (see ValidateBackupSpec) but never panics on an unvalidated one: // unmatched/invalid spec entries simply don't match any bind here. // // A bare-root bind (RelPath "") can never be matched by an explicit entry — an empty path is invalid // in the spec — so it always falls to the ro/writable default. func ClassifyBinds(spec *BackupSpec, binds []ComposeBind) (classified []ClassifiedBind, hasClassification bool) { out := make([]ClassifiedBind, 0, len(binds)) if spec == nil { for _, b := range binds { out = append(out, ClassifiedBind{ComposeBind: b, Origin: OriginLegacy}) } return out, false } explicit := make(map[BindRoot]map[string]BindClass) add := func(root BindRoot, list []BindSpec) { for _, e := range list { if explicit[root] == nil { explicit[root] = make(map[string]BindClass) } explicit[root][e.Path] = e.Class } } add(RootUserdata, spec.Userdata) add(RootHDD, spec.HDD) add(RootImport, spec.Import) for _, b := range binds { cb := ClassifiedBind{ComposeBind: b} if cls, ok := explicit[b.Root][b.RelPath]; ok { cb.Class, cb.Origin = cls, OriginExplicit } else if b.ReadOnly { cb.Class, cb.Origin = ClassExcluded, OriginDefaultRO } else { cb.Class, cb.Origin = ClassMandatory, OriginDefaultWritable } out = append(out, cb) } return out, true }