Files
felhom-controller/controller/internal/appbackup/userdata_bare_drive_guard_test.go
T

255 lines
8.3 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)
// 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)
}
}
}
// 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"
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 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) {
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)
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 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 := ""
for _, imp := range f.Imports {
if path, _ := strconv.Unquote(imp.Path.Value); path == appbackupImport {
alias = "appbackup"
if imp.Name != nil {
alias = imp.Name.Name
}
}
}
if !inPkg && alias == "" {
return nil, nil
}
isUserdataDir := func(e ast.Expr) bool {
switch x := e.(type) {
case *ast.Ident:
return inPkg && x.Name == "UserdataDir"
case *ast.SelectorExpr:
id, ok := x.X.(*ast.Ident)
return ok && alias != "" && id.Name == alias && x.Sel.Name == "UserdataDir"
}
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 && alias != "" && id.Name == alias {
return x.Sel.Name == "NamespaceRootFor" || x.Sel.Name == "NamespaceRoot"
}
switch x.Sel.Name {
case "GetStackNamespaceRoot", "StackNamespaceRoot", "namespaceRoot":
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:
return x.Name == "nsRoot"
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.
ast.Inspect(body, 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})
}
return true
})
}
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])
}