94f44931dd
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
329 lines
12 KiB
Go
329 lines
12 KiB
Go
package appbackup
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestNoBareDrivePathToUserdataDir is the guard the NamespaceRootFor doc comment (paths.go) names (R-1017).
|
|
//
|
|
// THE COUNT: UserdataDir takes a felhom-data NAMESPACE ROOT. R-203 found five callers passing a bare DRIVE path
|
|
// (HDD_PATH, a StoragePath) instead — identical on an enrolled drive, one `felhom-data` segment short on the
|
|
// system-data fallback, so the app and its backup looked in different directories while the run said ok. This
|
|
// test reads every non-test Go file of the module and checks the argument of EVERY UserdataDir call. An argument
|
|
// is accepted only when its shape says "namespace root" by construction:
|
|
//
|
|
// - a call to the resolver: NamespaceRootFor / NamespaceRoot, or a provider method documented as returning
|
|
// the namespace root (GetStackNamespaceRoot, StackNamespaceRoot, namespaceRoot);
|
|
// - filepath.Clean of an accepted argument;
|
|
// - an identifier named nsRoot — the contract's own parameter name. LIMIT, stated: a variable named nsRoot
|
|
// that holds a drive path passes; the name is the declaration the reviewer reads, and a lie there is a
|
|
// louder one than a bare `hddPath`.
|
|
//
|
|
// Anything else is a bare drive path until shown otherwise (fail-safe: ambiguity refuses). A reference to
|
|
// UserdataDir that is not a direct call (a func value) cannot be checked and is refused too. Known exceptions
|
|
// live in bareDriveAllowed with their reason; the test fails if one disappears too, so the list stays exact.
|
|
//
|
|
// COMPANION RED-PROOFS (R-1017): (1) a temporary `appbackup.UserdataDir(hddPath)` added to stacks/deploy.go → FAIL,
|
|
// 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, 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.
|
|
for _, want := range []string{
|
|
"internal/stacks/deploy.go:withPathVars",
|
|
"internal/appexport/export.go",
|
|
"internal/web/handlers.go",
|
|
"internal/appbackup/userdata.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)
|
|
}
|
|
}
|
|
|
|
got := map[string]bool{}
|
|
for _, v := range violations {
|
|
key := v.file + ":" + v.fn
|
|
if _, ok := bareDriveAllowed[key]; ok {
|
|
got[key] = true
|
|
continue
|
|
}
|
|
t.Errorf("%s:%d (%s): UserdataDir(%s) — the argument is not a namespace root by construction. "+
|
|
"Put a drive path through appbackup.NamespaceRootFor(drive, systemDataPath) first (R-203).",
|
|
v.file, v.line, v.fn, v.arg)
|
|
}
|
|
for key, why := range bareDriveAllowed {
|
|
if !got[key] {
|
|
t.Errorf("allowed exception %s no longer passes a bare path — remove it from bareDriveAllowed (the count shrank; was: %s)", key, why)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 " +
|
|
"drive is never a registered StoragePath (R-203 changelog), so every such drive is an enrolled in-guest " +
|
|
"mount and IS its namespace root. Latent, not live — routing it needs the system-data path here.",
|
|
}
|
|
|
|
type bareCall struct {
|
|
file, fn, arg string
|
|
line int
|
|
}
|
|
|
|
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(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatal("go.mod not found above the test directory")
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
n := d.Name()
|
|
if p != root && (n == "vendor" || n == "testdata" || n == "node_modules" || strings.HasPrefix(n, ".")) {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
|
|
return nil
|
|
}
|
|
f, perr := parser.ParseFile(fset, p, nil, 0)
|
|
if perr != nil {
|
|
return perr
|
|
}
|
|
rel, _ := filepath.Rel(root, p)
|
|
rel = filepath.ToSlash(rel)
|
|
c, b := scanFile(fset, f, rel, helpers)
|
|
calls = append(calls, c...)
|
|
bad = append(bad, b...)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walking %s: %v", root, err)
|
|
}
|
|
sort.Strings(calls)
|
|
return calls, bad
|
|
}
|
|
|
|
// 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 {
|
|
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 && len(aliases) == 0 {
|
|
return nil, nil
|
|
}
|
|
isUserdataDir := func(e ast.Expr) bool {
|
|
switch x := e.(type) {
|
|
case *ast.Ident:
|
|
return inPkg && helpers[x.Name]
|
|
case *ast.SelectorExpr:
|
|
id, ok := x.X.(*ast.Ident)
|
|
return ok && aliases[id.Name] && helpers[x.Sel.Name]
|
|
}
|
|
return false
|
|
}
|
|
isResolver := func(fun ast.Expr) bool {
|
|
switch x := fun.(type) {
|
|
case *ast.Ident:
|
|
return inPkg && (x.Name == "NamespaceRootFor" || x.Name == "NamespaceRoot")
|
|
case *ast.SelectorExpr:
|
|
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", "AppNamespaceRoot":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
var nsRootArg func(e ast.Expr) bool
|
|
nsRootArg = func(e ast.Expr) bool {
|
|
switch x := e.(type) {
|
|
case *ast.ParenExpr:
|
|
return nsRootArg(x.X)
|
|
case *ast.Ident:
|
|
// 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 {
|
|
return nsRootArg(x.Args[0])
|
|
}
|
|
}
|
|
return isResolver(x.Fun)
|
|
}
|
|
return false
|
|
}
|
|
|
|
visit := func(fn string, body ast.Node) {
|
|
called := map[ast.Expr]bool{}
|
|
ast.Inspect(body, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok || !isUserdataDir(call.Fun) {
|
|
return true
|
|
}
|
|
called[call.Fun] = true
|
|
calls = append(calls, rel+":"+fn)
|
|
if len(call.Args) < 1 || !nsRootArg(call.Args[0]) {
|
|
arg := "?"
|
|
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})
|
|
}
|
|
return true
|
|
})
|
|
// A func-value reference (f := appbackup.UserdataDir) escapes the argument check — refuse it.
|
|
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) {
|
|
case *ast.FuncDecl:
|
|
if d.Body != nil {
|
|
visit(d.Name.Name, d.Body)
|
|
}
|
|
case *ast.GenDecl:
|
|
visit("<package-level>", d)
|
|
}
|
|
}
|
|
return calls, bad
|
|
}
|
|
|
|
func exprString(fset *token.FileSet, e ast.Expr) string {
|
|
start, end := fset.Position(e.Pos()), fset.Position(e.End())
|
|
b, err := os.ReadFile(start.Filename)
|
|
if err != nil || end.Offset > len(b) {
|
|
return "?"
|
|
}
|
|
return string(b[start.Offset:end.Offset])
|
|
}
|