v0.325.0: share + escrow limiters reserve first; family sessions as fingerprints; samba renames apply; path guard widened; gates in a worktree
gates / gates (push) Successful in 45s

R-1111 reserveAttempt is the one limiter body (share password, escrow re-auth, dashboard sign-in). R-1112
family.json holds fingerprints; old files are converted at load. R-1113 the household's pass reuses its
family session. R-1109 the samba compose carries the smb.conf hash. R-1110 TestNoBareDrivePathToPathHelpers.
R-1114 shared_scripts.py resolves the felhom.eu sibling from a worktree. Each red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 09:36:34 +02:00
parent 7d41c2973c
commit 94f44931dd
28 changed files with 945 additions and 190 deletions
@@ -36,7 +36,7 @@ import (
// naming the file and function; (2) userdataKept (stacks/delete.go) reverted to the bare hddPath → FAIL.
func TestNoBareDrivePathToUserdataDir(t *testing.T) {
root := moduleRoot(t)
calls, violations := scanUserdataDirCalls(t, root)
calls, violations := scanUserdataDirCalls(t, root, map[string]bool{"UserdataDir": true})
// Positive control (rule 3: an absent finding is not evidence): the scan MUST reach the known callers,
// or a scope bug (wrong root, one-level walk) would turn this test green on an empty census.
@@ -76,6 +76,57 @@ func TestNoBareDrivePathToUserdataDir(t *testing.T) {
}
}
// TestNoBareDrivePathToPathHelpers is the same guard for UserdataDir's three siblings (R-1110): RecoveryUnitPath,
// AppDataDir and PrimaryBackupPath take a NAMESPACE ROOT too (the NamespaceRootFor contract names all four), and
// package backup re-exports them, so both packages' calls are read. Same acceptance rule as above.
//
// THE CENSUS on 2026-10-17 (41 calls): every one passed a namespace root, but two only by a comment —
// stacks/delete.go GetStackBackupData's parameter was named drivePath (its one caller passes AppNamespaceRoot),
// and web/handlers.go appDetailsForPath passed a registered StoragePath, which is its namespace root only while
// the system drive is never a StoragePath. The first was renamed nsRoot; the second now goes through
// appbackup.NamespaceRootFor (same path on an enrolled drive, correct on the fallback). The loop variables of
// removed_units.go, offbox_placement.go and offbox.go (root, nr — each built by namespaceRoot) were renamed nsRoot.
//
// COMPANION RED-PROOFS (R-1110): (1) appDetailsForPath back to backup.AppDataDir(storagePath, name) → FAIL naming
// internal/web/handlers.go:appDetailsForPath; (2) GetStackBackupData's parameter back to drivePath → FAIL naming
// internal/stacks/delete.go; (3) an unqualified RecoveryUnitPath(drivePath, …) inside package backup → FAIL.
func TestNoBareDrivePathToPathHelpers(t *testing.T) {
root := moduleRoot(t)
calls, violations := scanUserdataDirCalls(t, root, map[string]bool{
"RecoveryUnitPath": true, "AppDataDir": true, "PrimaryBackupPath": true,
})
// Positive control: the census must reach callers in all four places the helpers are used from — an
// appbackup-qualified call (stacks), an unqualified call inside package backup, a backup-qualified call (web,
// api) — or a scope bug would turn this green on an empty census.
for _, want := range []string{
"internal/stacks/migrate.go",
"internal/stacks/delete.go:GetStackBackupData",
"internal/backup/removed_units.go:ListRemovedAppUnits",
"internal/backup/tier2_capture.go",
"internal/web/handlers.go:appDetailsForPath",
"internal/api/router.go",
} {
found := false
for _, c := range calls {
if strings.HasPrefix(c, want) {
found = true
break
}
}
if !found {
t.Errorf("the census did not reach %s — the scan is blind (calls seen: %v)", want, calls)
}
}
if len(calls) < 30 {
t.Errorf("the census found %d calls; 41 on 2026-10-17 — a shrink this large is a scope bug, not a cleanup", len(calls))
}
for _, v := range violations {
t.Errorf("%s:%d (%s): %s — the first argument is not a namespace root by construction. "+
"Put a drive path through appbackup.NamespaceRootFor(drive, systemDataPath) first (R-203, R-1110).",
v.file, v.line, v.fn, v.arg)
}
}
// bareDriveAllowed is the count. Keyed "relative/file.go:Function" (not a line number, which drifts).
var bareDriveAllowed = map[string]string{
"internal/stacks/media_library.go:mediaBindSource": "drive comes from GetStoragePaths (MediaDrives); the system " +
@@ -90,6 +141,10 @@ type bareCall struct {
const appbackupImport = "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
// backupImport: package backup re-exports RecoveryUnitPath / AppDataDir / PrimaryBackupPath as one-line bridges
// (appbackup_bridge.go), so a bare drive path can reach the same helper through either package (R-1110).
const backupImport = "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
func moduleRoot(t *testing.T) string {
t.Helper()
dir, err := filepath.Abs(".")
@@ -108,9 +163,10 @@ func moduleRoot(t *testing.T) string {
}
}
// scanUserdataDirCalls returns every UserdataDir call site ("file:Func") and those whose argument is not a
// namespace root by construction. Recursive walk (os.walk, not one level — the R-421 scope trap).
func scanUserdataDirCalls(t *testing.T, root string) (calls []string, bad []bareCall) {
// scanUserdataDirCalls returns every call site ("file:Func") of the named helpers (UserdataDir, or the R-1110
// siblings) and those whose FIRST argument is not a namespace root by construction. Recursive walk (os.walk, not
// one level — the R-421 scope trap).
func scanUserdataDirCalls(t *testing.T, root string, helpers map[string]bool) (calls []string, bad []bareCall) {
t.Helper()
fset := token.NewFileSet()
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
@@ -133,7 +189,7 @@ func scanUserdataDirCalls(t *testing.T, root string) (calls []string, bad []bare
}
rel, _ := filepath.Rel(root, p)
rel = filepath.ToSlash(rel)
c, b := scanFile(fset, f, rel)
c, b := scanFile(fset, f, rel, helpers)
calls = append(calls, c...)
bad = append(bad, b...)
return nil
@@ -145,29 +201,34 @@ func scanUserdataDirCalls(t *testing.T, root string) (calls []string, bad []bare
return calls, bad
}
// scanFile finds the UserdataDir references of one file. Inside package appbackup the call is unqualified;
// elsewhere it is <alias>.UserdataDir for the appbackup import (alias honoured).
func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad []bareCall) {
inPkg := f.Name.Name == "appbackup"
alias := ""
// scanFile finds the references to the named helpers in one file. Inside package appbackup (and, for the bridged
// siblings, package backup) the call is unqualified; elsewhere it is <alias>.Helper for either import (alias
// honoured).
func scanFile(fset *token.FileSet, f *ast.File, rel string, helpers map[string]bool) (calls []string, bad []bareCall) {
inPkg := (f.Name.Name == "appbackup" && strings.HasPrefix(rel, "internal/appbackup/")) ||
(f.Name.Name == "backup" && strings.HasPrefix(rel, "internal/backup/"))
aliases := map[string]bool{}
for _, imp := range f.Imports {
if path, _ := strconv.Unquote(imp.Path.Value); path == appbackupImport {
alias = "appbackup"
if imp.Name != nil {
alias = imp.Name.Name
}
path, _ := strconv.Unquote(imp.Path.Value)
if path != appbackupImport && path != backupImport {
continue
}
name := filepath.Base(path)
if imp.Name != nil {
name = imp.Name.Name
}
aliases[name] = true
}
if !inPkg && alias == "" {
if !inPkg && len(aliases) == 0 {
return nil, nil
}
isUserdataDir := func(e ast.Expr) bool {
switch x := e.(type) {
case *ast.Ident:
return inPkg && x.Name == "UserdataDir"
return inPkg && helpers[x.Name]
case *ast.SelectorExpr:
id, ok := x.X.(*ast.Ident)
return ok && alias != "" && id.Name == alias && x.Sel.Name == "UserdataDir"
return ok && aliases[id.Name] && helpers[x.Sel.Name]
}
return false
}
@@ -176,11 +237,11 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad
case *ast.Ident:
return inPkg && (x.Name == "NamespaceRootFor" || x.Name == "NamespaceRoot")
case *ast.SelectorExpr:
if id, ok := x.X.(*ast.Ident); ok && alias != "" && id.Name == alias {
if id, ok := x.X.(*ast.Ident); ok && aliases[id.Name] {
return x.Sel.Name == "NamespaceRootFor" || x.Sel.Name == "NamespaceRoot"
}
switch x.Sel.Name {
case "GetStackNamespaceRoot", "StackNamespaceRoot", "namespaceRoot":
case "GetStackNamespaceRoot", "StackNamespaceRoot", "namespaceRoot", "AppNamespaceRoot":
return true
}
}
@@ -192,7 +253,12 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad
case *ast.ParenExpr:
return nsRootArg(x.X)
case *ast.Ident:
return x.Name == "nsRoot"
// nsRoot, or a qualified one (sourceNsRoot, targetNsRoot) — R-1110 widened the name rule; same LIMIT.
return x.Name == "nsRoot" || strings.HasSuffix(x.Name, "NsRoot")
case *ast.SelectorExpr:
// The migration job's two RESOLVED roots (stacks/migrate.go: `json:"source_ns"` — „resolved felhom-data
// namespace root", set only from appbackup.NamespaceRoot). A field, so the name is the declaration.
return x.Sel.Name == "SourceNS" || x.Sel.Name == "TargetNS"
case *ast.CallExpr:
if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "filepath" && sel.Sel.Name == "Clean" && len(x.Args) == 1 {
@@ -213,9 +279,9 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad
}
called[call.Fun] = true
calls = append(calls, rel+":"+fn)
if len(call.Args) != 1 || !nsRootArg(call.Args[0]) {
if len(call.Args) < 1 || !nsRootArg(call.Args[0]) {
arg := "?"
if len(call.Args) == 1 {
if len(call.Args) >= 1 {
arg = exprString(fset, call.Args[0])
}
bad = append(bad, bareCall{file: rel, fn: fn, arg: arg, line: fset.Position(call.Pos()).Line})
@@ -223,13 +289,21 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad
return true
})
// A func-value reference (f := appbackup.UserdataDir) escapes the argument check — refuse it.
ast.Inspect(body, func(n ast.Node) bool {
var refs func(n ast.Node) bool
refs = func(n ast.Node) bool {
e, ok := n.(ast.Expr)
if ok && isUserdataDir(e) && !called[e] {
bad = append(bad, bareCall{file: rel, fn: fn, arg: "<not a direct call>", line: fset.Position(e.Pos()).Line})
}
// The Sel of pkg.Helper is an Ident with the helper's name; inside package backup it would read as an
// unqualified reference to backup's own bridge. Judge the selector as a whole, then only its X.
if sel, ok := n.(*ast.SelectorExpr); ok {
ast.Inspect(sel.X, refs)
return false
}
return true
})
}
ast.Inspect(body, refs)
}
for _, decl := range f.Decls {
switch d := decl.(type) {