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
+70 -9
View File
@@ -115,6 +115,19 @@ func (s *Server) sharingResolveStorageRoot(raw string) (string, error) {
return "", errNotShareable
}
// ShareRow is one row of the shares table. It is a PACKAGE-LEVEL type, not a function-local struct,
// so the render test constructs the exact shape the handler passes: this template reads .System and
// .Available, and a field present in one and missing from the other is a render-time 500 that no
// handler test would catch (the template-gate class this project has hit four times).
type ShareRow struct {
Name string
Path string
ReadOnly bool
Offsite bool
System bool // controller-owned (R-75): no delete button, and the handler refuses it anyway
Available bool
}
// sharingPageData assembles the „Megosztás" page state.
func (s *Server) sharingPageData() map[string]interface{} {
data := s.settingsBaseData("sharing", "Hálózati megosztás")
@@ -134,18 +147,12 @@ func (s *Server) sharingPageData() map[string]interface{} {
data["SMBDirectAddress"] = s.sambaLANAddress()
}
type shareRow struct {
Name string
Path string
ReadOnly bool
Offsite bool
Available bool
}
var rows []shareRow
var rows []ShareRow
for _, sh := range s.settings.GetSMBShares() {
fi, err := os.Stat(sh.Path)
rows = append(rows, shareRow{
rows = append(rows, ShareRow{
Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite,
System: sh.System,
Available: err == nil && fi.IsDir(),
})
}
@@ -272,6 +279,14 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
return
}
// R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling
// sharing is the customer's decision (it puts SMB on the household LAN and demands a household
// password); deploying a drop-zone app must not trigger it. "Mandatory" here means "always present
// once sharing is on", not "turns sharing on".
if err := s.ensureImportShare(); err != nil {
s.logger.Printf("[WARN] [sharing] could not ensure the import share: %v", err)
}
// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
// regardless of whether anything actually came up.
@@ -411,6 +426,15 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
// SERVER-SIDE refusal for controller-owned shares (R-75), BEFORE any mutation. The template also
// omits the button; both are required and they prove different things — a render gate is not
// enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson).
for _, sh := range s.settings.GetSMBShares() {
if strings.EqualFold(sh.Name, name) && sh.System {
sharingRedirect(w, r, "Ez a megosztás a rendszer része, nem törölhető.")
return
}
}
if err := s.settings.RemoveSMBShare(name); err != nil {
sharingRedirect(w, r, err.Error())
return
@@ -524,3 +548,40 @@ func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) {
fmt.Fprintf(w, `{"error":"encode"}`)
}
}
// ensureImportShare creates the controller-owned drop-zone share (R-75) if it is not already there.
// Idempotent, and a no-op when the import root is unresolvable.
//
// It writes to the store DIRECTLY rather than going through sharingResolvePath: that guard validates
// paths a CUSTOMER supplied through the picker, and refuses anything outside a registered storage
// root. The system drive is deliberately not registered (registering it would make a 50 GB volume
// holding the recovery units a customer-visible drive, a deploy target and a wipe candidate), so the
// guard would refuse this path — correctly, for customer input. A controller-generated constant is a
// different trust class.
//
// Offsite is FALSE: the drop-zone is class `excluded` data, and shipping an inbox offsite would
// contradict the class the backup engines already act on.
func (s *Server) ensureImportShare() error {
if s.stackMgr == nil {
return nil
}
root := s.stackMgr.GetImportRoot()
if root == "" {
return nil
}
for _, sh := range s.settings.GetSMBShares() {
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
return nil // already present
}
}
if err := s.stackMgr.EnsureImportRoot(); err != nil {
return err
}
return s.settings.AddSMBShare(settings.SMBShare{
Name: settings.SystemImportShareName,
Path: root,
ReadOnly: false,
Offsite: false,
System: true,
})
}