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
+21 -38
View File
@@ -513,49 +513,32 @@ func buildPathInfo(path string) HDDPath {
// ParseComposeUserdataMounts reads a docker-compose.yml and extracts the host bind-source paths that
// reference ${USERDATA_PATH} (resolved to userdataPath) — the dirs the deploy belt must pre-create
// with the userdata convention. Same scanner shape as ParseComposeHDDMounts.
// with the userdata convention.
//
// R-75: this is now a thin RESOLVER over ParseComposeClassifiableBinds, which is the ONE authoritative
// compose-bind scanner. The two used to be byte-for-byte duplicate scanners (SPIKE §3) differing only
// in what they threw away, so a fix to one silently skipped the other; the classifier won because it
// is the richer of the two (it keeps the root and the :ro flag, both of which this function discards
// but the classification and derivation paths need).
//
// ONE deliberate behaviour drop, recorded rather than hidden: the old textual
// strings.ReplaceAll("${USERDATA_PATH}", …) + containment check also accepted a bind written as a
// LITERAL absolute path that happened to fall under userdataPath. The classifier matches the ${VAR}
// reference only. No catalog template has ever used the literal form (verified across all 53 in
// SPIKE §2 — every host token is a ${VAR}, a named volume, or the docker socket), and such a compose
// would be pinned to one machine's drive layout, so the capability was dead.
func ParseComposeUserdataMounts(composePath, userdataPath string) []string {
if userdataPath == "" {
return nil
}
data, err := os.ReadFile(composePath)
if err != nil {
return nil
var out []string
for _, b := range ParseComposeClassifiableBinds(composePath) {
if b.Root != appbackup.RootUserdata {
continue
}
out = append(out, filepath.Join(userdataPath, filepath.FromSlash(b.RelPath)))
}
var mounts []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(data)))
inVolumes := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "volumes:") {
inVolumes = true
continue
}
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
inVolumes = false
}
if !inVolumes || !strings.HasPrefix(line, "- ") {
continue
}
mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
parts := strings.SplitN(mountStr, ":", 3)
if len(parts) < 2 {
continue
}
hostPath := strings.ReplaceAll(parts[0], "${USERDATA_PATH}", userdataPath)
cleanPath := filepath.Clean(hostPath)
cleanUD := filepath.Clean(userdataPath)
// must be userdataPath itself or a subpath (clean before check — traversal-safe)
if cleanPath != cleanUD && !strings.HasPrefix(cleanPath, cleanUD+string(filepath.Separator)) {
continue
}
if !seen[cleanPath] {
seen[cleanPath] = true
mounts = append(mounts, cleanPath)
}
}
return mounts
return out
}
// ExportDataMounts returns the host directories a .fab export must capture for an app: the