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 (42 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; 42 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 .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: "", 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("", 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]) }