diff --git a/check_schemapurity.go b/check_schemapurity.go new file mode 100644 index 0000000..af5a7bc --- /dev/null +++ b/check_schemapurity.go @@ -0,0 +1,99 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +func init() { + register(Check{ + Seq: 310, + ID: "32", + Title: "schema.sql contains only tables created by core migrations", + Run: func(ctx *ScanContext, rep *Reporter) { + schemaPath := filepath.Join(ctx.backendDir, "sql", "schema.sql") + migrationsDir := filepath.Join(ctx.backendDir, "sql", "migrations") + if !fileExists(schemaPath) || !dirExists(migrationsDir) { + rep.Skip("no backend/sql/schema.sql + migrations pair to check") + return + } + + schemaTables, err := schemaCreatedTables(schemaPath) + if err != nil { + rep.Fatal("reading schema.sql: %v", err) + return + } + migrationTables, err := migrationCreatedTables(migrationsDir) + if err != nil { + rep.Fatal("reading migrations: %v", err) + return + } + + var leaked []string + for table := range schemaTables { + if !migrationTables[table] { + leaked = append(leaked, table) + } + } + if len(leaked) > 0 { + sort.Strings(leaked) + rep.Fail("%d table(s) in schema.sql are not created by any core migration (plugin leakage; regenerate with `make schema`, which dumps a clean-room DB)", len(leaked)) + for _, t := range leaked { + rep.Findingf("backend/sql/schema.sql — table %q has no CREATE TABLE in backend/sql/migrations", t) + } + return + } + rep.OK("all %d schema.sql tables originate from core migrations", len(schemaTables)) + }, + }) +} + +var schemaCreateTableRe = regexp.MustCompile(`(?im)^CREATE TABLE (?:IF NOT EXISTS )?(?:public\.)?([a-zA-Z0-9_]+)`) + +// Migrations are hand-written: CREATE TABLE may be indented (inside DO blocks +// etc.), so no line anchor. +var migrationCreateTableRe = regexp.MustCompile(`(?i)CREATE TABLE (?:IF NOT EXISTS )?(?:public\.)?([a-zA-Z0-9_]+)`) + +func schemaCreatedTables(path string) (map[string]bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + tables := map[string]bool{} + for _, m := range schemaCreateTableRe.FindAllStringSubmatch(string(data), -1) { + tables[strings.ToLower(m[1])] = true + } + return tables, nil +} + +// migrationCreatedTables collects every table name a migration creates or +// renames to, from both Up and Down sections (a superset is fine: the check +// only flags schema.sql tables NO migration could have produced). +var migrationRenameRe = regexp.MustCompile(`(?i)ALTER TABLE (?:IF EXISTS )?(?:ONLY )?(?:public\.)?[a-zA-Z0-9_]+ RENAME TO ([a-zA-Z0-9_]+)`) + +func migrationCreatedTables(dir string) (map[string]bool, error) { + tables := map[string]bool{"goose_db_version": true} + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, err + } + for _, m := range migrationCreateTableRe.FindAllStringSubmatch(string(data), -1) { + tables[strings.ToLower(m[1])] = true + } + for _, m := range migrationRenameRe.FindAllStringSubmatch(string(data), -1) { + tables[strings.ToLower(m[1])] = true + } + } + return tables, nil +}