v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces

${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).

Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.

Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.

Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.

One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.

Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.

Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.

Tests 915 -> 949, all green. MinAgent unchanged.
This commit is contained in:
2026-07-26 08:12:57 +02:00
parent 3b672ba74c
commit 2958946517
57 changed files with 2228 additions and 151 deletions
+35 -13
View File
@@ -59,6 +59,8 @@ const (
reasonEscape = "path escapes the drive root"
reasonBareRoot = "bare drive-root bind would capture the backups tree"
reasonReserved = "path inside the reserved backups zone"
// reasonNoImportRoot: a ${IMPORT_PATH} bind with no resolvable system namespace root (R-75).
reasonNoImportRoot = "canonical import root unresolvable (system_data_path unconfigured)"
)
// ComputeCaptureSet resolves an app's classified binds into the tier-filtered absolute capture set.
@@ -71,16 +73,17 @@ const (
// so the engines' no-block branch stays byte-identical to today (the SQ5 cost-regression guard).
//
// Resolution: RootHDD → path.Join(hddPath, relPath); RootUserdata → path.Join(hddPath, "userdata",
// relPath). Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by
// this tier, refused for structural safety".
func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath string) CaptureSet {
// relPath); RootImport → path.Join(importRoot, relPath) — the SYSTEM drive, never hddPath (R-75).
// Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by this tier,
// refused for structural safety".
func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath, importRoot string) CaptureSet {
if !hasClassification {
return CaptureSet{HasClassification: false}
}
cs := CaptureSet{HasClassification: true}
// Stages 13: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) })
uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(c BindClass) bool { return tierKeeps(tier, c) })
cs.Skipped = skipped
// Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor).
@@ -107,18 +110,18 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
// collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply
// containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a
// mandatory child inside an excluded parent stays independently addressable).
func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
func resolveGuardCollapse(binds []ClassifiedBind, hddPath, importRoot string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
var resolved []CapturePath
for _, b := range binds {
if !keep(b.Class) {
continue
}
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
if reason, bad := structuralGuard(b.Root, b.RelPath, importRoot); bad {
skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason})
continue
}
resolved = append(resolved, CapturePath{
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
Abs: resolveAbs(hddPath, importRoot, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
})
}
byAbs := make(map[string]CapturePath, len(resolved))
@@ -153,12 +156,12 @@ type FabBuckets struct {
// selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet
// (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is
// Abs-sorted (deterministic).
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets {
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath, importRoot string) FabBuckets {
if !hasClassification {
return FabBuckets{HasClassification: false}
}
fb := FabBuckets{HasClassification: true}
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true })
uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(BindClass) bool { return true })
fb.Skipped = skipped
for _, cp := range uniq {
switch cp.Class {
@@ -201,10 +204,19 @@ func tierKeeps(tier CaptureTier, class BindClass) bool {
// there (ParseComposeClassifiableBinds path.Cleans; ValidateBackupSpec vets only SPEC entries), so an
// unlisted writable "${HDD_PATH}/../x" bind reaches here classed mandatory — this guard is
// load-bearing security, not defence-in-depth.
func structuralGuard(root BindRoot, relPath string) (reason string, bad bool) {
func structuralGuard(root BindRoot, relPath, importRoot string) (reason string, bad bool) {
if relPathEscapes(relPath) {
return reasonEscape, true
}
// RootImport (R-75) resolves against the SYSTEM drive, not hddPath. If that root is unresolvable
// (system_data_path unconfigured) the bind cannot be placed at all — refuse it LOUDLY into Skipped
// rather than let resolveAbs join onto "" and produce a relative, wrong-drive path. The other two
// roots cannot hit this: hddPath is checked by their own callers.
if root == RootImport && importRoot == "" {
return reasonNoImportRoot, true
}
// A bare ${IMPORT_PATH} bind is allowed: it resolves to <sysNS>/userdata/import, which nests no
// backups/ tree (backups live at <sysNS>/backups, a sibling of userdata).
if root == RootHDD {
if relPath == "" {
return reasonBareRoot, true // bare ${HDD_PATH} would nest <hddPath>/backups into the capture
@@ -233,11 +245,21 @@ func relPathEscapes(relPath string) bool {
}
// resolveAbs maps a guarded (root, relPath) to its in-container absolute path via slash algebra.
func resolveAbs(hddPath string, root BindRoot, relPath string) string {
if root == RootUserdata {
//
// RootImport is the one root that does NOT resolve against hddPath: the canonical drop-zone lives on
// the SYSTEM drive (R-75), so importRoot is supplied separately by the caller. Resolving it against
// hddPath would silently name a directory on the WRONG DRIVE — a .fab opt-in would then capture (or
// on restore, write) somewhere that merely looks plausible. An empty importRoot is the unresolvable
// case and is refused upstream by structuralGuard, never silently joined.
func resolveAbs(hddPath, importRoot string, root BindRoot, relPath string) string {
switch root {
case RootUserdata:
return path.Join(hddPath, "userdata", relPath)
case RootImport:
return path.Join(importRoot, relPath)
default:
return path.Join(hddPath, relPath)
}
return path.Join(hddPath, relPath)
}
// strongerCapture picks the winner of an equal-Abs collision: mandatory beats optional; on equal