package stacks import ( "go/ast" "go/parser" "go/token" "path/filepath" "sort" "strings" "testing" ) // TestAllowList_IsWiredIntoEveryComposeWriter (R-971) — the catalog allow-list is only a control if EVERY path that // writes an app's compose asks it first. Walked as an AST (comments dropped, so a commented-out call does not count), // the shape of TestAdmission_IsWiredIntoEveryProductionWriteLeg. // // 1. DISCOVERY: every production function that names the compose file (the literal "docker-compose.yml", a // ComposePathIn / AppliedComposePath call, a .ComposePath field) AND calls a file write must be in `writers` — // either GATED (its first call of the gate comes before its first write) or EXEMPT with the reason. A new writer // fails this test until someone decides which it is. // 2. DECISION POINTS: the paths that choose an app's env (install, the two settings changes, the two restores) ask // the gate before their first mutation. // // RED-PROOF (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt): remove the admit call // from copyTemplates → step 1 fails naming it; add a new function that writes ComposePathIn(dir) → step 1 fails as // an unknown writer. func TestAllowList_IsWiredIntoEveryComposeWriter(t *testing.T) { type rule struct { gate string // "" = exempt why string // exempt: the reason before string // gated: the write the gate must precede (the first call of this name) } writers := map[string]rule{ "internal/sync/sync.go:copyTemplates": {gate: "admit", before: "writeIfChanged"}, "internal/stacks/pin.go:advancePinTo": {gate: "admitCompose", before: "StoreAppliedDefinition"}, "cmd/controller/main.go:RecreateStackDefinitionFromUnit": {gate: "AdmitDefinitionFromUnit", before: "WriteFile"}, "internal/stacks/infra.go:ensureFileBrowser": {why: "controller-rendered infra stack — out of scope"}, "internal/stacks/infra.go:reconcileFileBrowserImage": {why: "controller-rendered infra stack — out of scope"}, "internal/stacks/install_interrupted.go:RecoverInterruptedInstalls": {why: "writes the interrupted-install marker, no compose"}, "internal/stacks/pin.go:StoreAppliedDefinition": {why: "the STORED copy; it becomes live only through copyTemplates' frozen branch, which admits it"}, "internal/stacks/update.go:runGuardedUpdate": {why: "copies the PREVIOUS compose aside (the undo); the new one is written by advancePinTo"}, "internal/stacks/update.go:restoreDefinition": {why: "puts back the bytes the app ran on before the update — an undo adds nothing"}, "internal/backup/offbox_reconstitute.go:captureLiveDefinition": {why: "a scratch copy in a temp dir, never brought up"}, "internal/backup/recovery_unit.go:captureRecoveryUnit": {why: "the backup's copy; its way back in is RecreateStackDefinitionFromUnit (gated)"}, } decisions := []struct{ fn, gate, before string }{ {"internal/stacks/deploy.go:DeployStack", "admitCompose", "SaveAppConfig"}, {"internal/stacks/deploy.go:UpdateStackConfig", "admitWithNewValues", "SaveAppConfig"}, {"internal/stacks/deploy.go:UpdateOptionalConfig", "admitWithNewValues", "SaveAppConfig"}, {"internal/backup/restore_unit.go:RestoreFromRecoveryUnitAtWith", "admitRestoredDefinition", "StopStack"}, {"internal/backup/offbox_reconstitute.go:ReconstituteFromOffsite", "admitRestoredDefinition", "StopStack"}, } writes := map[string]bool{"WriteFile": true, "writeIfChanged": true, "copyIfChanged": true, "atomicWrite": true, "Rename": true, "Create": true, "OpenFile": true, "StoreAppliedDefinition": true, "copyFile": true, "CopyFile": true} root := filepath.Join("..", "..") calls := map[string][]string{} // "dir/file.go:Func" → called names in source order var found []string for _, dir := range []string{"internal/stacks", "internal/sync", "internal/backup", "internal/appexport", "internal/web", "internal/api", "cmd/controller"} { files, _ := filepath.Glob(filepath.Join(root, dir, "*.go")) if len(files) == 0 { t.Fatalf("no Go files under %s — the walk cannot see the code it guards", dir) } for _, f := range files { if strings.HasSuffix(f, "_test.go") { continue } fset := token.NewFileSet() file, err := parser.ParseFile(fset, f, nil, 0) if err != nil { t.Fatal(err) } for _, d := range file.Decls { fd, ok := d.(*ast.FuncDecl) if !ok || fd.Body == nil { continue } id := dir + "/" + filepath.Base(f) + ":" + fd.Name.Name names, compose, writesFile := []string{}, false, false ast.Inspect(fd.Body, func(n ast.Node) bool { switch v := n.(type) { case *ast.SelectorExpr: if v.Sel.Name == "ComposePath" { compose = true } case *ast.BasicLit: if v.Value == `"docker-compose.yml"` { compose = true } case *ast.CallExpr: name := "" switch fn := v.Fun.(type) { case *ast.Ident: name = fn.Name case *ast.SelectorExpr: name = fn.Sel.Name } if name == "ComposePathIn" || name == "AppliedComposePath" { compose = true } if writes[name] { writesFile = true } if name != "" { names = append(names, name) } } return true }) calls[id] = names if compose && writesFile { found = append(found, id) } } } } // 1. Discovery. sort.Strings(found) for _, id := range found { r, known := writers[id] if !known { t.Errorf("%s writes a file and names the compose file, and the allow-list guard does not know it — gate it "+ "(admitCompose before the write) or add it to `writers` as exempt with the reason", id) continue } if r.gate == "" { continue } assertCalledBefore(t, id, calls[id], r.gate, r.before) } for id := range writers { if _, ok := calls[id]; !ok { t.Errorf("fixture drift: %s no longer exists — this guard can no longer see that writer", id) } } // 2. Decision points. for _, d := range decisions { c, ok := calls[d.fn] if !ok { t.Errorf("fixture drift: %s no longer exists", d.fn) continue } assertCalledBefore(t, d.fn, c, d.gate, d.before) } } func assertCalledBefore(t *testing.T, id string, calls []string, gate, act string) { t.Helper() gi, ai := -1, -1 for i, c := range calls { if c == gate && gi < 0 { gi = i } if c == act && ai < 0 { ai = i } } switch { case gi < 0: t.Errorf("%s: the allow-list gate %q is never called", id, gate) case ai < 0: t.Errorf("fixture drift: %s no longer calls %q — the guard cannot order the gate against it", id, act) case gi > ai: t.Errorf("%s: %q first appears after %q — the check must come before the write", id, gate, act) } }