package guards import ( "regexp" "strings" "testing" ) // addressWords are the identifier parts that name an address. // §10: "people — person ref, blind index, created_at. No address column." // // It matches the runtime twin's list in internal/home/db_test.go on // purpose. An earlier version of the two disagreed — this one had no // bare "mail", so a column called sender_mail passed the static guard // and was caught only at runtime — and two guards that disagree about // what they forbid are one guard plus a surprise. var addressWords = map[string]bool{ "address": true, "addresses": true, "addr": true, "email": true, "emails": true, "mail": true, "recipient": true, "recipients": true, } // identifier finds the word-shaped runs in a line: letters, digits and // the separators SQL identifiers use. var identifier = regexp.MustCompile(`[A-Za-z0-9_-]+`) // namesAnAddress reports whether a line of SQL declares a column whose // name says it holds an address, and which word gave it away. // // It works per identifier, not per line, and that is the whole // correction. The first version matched a regex against the line and // exempted the ENTIRE line when the word "sealed" appeared anywhere on // it. Three real leaks walked through, all found by a reviewer running // them against a migration: // // unsealed_address TEXT NOT NULL -- "sealed" is a substring // sealed_note BLOB, address TEXT NOT NULL -- one line, two columns // address TEXT NOT NULL DEFAULT 'sealed' -- the word in a default // // The exemption is now a whole "sealed" part of one identifier, so it // covers sealed_address and nothing standing next to it. // // Splitting on _ and - also fixes the earlier \b problem: \b counts _ as // a word character, so \bemail\b never matched owner_email, which is // exactly the column somebody would add. func namesAnAddress(line string) (string, bool) { // Match on the code, not the comment. A migration explaining why it // has no address column is not an address column, and the first one // written tripped this guard on its own prose. Nothing hides here: a // column definition inside a SQL comment is not a column. code, _, _ := strings.Cut(line, "--") for _, word := range identifier.FindAllString(code, -1) { parts := strings.FieldsFunc(strings.ToLower(word), func(r rune) bool { return r == '_' || r == '-' }) if slicesContains(parts, "sealed") { // A sealed blob is not an address, and says so in its name. continue } for _, p := range parts { if addressWords[p] { return word, true } } } return "", false } func slicesContains(parts []string, want string) bool { for _, p := range parts { if p == want { return true } } return false } // TestNoAddressColumn reads every migration and fails on a column whose // name says it holds an address. This is the cheap half of gate 2; the // expensive half — a full sign-in leaves no readable address anywhere, // WAL included — lives with the sign-in it needs. // // It is a tripwire, like gate 9: it would pass trivially with no // migrations, and that is correct. A guard written after the schema it // guards is a guard written to pass. func TestNoAddressColumn(t *testing.T) { root := repoRoot(t) files := migrationFiles(t, root) if len(files) == 0 { t.Fatal("no migrations found: the guard would pass by not looking") } for _, path := range files { rel := relPath(t, root, path) for i, line := range strings.Split(readFile(t, path), "\n") { if word, bad := namesAnAddress(line); bad { t.Errorf("%s:%d: %q names an address column; §10 says there is none", rel, i+1, word) } } } } // TestNamesAnAddress proves the matcher fires, and fires on the shapes a // schema would actually use. A tripwire that passes today is only worth // having if it would still fire tomorrow. func TestNamesAnAddress(t *testing.T) { shouldMatch := []string{ " address TEXT NOT NULL,", " email TEXT,", " e_mail TEXT,", " mail_to TEXT,", " recipient TEXT,", " EMAIL TEXT,", " owner_email TEXT,", " sender_mail TEXT,", " emails TEXT,", " addr TEXT,", // The three the line-level exemption used to let through. " unsealed_address TEXT NOT NULL,", " sealed_note BLOB, address TEXT NOT NULL,", " address TEXT NOT NULL DEFAULT 'sealed',", } for _, line := range shouldMatch { if _, bad := namesAnAddress(line); !bad { t.Errorf("namesAnAddress missed %q", line) } } shouldNotMatch := []string{ " person_ref TEXT PRIMARY KEY,", " blind_index TEXT NOT NULL UNIQUE,", " created_at TEXT NOT NULL", "CREATE TABLE people (", " host TEXT NOT NULL,", // The one real exemption: a sealed blob is not an address, and // says so in its own name. " sealed_address BLOB NOT NULL,", // Prose about addresses is not a column. "-- there is no address column here and that is the point", " person_ref TEXT PRIMARY KEY, -- not the email address", // "mailer" and "emailed" are words, not column names. " mailer_id TEXT,", " emailed_at TEXT,", } for _, line := range shouldNotMatch { if word, bad := namesAnAddress(line); bad { t.Errorf("namesAnAddress wrongly matched %q in %q", word, line) } } } // Every word this guard forbids must actually be matched by it. They are // two pieces of one guard, and a word in the list that the matcher never // sees is a rule nobody enforces. func TestEveryForbiddenWordIsMatched(t *testing.T) { for word := range addressWords { if _, bad := namesAnAddress(" " + word + " TEXT,"); !bad { t.Errorf("addressWords has %q but namesAnAddress does not match it", word) } if _, bad := namesAnAddress(" owner_" + word + " TEXT,"); !bad { t.Errorf("addressWords has %q but namesAnAddress does not match owner_%s", word, word) } } }