feat: gate schema.sql against tables not created by core migrations
Catches plugin-migration leakage (e.g. the .so-era ctpp_* tables) entering the generated schema dump and sqlc models. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
43113fc865
commit
9a0099a95a
99
check_schemapurity.go
Normal file
99
check_schemapurity.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user