apppolicy: the writers source test sees same-package and raw-string imports (#328)
Review fix round 1: unqualified AppPolicyKey calls inside internal/release and writer references inside internal/apppolicy outside the writer chain are now checked; imports are parsed and unquoted before the relevance filter, so a backtick import path is seen. A fixture module pins both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+261
−65
internal/apppolicy/writers_test.go+261 −65
diff --git a/internal/apppolicy/writers_test.go b/internal/apppolicy/writers_test.go| index 74c3ad5f1..d6d144331 100644 |
| --- a/internal/apppolicy/writers_test.go |
| +++ b/internal/apppolicy/writers_test.go |
| @@ -2,11 +2,13 @@ package apppolicy |
| import ( |
| "bytes" |
| + "fmt" |
| "go/ast" |
| "go/parser" |
| "go/token" |
| "io/fs" |
| "os" |
| + "path" |
| "path/filepath" |
| "slices" |
| "strconv" |
| @@ -15,8 +17,12 @@ import ( |
| ) |
| const ( |
| - apppolicyImport = "github.com/carlosframework/platform/internal/apppolicy" |
| - releaseImport = "github.com/carlosframework/platform/internal/release" |
| + modulePath = "github.com/carlosframework/platform" |
| + apppolicyImport = modulePath + "/internal/apppolicy" |
| + releaseImport = modulePath + "/internal/release" |
| + apppolicyPkgDir = "internal/apppolicy" |
| + releasePkgDir = "internal/release" |
| + releasePolicyKey = "release.AppPolicyKey" |
| ) |
| // policyWriterSites is the boundary of spec §3, kept by source: the only |
| @@ -32,108 +38,298 @@ var policyWriterSites = map[string][]string{ |
| // The create-time clear (Task 11). |
| "apppolicy.Delete": {"internal/console/server.go"}, |
| // The purge, the box's store door and the mutation classifier. |
| - "release.AppPolicyKey": {"internal/release/trash.go", "internal/edge/storedoor.go", "internal/hostops/mutation_classifier.go"}, |
| + releasePolicyKey: {"internal/release/trash.go", "internal/edge/storedoor.go", "internal/hostops/mutation_classifier.go"}, |
| } |
| +// inPackageChain is, inside internal/apppolicy, the functions whose bodies |
| +// may name a restricted function: the writers call each other (PutIfMatch |
| +// -> PutIfMatchETag -> Sign, PutWire) and Key builds the key. Any other |
| +// function there that names one would be a writer of its own. |
| +var inPackageChain = []string{"PutIfMatch", "PutIfMatchETag", "PutWire", "Sign", "Delete", "Key"} |
| + |
| // TestPolicyWritersAreTheDoorThePurgeAndCreate walks every non-test .go |
| // file of the module and fails naming file:line of any reference to a |
| // policy writer, or to release.AppPolicyKey, outside its allowed files. |
| func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) { |
| root := moduleRoot(t) |
| + violations, seen, err := scanPolicyWriters(root) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, v := range violations { |
| + t.Error(v) |
| + } |
| + // The box's store door builds the key today: a walk that finds no |
| + // reference there is broken, not clean. |
| + if seen[releasePolicyKey] == 0 { |
| + t.Fatalf("no %s reference found under %s: the scan is broken", releasePolicyKey, root) |
| + } |
| +} |
| + |
| +// TestPolicyWritersScanSeesEveryForm runs the scan over a fixture module: |
| +// unqualified calls inside the defining package, a raw-string import, an |
| +// aliased import and a dot import are all seen, and the permitted forms |
| +// are not reported. |
| +func TestPolicyWritersScanSeesEveryForm(t *testing.T) { |
| + root := t.TempDir() |
| + files := map[string]string{ |
| + "go.mod": "module " + modulePath + "\n\ngo 1.26.0\n", |
| + // The definition itself is not a reference. |
| + "internal/release/release.go": `package release |
| +func AppPolicyKey(acct, app string) string { return acct + app } |
| +`, |
| + // The purge may build the key, unqualified. |
| + "internal/release/trash.go": `package release |
| +func purge() string { return AppPolicyKey("bab", "shop") } |
| +`, |
| + // Any other file in package release may not; a local of the same |
| + // name, a field and a selector are not references to it. |
| + "internal/release/other.go": `package release |
| +type rec struct{ AppPolicyKey string } |
| +func other(r rec) string { |
| + AppPolicyKey := "x" |
| + _ = r.AppPolicyKey |
| + _ = rec{AppPolicyKey: "y"} |
| + return AppPolicyKey |
| +} |
| +func sneaky() string { return AppPolicyKey("bab", "shop") } |
| +`, |
| + "internal/release/other_test.go": `package release |
| +func inTest() string { return AppPolicyKey("bab", "shop") } |
| +`, |
| + // Inside apppolicy the writer chain and Key are permitted; a new |
| + // function there that names a writer is not. |
| + "internal/apppolicy/apppolicy.go": `package apppolicy |
| +import "github.com/carlosframework/platform/internal/release" |
| +func Key(sqid, app string) string { return release.AppPolicyKey(sqid, app) } |
| +func Sign() []byte { return nil } |
| +func PutWire(b []byte) {} |
| +func PutIfMatchETag() { PutWire(Sign()) } |
| +func PutIfMatch() { PutIfMatchETag() } |
| +func Delete() { _ = Key("bab", "shop") } |
| +func Resign() { PutWire(Sign()) } |
| +`, |
| + // A raw-string import is still an import. |
| + "internal/door/raw.go": "package door\nimport `" + apppolicyImport + "`\nfunc clear() { apppolicy.Delete() }\n", |
| + "internal/door/alias.go": `package door |
| +import ap "` + apppolicyImport + `" |
| +func write() { ap.PutIfMatch() } |
| +func read() string { return ap.Key("bab", "shop") } |
| +`, |
| + "internal/door/dot.go": `package door |
| +import . "` + releaseImport + `" |
| +`, |
| + // The permitted callers. |
| + "internal/console/server.go": `package console |
| +import "` + apppolicyImport + `" |
| +func create() { apppolicy.Delete(); _ = apppolicy.Key("bab", "shop") } |
| +`, |
| + "internal/edge/storedoor.go": `package edge |
| +import "` + releaseImport + `" |
| +var _ = release.AppPolicyKey("bab", "shop") |
| +`, |
| + } |
| + for name, src := range files { |
| + p := filepath.Join(root, filepath.FromSlash(name)) |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + violations, _, err := scanPolicyWriters(root) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{ |
| + "internal/apppolicy/apppolicy.go:9:17: apppolicy.PutWire", |
| + "internal/apppolicy/apppolicy.go:9:25: apppolicy.Sign", |
| + "internal/door/alias.go:3:16: apppolicy.PutIfMatch", |
| + "internal/door/dot.go:2:8: dot import", |
| + "internal/door/raw.go:3:16: apppolicy.Delete", |
| + "internal/release/other.go:9:31: release.AppPolicyKey", |
| + } |
| + if len(violations) != len(want) { |
| + t.Errorf("got %d violations, want %d:\n%s", len(violations), len(want), strings.Join(violations, "\n")) |
| + } |
| + for _, w := range want { |
| + if !slices.ContainsFunc(violations, func(v string) bool { return strings.HasPrefix(v, w) }) { |
| + t.Errorf("missing violation %q in:\n%s", w, strings.Join(violations, "\n")) |
| + } |
| + } |
| +} |
| + |
| +// scanPolicyWriters walks the module at root and returns one line per |
| +// reference to a restricted name outside its allowed files, and how many |
| +// references of each name it saw, allowed or not. |
| +func scanPolicyWriters(root string) (violations []string, seen map[string]int, err error) { |
| + seen = map[string]int{} |
| fset := token.NewFileSet() |
| - seen := map[string]int{} // references found, allowed or not, by name |
| - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| + err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { |
| if err != nil { |
| return err |
| } |
| - rel, _ := filepath.Rel(root, path) |
| + rel, _ := filepath.Rel(root, p) |
| rel = filepath.ToSlash(rel) |
| if d.IsDir() { |
| - if path == root { |
| + if p == root { |
| return nil |
| } |
| name := d.Name() |
| if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "vendor" || name == "node_modules" { |
| return filepath.SkipDir |
| } |
| - if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { |
| + if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil { |
| return filepath.SkipDir // another module |
| } |
| - if rel == "internal/apppolicy" { |
| - return filepath.SkipDir |
| - } |
| return nil |
| } |
| if !strings.HasSuffix(rel, ".go") || strings.HasSuffix(rel, "_test.go") { |
| return nil |
| } |
| - src, err := os.ReadFile(path) |
| + src, err := os.ReadFile(p) |
| if err != nil { |
| return err |
| } |
| - if !bytes.Contains(src, []byte(apppolicyImport+`"`)) && !bytes.Contains(src, []byte(releaseImport+`"`)) { |
| + dir := path.Dir(rel) |
| + // Imports first, unquoted, so a raw-string import path is seen. |
| + hdr, err := parser.ParseFile(token.NewFileSet(), rel, src, parser.ImportsOnly) |
| + if err != nil { |
| + return err |
| + } |
| + relevant := dir == apppolicyPkgDir || dir == releasePkgDir |
| + for _, imp := range hdr.Imports { |
| + if ip, _ := strconv.Unquote(imp.Path.Value); ip == apppolicyImport || ip == releaseImport { |
| + relevant = true |
| + } |
| + } |
| + if !relevant { |
| return nil |
| } |
| - f, err := parser.ParseFile(fset, rel, src, parser.SkipObjectResolution) |
| + f, err := parser.ParseFile(fset, rel, src, 0) |
| if err != nil { |
| return err |
| } |
| - local := map[string]string{} // file-local package name -> "apppolicy" or "release" |
| - for _, imp := range f.Imports { |
| - path, _ := strconv.Unquote(imp.Path.Value) |
| - var pkg string |
| - switch path { |
| - case apppolicyImport: |
| - pkg = "apppolicy" |
| - case releaseImport: |
| - pkg = "release" |
| - default: |
| - continue |
| - } |
| - name := pkg |
| - if imp.Name != nil { |
| - name = imp.Name.Name |
| - } |
| - if name == "." { |
| - t.Errorf("%s: dot import of %s hides policy writer calls from this check", fset.Position(imp.Pos()), path) |
| - continue |
| + violations = append(violations, scanFile(fset, f, rel, seen)...) |
| + return nil |
| + }) |
| + return violations, seen, err |
| +} |
| + |
| +func scanFile(fset *token.FileSet, f *ast.File, rel string, seen map[string]int) []string { |
| + var out []string |
| + report := func(pos token.Pos, name, fn string) { |
| + seen[name]++ |
| + if path.Dir(rel) == apppolicyPkgDir { |
| + if !slices.Contains(inPackageChain, fn) { |
| + out = append(out, fmt.Sprintf("%s: %s in %s, outside the writer chain %v", fset.Position(pos), name, fn, inPackageChain)) |
| } |
| - local[name] = pkg |
| + return |
| } |
| - ast.Inspect(f, func(n ast.Node) bool { |
| - sel, ok := n.(*ast.SelectorExpr) |
| - if !ok { |
| - return true |
| - } |
| - x, ok := sel.X.(*ast.Ident) |
| - if !ok { |
| - return true |
| - } |
| - pkg, ok := local[x.Name] |
| - if !ok { |
| - return true |
| - } |
| - name := pkg + "." + sel.Sel.Name |
| - allowed, restricted := policyWriterSites[name] |
| - if !restricted { |
| - return true |
| - } |
| - seen[name]++ |
| - if !slices.Contains(allowed, rel) { |
| - t.Errorf("%s: %s outside its allowed files %v", fset.Position(sel.Pos()), name, allowed) |
| + if allowed := policyWriterSites[name]; !slices.Contains(allowed, rel) { |
| + out = append(out, fmt.Sprintf("%s: %s outside its allowed files %v", fset.Position(pos), name, allowed)) |
| + } |
| + } |
| + local := map[string]string{} // file-local package name -> "apppolicy" or "release" |
| + for _, imp := range f.Imports { |
| + ip, _ := strconv.Unquote(imp.Path.Value) |
| + var pkg string |
| + switch ip { |
| + case apppolicyImport: |
| + pkg = "apppolicy" |
| + case releaseImport: |
| + pkg = "release" |
| + default: |
| + continue |
| + } |
| + name := pkg |
| + if imp.Name != nil { |
| + name = imp.Name.Name |
| + } |
| + if name == "." { |
| + out = append(out, fmt.Sprintf("%s: dot import of %s hides policy writer references from this check", fset.Position(imp.Pos()), ip)) |
| + continue |
| + } |
| + local[name] = pkg |
| + } |
| + // Unqualified names refer to the file's own package. |
| + var home string |
| + switch path.Dir(rel) { |
| + case apppolicyPkgDir: |
| + home = "apppolicy" |
| + case releasePkgDir: |
| + home = "release" |
| + } |
| + for _, decl := range f.Decls { |
| + fn := "" |
| + if fd, ok := decl.(*ast.FuncDecl); ok && fd.Recv == nil { |
| + fn = fd.Name.Name |
| + } |
| + var walk func(ast.Node) bool |
| + walk = func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.FuncDecl: |
| + // The definition's own name is not a reference. |
| + for _, c := range []ast.Node{n.Recv, n.Type, n.Body} { |
| + if c != nil && !isNilNode(c) { |
| + ast.Inspect(c, walk) |
| + } |
| + } |
| + return false |
| + case *ast.SelectorExpr: |
| + if x, ok := n.X.(*ast.Ident); ok && x.Obj == nil { |
| + if pkg, ok := local[x.Name]; ok { |
| + if name := pkg + "." + n.Sel.Name; policyWriterSites[name] != nil { |
| + report(n.Pos(), name, fn) |
| + } |
| + } |
| + } |
| + ast.Inspect(n.X, walk) // never the selected field or method name |
| + return false |
| + case *ast.KeyValueExpr: |
| + if _, ok := n.Key.(*ast.Ident); ok { |
| + ast.Inspect(n.Value, walk) // a composite literal's field name |
| + return false |
| + } |
| + case *ast.Field: |
| + if n.Type != nil { |
| + ast.Inspect(n.Type, walk) // field and parameter names are declarations |
| + } |
| + return false |
| + case *ast.Ident: |
| + // Unresolved in this file means package scope (or universe); |
| + // resolved to a top-level func of this file is the same. |
| + if home == "" || !(n.Obj == nil || isTopFunc(n.Obj)) { |
| + return false |
| + } |
| + if name := home + "." + n.Name; policyWriterSites[name] != nil { |
| + report(n.Pos(), name, fn) |
| + } |
| } |
| return true |
| - }) |
| - return nil |
| - }) |
| - if err != nil { |
| - t.Fatal(err) |
| + } |
| + ast.Inspect(decl, walk) |
| } |
| - // The box's store door builds the key today: a walk that finds no |
| - // reference there is broken, not clean. |
| - if seen["release.AppPolicyKey"] == 0 { |
| - t.Fatalf("no release.AppPolicyKey reference found under %s: the scan is broken", root) |
| + return out |
| +} |
| + |
| +func isTopFunc(o *ast.Object) bool { |
| + fd, ok := o.Decl.(*ast.FuncDecl) |
| + return o.Kind == ast.Fun && ok && fd.Recv == nil |
| +} |
| + |
| +// isNilNode reports a typed nil (a FuncDecl without receiver or body). |
| +func isNilNode(n ast.Node) bool { |
| + switch v := n.(type) { |
| + case *ast.FieldList: |
| + return v == nil |
| + case *ast.BlockStmt: |
| + return v == nil |
| + case *ast.FuncType: |
| + return v == nil |
| } |
| + return false |
| } |
| func moduleRoot(t *testing.T) string { |
| @@ -144,7 +340,7 @@ func moduleRoot(t *testing.T) string { |
| } |
| for { |
| if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil && |
| - bytes.HasPrefix(bytes.TrimSpace(b), []byte("module github.com/carlosframework/platform\n")) { |
| + bytes.HasPrefix(bytes.TrimSpace(b), []byte("module "+modulePath+"\n")) { |
| return dir |
| } |
| parent := filepath.Dir(dir) |