oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package guards
2
3import (
4 "regexp"
5 "strings"
6 "testing"
7)
8
9// addressWords are the identifier parts that name an address.
10// §10: "people — person ref, blind index, created_at. No address column."
11//
12// It matches the runtime twin's list in internal/home/db_test.go on
13// purpose. An earlier version of the two disagreed — this one had no
14// bare "mail", so a column called sender_mail passed the static guard
15// and was caught only at runtime — and two guards that disagree about
16// what they forbid are one guard plus a surprise.
17var addressWords = map[string]bool{
18 "address": true, "addresses": true, "addr": true,
19 "email": true, "emails": true, "mail": true,
20 "recipient": true, "recipients": true,
21}
22
23// identifier finds the word-shaped runs in a line: letters, digits and
24// the separators SQL identifiers use.
25var identifier = regexp.MustCompile(`[A-Za-z0-9_-]+`)
26
27// namesAnAddress reports whether a line of SQL declares a column whose
28// name says it holds an address, and which word gave it away.
29//
30// It works per identifier, not per line, and that is the whole
31// correction. The first version matched a regex against the line and
32// exempted the ENTIRE line when the word "sealed" appeared anywhere on
33// it. Three real leaks walked through, all found by a reviewer running
34// them against a migration:
35//
36// unsealed_address TEXT NOT NULL -- "sealed" is a substring
37// sealed_note BLOB, address TEXT NOT NULL -- one line, two columns
38// address TEXT NOT NULL DEFAULT 'sealed' -- the word in a default
39//
40// The exemption is now a whole "sealed" part of one identifier, so it
41// covers sealed_address and nothing standing next to it.
42//
43// Splitting on _ and - also fixes the earlier \b problem: \b counts _ as
44// a word character, so \bemail\b never matched owner_email, which is
45// exactly the column somebody would add.
46func namesAnAddress(line string) (string, bool) {
47 // Match on the code, not the comment. A migration explaining why it
48 // has no address column is not an address column, and the first one
49 // written tripped this guard on its own prose. Nothing hides here: a
50 // column definition inside a SQL comment is not a column.
51 code, _, _ := strings.Cut(line, "--")
52 for _, word := range identifier.FindAllString(code, -1) {
53 parts := strings.FieldsFunc(strings.ToLower(word), func(r rune) bool {
54 return r == '_' || r == '-'
55 })
56 if slicesContains(parts, "sealed") {
57 // A sealed blob is not an address, and says so in its name.
58 continue
59 }
60 for _, p := range parts {
61 if addressWords[p] {
62 return word, true
63 }
64 }
65 }
66 return "", false
67}
68
69func slicesContains(parts []string, want string) bool {
70 for _, p := range parts {
71 if p == want {
72 return true
73 }
74 }
75 return false
76}
77
78// TestNoAddressColumn reads every migration and fails on a column whose
79// name says it holds an address. This is the cheap half of gate 2; the
80// expensive half — a full sign-in leaves no readable address anywhere,
81// WAL included — lives with the sign-in it needs.
82//
83// It is a tripwire, like gate 9: it would pass trivially with no
84// migrations, and that is correct. A guard written after the schema it
85// guards is a guard written to pass.
86func TestNoAddressColumn(t *testing.T) {
87 root := repoRoot(t)
88 files := migrationFiles(t, root)
89 if len(files) == 0 {
90 t.Fatal("no migrations found: the guard would pass by not looking")
91 }
92 for _, path := range files {
93 rel := relPath(t, root, path)
94 for i, line := range strings.Split(readFile(t, path), "\n") {
95 if word, bad := namesAnAddress(line); bad {
96 t.Errorf("%s:%d: %q names an address column; §10 says there is none",
97 rel, i+1, word)
98 }
99 }
100 }
101}
102
103// TestNamesAnAddress proves the matcher fires, and fires on the shapes a
104// schema would actually use. A tripwire that passes today is only worth
105// having if it would still fire tomorrow.
106func TestNamesAnAddress(t *testing.T) {
107 shouldMatch := []string{
108 " address TEXT NOT NULL,",
109 " email TEXT,",
110 " e_mail TEXT,",
111 " mail_to TEXT,",
112 " recipient TEXT,",
113 " EMAIL TEXT,",
114 " owner_email TEXT,",
115 " sender_mail TEXT,",
116 " emails TEXT,",
117 " addr TEXT,",
118 // The three the line-level exemption used to let through.
119 " unsealed_address TEXT NOT NULL,",
120 " sealed_note BLOB, address TEXT NOT NULL,",
121 " address TEXT NOT NULL DEFAULT 'sealed',",
122 }
123 for _, line := range shouldMatch {
124 if _, bad := namesAnAddress(line); !bad {
125 t.Errorf("namesAnAddress missed %q", line)
126 }
127 }
128
129 shouldNotMatch := []string{
130 " person_ref TEXT PRIMARY KEY,",
131 " blind_index TEXT NOT NULL UNIQUE,",
132 " created_at TEXT NOT NULL",
133 "CREATE TABLE people (",
134 " host TEXT NOT NULL,",
135 // The one real exemption: a sealed blob is not an address, and
136 // says so in its own name.
137 " sealed_address BLOB NOT NULL,",
138 // Prose about addresses is not a column.
139 "-- there is no address column here and that is the point",
140 " person_ref TEXT PRIMARY KEY, -- not the email address",
141 // "mailer" and "emailed" are words, not column names.
142 " mailer_id TEXT,",
143 " emailed_at TEXT,",
144 }
145 for _, line := range shouldNotMatch {
146 if word, bad := namesAnAddress(line); bad {
147 t.Errorf("namesAnAddress wrongly matched %q in %q", word, line)
148 }
149 }
150}
151
152// Every word this guard forbids must actually be matched by it. They are
153// two pieces of one guard, and a word in the list that the matcher never
154// sees is a rule nobody enforces.
155func TestEveryForbiddenWordIsMatched(t *testing.T) {
156 for word := range addressWords {
157 if _, bad := namesAnAddress(" " + word + " TEXT,"); !bad {
158 t.Errorf("addressWords has %q but namesAnAddress does not match it", word)
159 }
160 if _, bad := namesAnAddress(" owner_" + word + " TEXT,"); !bad {
161 t.Errorf("addressWords has %q but namesAnAddress does not match owner_%s", word, word)
162 }
163 }
164}
165