Skip to main content

cratefield_core/
lint.rs

1//! The portable-SQL lint (ADR 0004): flags tokens that SQLite and Postgres
2//! disagree about, or that betray non-portable DDL. One canonical definition
3//! shared by `fz doctor` and the Postgres migration runner's set selection
4//! (issue #18) so the two can never drift.
5//!
6//! The lint reads **DDL**, not the whole file. Comments and string
7//! literals are stripped first, because neither is executed: a migration
8//! that explains itself in prose, or stores the word `blob` as data, is
9//! portable. Scanning them produced false positives that failed a module
10//! for a backtick inside a `--` comment.
11
12/// Returns `(token, explanation)` pairs found in `sql`.
13///
14/// This is the predicate behind both `fz doctor`'s migration lint and the
15/// `cratefield-adapter-postgres` runner's rule that a module's `sqlite`
16/// migration set may be applied to Postgres only when it passes.
17///
18/// SQL comments (`-- ...`, `/* ... */`) and single-quoted string literals
19/// are ignored: they are not DDL. Double-quoted identifiers are not
20/// ignored, because they are DDL and portable in both dialects.
21#[must_use]
22pub fn lint_portable_sql(sql: &str) -> Vec<(&'static str, &'static str)> {
23    const BANNED: &[(&str, &str)] = &[
24        (
25            "AUTOINCREMENT",
26            "SQLite-only; use plain INTEGER PRIMARY KEY (ULID ids instead of autoincrement)",
27        ),
28        (
29            "datetime(",
30            "dialect function; store ISO-8601 TEXT and compute in code",
31        ),
32        ("SERIAL", "Postgres-only; use TEXT ULID ids"),
33        (
34            "NOW()",
35            "dialect function; bind an ISO-8601 timestamp instead",
36        ),
37        (
38            "json_extract",
39            "SQLite-only JSON function; parse JSON in code",
40        ),
41        (
42            "`",
43            "backtick quoting is MySQL/SQLite; use double quotes or none",
44        ),
45        (
46            "BLOB",
47            "SQLite-only type; Postgres has BYTEA — ship a migrations/postgres override",
48        ),
49    ];
50
51    let haystack = strip_non_ddl(sql).to_ascii_lowercase();
52    BANNED
53        .iter()
54        .filter(|(token, _)| {
55            if *token == "`" {
56                haystack.contains('`')
57            } else {
58                haystack.contains(&token.to_ascii_lowercase())
59            }
60        })
61        .copied()
62        .collect()
63}
64
65/// Card-data fragments the harness never stores. With a normal Stripe
66/// integration the card details go straight to Stripe (Checkout, Elements, the
67/// SDKs) and never reach a backend, so a column or secret shaped like a card
68/// number, verification code or full expiry is a mistake. Deliberately the
69/// *specific* forms and not bare `pan`, `track` or `expiry`, which collide with
70/// legitimate columns — an audio `pan`, a music `track`, a `session_expiry`
71/// (issue #44).
72const CARD_DATA: &[&str] = &[
73    "card_number",
74    "cardnumber",
75    "card_no",
76    "cardno",
77    "primary_account_number",
78    "full_pan",
79    "cvv",
80    "cvc",
81    "cvv2",
82    "cvc2",
83    "card_cvv",
84    "card_cvc",
85    "exp_month",
86    "exp_year",
87    "card_expiry",
88    "expiry_date",
89    "expiration_date",
90    "track_data",
91    "magstripe",
92    "magnetic_stripe",
93];
94
95/// The first card-data fragment `text` contains (case-insensitive), or `None`.
96/// Keeps card numbers, verification codes and full expiry out of migrations and
97/// secret names — with a normal Stripe integration none of them should exist
98/// (issue #44).
99#[must_use]
100pub fn card_data_hit(text: &str) -> Option<&'static str> {
101    let hay = text.to_ascii_lowercase();
102    CARD_DATA.iter().copied().find(|frag| hay.contains(frag))
103}
104
105/// Card-data column or table names found in `sql`, as `(fragment, why)` pairs.
106/// Comments and string literals are ignored, so documenting the rule does not
107/// trip it.
108#[must_use]
109pub fn lint_card_data(sql: &str) -> Vec<(&'static str, &'static str)> {
110    match card_data_hit(&strip_non_ddl(sql)) {
111        Some(frag) => vec![(
112            frag,
113            "looks like card data; with a normal Stripe integration the card never \
114             reaches a backend — store only Stripe's identifiers",
115        )],
116        None => Vec::new(),
117    }
118}
119
120/// Words a `CREATE` may carry before `TABLE`. Anything else after `CREATE`
121/// — `INDEX`, `UNIQUE`, `VIEW`, `TRIGGER` — ends the match, which is how
122/// `CREATE UNIQUE INDEX deletion_jobs_confirmation_code` stays out of the
123/// result.
124///
125/// `VIRTUAL` is here because a virtual table is a table: it holds rows, an
126/// export would read it, and leaving it out would be a hole shaped exactly
127/// like the one this scan exists to close.
128const CREATE_MODIFIERS: &[&str] = &[
129    "temp",
130    "temporary",
131    "unlogged",
132    "global",
133    "local",
134    "virtual",
135];
136
137/// Every table `sql` leaves behind, in the order it creates them.
138///
139/// This is the other half of the personal-data rule (issue #272).
140/// [`undeclared_tables`](crate::undeclared_tables) compares
141/// [`Module::tables`](crate::Module::tables) against
142/// [`Module::personal_data`](crate::Module::personal_data), so it can only see
143/// a table that is already in one of those two lists; a table the module never
144/// lists is invisible to it, to `fz data export` and to erasure at once. The
145/// migrations are where a table actually comes into being, so they are what
146/// [`unlisted_tables`](crate::unlisted_tables) compares against — and this is
147/// the scan it reads them with.
148///
149/// **Not a SQL parser, on purpose.** A parser is a dependency and a decision
150/// this workspace has not taken; what a `CREATE TABLE` names is recoverable
151/// from a token walk, and a narrow scan that is wrong loudly is worth more
152/// here than a general one nobody can audit. It reads the same stripped DDL
153/// [`lint_portable_sql`] does, so the four ways it could be wrong are handled
154/// the same way in both:
155///
156/// 1. **A `CREATE TABLE` in a comment or a string is not DDL.** Both are
157///    replaced with spaces before the walk, so a migration that explains its
158///    own schema in prose — as `deletion_jobs` and `cms_item` both do — does
159///    not report the tables it mentions.
160/// 2. **A temporary table is not the module's table.** `CREATE TEMP TABLE`
161///    and `CREATE TEMPORARY TABLE` are skipped: the table is gone with the
162///    connection, so no export could read it and no erasure could miss it.
163/// 3. **The rebuild pattern leaves one table, not two.** SQLite cannot relax
164///    a `NOT NULL`, so `auth-core 0003` and `waitlist 0005` both create
165///    `<table>_rebuild`, copy into it, drop the original and rename. Rather
166///    than special-casing the name, the walk tracks what each statement does:
167///    `CREATE` adds, `DROP TABLE` removes, `ALTER TABLE … RENAME TO` moves.
168///    The answer is therefore what exists when the last migration has run,
169///    which is the thing `tables()` is supposed to describe. `ALTER TABLE …
170///    RENAME COLUMN … TO …` is left alone — it renames a column, not a table.
171/// 4. **Case.** Names come back exactly as the SQL wrote them; duplicates are
172///    collapsed ASCII-case-insensitively, because an unquoted identifier is
173///    case-folded by both dialects.
174///
175/// A name that is not a plain identifier — schema-qualified, or quoted with
176/// something exotic inside — is returned verbatim rather than dropped. It will
177/// not match anything in `tables()` and the module will fail the check, which
178/// is the safe direction for a scan to be uncertain in: nothing in this
179/// workspace needs such a name, and silently ignoring one is how a table
180/// escapes.
181///
182/// Postgres dollar-quoted bodies (`$$ … $$`) are **not** stripped, because
183/// nothing in this workspace uses one; a `CREATE TABLE` inside a function body
184/// would be reported. That, too, fails loudly rather than quietly.
185///
186/// ```
187/// use cratefield_core::created_tables;
188///
189/// let sql = "\
190/// -- CREATE TABLE mentioned_in_prose (…)
191/// CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY);
192/// CREATE INDEX notes_by_id ON notes (id);
193/// CREATE TEMP TABLE scratch (id TEXT);
194/// CREATE TABLE notes_rebuild (id TEXT PRIMARY KEY, body TEXT);
195/// DROP TABLE notes;
196/// ALTER TABLE notes_rebuild RENAME TO notes;";
197/// assert_eq!(created_tables(sql), ["notes"]);
198/// ```
199#[must_use]
200pub fn created_tables(sql: &str) -> Vec<String> {
201    let ddl = strip_non_ddl(sql);
202    let words: Vec<&str> = ddl
203        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '"' || c == '.'))
204        .filter(|word| !word.is_empty())
205        .collect();
206
207    let mut live: Vec<String> = Vec::new();
208    let mut i = 0;
209    while i < words.len() {
210        let word = words[i];
211        i += 1;
212        if word.eq_ignore_ascii_case("create") {
213            create_table(&words, &mut i, &mut live);
214        } else if word.eq_ignore_ascii_case("drop") {
215            drop_table(&words, &mut i, &mut live);
216        } else if word.eq_ignore_ascii_case("alter") {
217            rename_table(&words, &mut i, &mut live);
218        }
219    }
220    live
221}
222
223/// Whether the word at `at` is `keyword`, ignoring case. `false` past the end,
224/// so a statement cut off mid-way simply does not match.
225fn word_is(words: &[&str], at: usize, keyword: &str) -> bool {
226    words
227        .get(at)
228        .is_some_and(|word| word.eq_ignore_ascii_case(keyword))
229}
230
231/// `CREATE [TEMP|…] TABLE [IF NOT EXISTS] <name>`, from just after `CREATE`.
232fn create_table(words: &[&str], i: &mut usize, live: &mut Vec<String>) {
233    let mut temporary = false;
234    while CREATE_MODIFIERS
235        .iter()
236        .any(|modifier| word_is(words, *i, modifier))
237    {
238        temporary |= word_is(words, *i, "temp") || word_is(words, *i, "temporary");
239        *i += 1;
240    }
241    if !word_is(words, *i, "table") {
242        return;
243    }
244    *i += 1;
245    skip_keywords(words, i, &["if", "not", "exists"]);
246    let Some(name) = words.get(*i).copied() else {
247        return;
248    };
249    *i += 1;
250    if !temporary {
251        add(live, name);
252    }
253}
254
255/// `DROP TABLE [IF EXISTS] <name>`, from just after `DROP`.
256fn drop_table(words: &[&str], i: &mut usize, live: &mut Vec<String>) {
257    if !word_is(words, *i, "table") {
258        return;
259    }
260    *i += 1;
261    skip_keywords(words, i, &["if", "exists"]);
262    if let Some(name) = words.get(*i).copied() {
263        *i += 1;
264        remove(live, name);
265    }
266}
267
268/// `ALTER TABLE [IF EXISTS] <from> RENAME TO <to>`, from just after `ALTER`.
269/// Every other `ALTER TABLE` — including `RENAME COLUMN … TO …` — leaves the
270/// set alone.
271fn rename_table(words: &[&str], i: &mut usize, live: &mut Vec<String>) {
272    if !word_is(words, *i, "table") {
273        return;
274    }
275    *i += 1;
276    skip_keywords(words, i, &["if", "exists"]);
277    let Some(from) = words.get(*i).copied() else {
278        return;
279    };
280    *i += 1;
281    if !(word_is(words, *i, "rename") && word_is(words, *i + 1, "to")) {
282        return;
283    }
284    let Some(to) = words.get(*i + 2).copied() else {
285        return;
286    };
287    *i += 3;
288    remove(live, from);
289    add(live, to);
290}
291
292/// Steps over `keywords` when they all appear next, in order; leaves `i`
293/// alone otherwise, so a table actually called `if` is still read as a name.
294fn skip_keywords(words: &[&str], i: &mut usize, keywords: &[&str]) {
295    let matched = keywords
296        .iter()
297        .enumerate()
298        .all(|(offset, keyword)| word_is(words, *i + offset, keyword));
299    if matched {
300        *i += keywords.len();
301    }
302}
303
304/// Adds a table name, unquoted, unless an equal-ignoring-case one is there.
305fn add(live: &mut Vec<String>, name: &str) {
306    let name = unquote(name);
307    if !live.iter().any(|table| table.eq_ignore_ascii_case(&name)) {
308        live.push(name);
309    }
310}
311
312/// Removes a table name, ignoring case; a name that is not there is a drop of
313/// a table another module or an earlier deployment owns, and is not our
314/// business.
315fn remove(live: &mut Vec<String>, name: &str) {
316    let name = unquote(name);
317    live.retain(|table| !table.eq_ignore_ascii_case(&name));
318}
319
320/// `"users"` → `users`. Anything else is returned unchanged, including a
321/// half-quoted name, which will fail the comparison rather than be guessed at.
322fn unquote(name: &str) -> String {
323    name.strip_prefix('"')
324        .and_then(|rest| rest.strip_suffix('"'))
325        .unwrap_or(name)
326        .to_owned()
327}
328
329/// Replaces every SQL comment and single-quoted string literal with spaces,
330/// leaving the executable DDL and its byte positions alone.
331///
332/// Block comments nest in Postgres and do not in SQLite; this counts depth,
333/// which is right for Postgres and harmless for SQLite (a migration relying
334/// on the difference is not portable anyway). An unterminated comment or
335/// literal swallows the rest of the input rather than panicking — the
336/// database will reject it long before the lint matters.
337fn strip_non_ddl(sql: &str) -> String {
338    let bytes = sql.as_bytes();
339    let mut out = String::with_capacity(sql.len());
340    let mut i = 0;
341    while i < bytes.len() {
342        // `--` to end of line.
343        if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
344            while i < bytes.len() && bytes[i] != b'\n' {
345                out.push(' ');
346                i += 1;
347            }
348            continue;
349        }
350        // `/* ... */`, nesting.
351        if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
352            let mut depth = 1_usize;
353            out.push_str("  ");
354            i += 2;
355            while i < bytes.len() && depth > 0 {
356                if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
357                    depth += 1;
358                    out.push_str("  ");
359                    i += 2;
360                } else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
361                    depth -= 1;
362                    out.push_str("  ");
363                    i += 2;
364                } else {
365                    out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
366                    i += 1;
367                }
368            }
369            continue;
370        }
371        // `'...'`, with `''` as the escape.
372        if bytes[i] == b'\'' {
373            out.push(' ');
374            i += 1;
375            while i < bytes.len() {
376                if bytes[i] == b'\'' {
377                    if bytes.get(i + 1) == Some(&b'\'') {
378                        out.push_str("  ");
379                        i += 2;
380                        continue;
381                    }
382                    out.push(' ');
383                    i += 1;
384                    break;
385                }
386                out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
387                i += 1;
388            }
389            continue;
390        }
391        // Not a byte we rewrite: copy the whole UTF-8 character.
392        let start = i;
393        i += 1;
394        while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
395            i += 1;
396        }
397        out.push_str(&sql[start..i]);
398    }
399    out
400}
401
402#[cfg(test)]
403mod tests {
404    use super::lint_portable_sql;
405
406    #[test]
407    fn clean_portable_sql_passes() {
408        let sql = "CREATE TABLE t (id TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0, \
409                   created_at TEXT NOT NULL, UNIQUE(id));";
410        assert!(lint_portable_sql(sql).is_empty());
411    }
412
413    #[test]
414    fn every_banned_token_is_flagged() {
415        let sql = "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, \
416                   at datetime('now'), s SERIAL, n NOW(), j json_extract(x,'$'), c `col`);";
417        let found = lint_portable_sql(sql);
418        let tokens: Vec<&str> = found.iter().map(|(token, _)| *token).collect();
419        assert_eq!(
420            tokens,
421            [
422                "AUTOINCREMENT",
423                "datetime(",
424                "SERIAL",
425                "NOW()",
426                "json_extract",
427                "`"
428            ]
429        );
430    }
431
432    #[test]
433    fn matching_is_case_insensitive() {
434        assert!(
435            lint_portable_sql("SELECT autoincrement FROM t;")
436                .iter()
437                .any(|(token, _)| *token == "AUTOINCREMENT")
438        );
439    }
440
441    #[test]
442    fn prose_in_a_comment_is_not_ddl() {
443        // The regression: `module-cms` explains its tables in a `--`
444        // comment, and markdown-style backticks around a table name failed
445        // the whole module for MySQL quoting it does not use.
446        let sql = "-- A small content store. `cms_item` is the working copy;\n\
447                   -- `cms_revision` is append-only history (stored as BLOB\n\
448                   -- in some other database, but not here).\n\
449                   CREATE TABLE cms_item (id TEXT PRIMARY KEY);";
450        assert_eq!(lint_portable_sql(sql), vec![]);
451    }
452
453    #[test]
454    fn a_block_comment_is_not_ddl_and_may_nest() {
455        let sql = "/* uses `backticks` and /* nests, mentioning SERIAL */ still inside */ \
456                   CREATE TABLE t (id TEXT PRIMARY KEY);";
457        assert_eq!(lint_portable_sql(sql), vec![]);
458    }
459
460    #[test]
461    fn a_string_literal_is_data_not_ddl() {
462        // Storing the word as a value says nothing about the column type.
463        let sql = "INSERT INTO kinds (name) VALUES ('blob'), ('serial'), \
464                   ('it''s NOW() in prose');";
465        assert_eq!(lint_portable_sql(sql), vec![]);
466    }
467
468    #[test]
469    fn stripping_comments_does_not_hide_real_ddl() {
470        // The other half: the same tokens outside a comment still fail, and
471        // a comment must not swallow the statement that follows it.
472        let sql = "-- a note about ids\n\
473                   CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, c `col` TEXT);";
474        let tokens: Vec<&str> = lint_portable_sql(sql)
475            .iter()
476            .map(|(token, _)| *token)
477            .collect();
478        assert_eq!(tokens, ["AUTOINCREMENT", "`"]);
479    }
480
481    #[test]
482    fn a_double_quoted_identifier_is_still_ddl() {
483        // Double quotes are portable, so they are not stripped: a banned
484        // token inside them is a real column type, not prose.
485        let tokens: Vec<&str> = lint_portable_sql(r#"CREATE TABLE t ("data" BLOB);"#)
486            .iter()
487            .map(|(token, _)| *token)
488            .collect();
489        assert_eq!(tokens, ["BLOB"]);
490    }
491
492    #[test]
493    fn an_unterminated_comment_or_literal_does_not_panic() {
494        // Malformed SQL is the database's problem, not the lint's.
495        assert_eq!(lint_portable_sql("/* never closed"), vec![]);
496        assert_eq!(lint_portable_sql("SELECT 'never closed"), vec![]);
497        assert_eq!(lint_portable_sql("-- never newline"), vec![]);
498        // Multi-byte characters inside and outside the stripped spans.
499        assert_eq!(
500            lint_portable_sql("-- naïve ünicode ✓\nCREATE TABLE t (id TEXT);"),
501            vec![]
502        );
503    }
504
505    #[test]
506    fn now_without_parens_is_not_flagged() {
507        // `NOW()` is the dialect function; the word "now" in prose or a
508        // column name is not.
509        assert!(lint_portable_sql("SELECT now FROM t;").is_empty());
510    }
511}
512
513#[cfg(test)]
514mod created_tables_tests {
515    use super::created_tables;
516
517    #[test]
518    fn a_plain_create_is_found() {
519        assert_eq!(
520            created_tables("CREATE TABLE deletion_jobs (id TEXT PRIMARY KEY);"),
521            ["deletion_jobs"]
522        );
523        assert_eq!(
524            created_tables("create table if not exists users(id TEXT);"),
525            ["users"]
526        );
527        // No space before the column list, and a quoted name.
528        assert_eq!(
529            created_tables(r#"CREATE TABLE "users"(id TEXT);"#),
530            ["users"]
531        );
532    }
533
534    #[test]
535    fn every_create_in_one_set_is_found_in_order() {
536        // The anti-vacuity case at the scan's own level: a scan that stopped
537        // matching would return an empty vec and every absence assertion
538        // built on it would keep passing.
539        let sql = "CREATE TABLE a (id TEXT);\n\
540                   CREATE TABLE IF NOT EXISTS b (id TEXT);\n\
541                   CREATE UNLOGGED TABLE c (id TEXT);";
542        assert_eq!(created_tables(sql), ["a", "b", "c"]);
543    }
544
545    #[test]
546    fn an_index_view_or_trigger_is_not_a_table() {
547        // `CREATE UNIQUE INDEX deletion_jobs_confirmation_code ON deletion_jobs`
548        // is the real line this protects: a scan that read the word after
549        // `CREATE` would report the index name as a table.
550        let sql = "CREATE UNIQUE INDEX deletion_jobs_confirmation_code ON deletion_jobs (code);\n\
551                   CREATE INDEX by_status ON deletion_jobs (status);\n\
552                   CREATE VIEW live_jobs AS SELECT * FROM deletion_jobs;\n\
553                   CREATE TRIGGER t AFTER INSERT ON deletion_jobs BEGIN SELECT 1; END;";
554        assert_eq!(created_tables(sql), Vec::<String>::new());
555    }
556
557    #[test]
558    fn a_create_table_in_a_comment_or_a_string_is_not_ddl() {
559        // The regression `lint_portable_sql` already learned once: a migration
560        // that explains its own schema in prose must not report the tables the
561        // prose names. `deletion_jobs` opens with twelve lines of exactly that.
562        let sql = "-- CREATE TABLE ghost (id TEXT) is what this replaces.\n\
563                   /* CREATE TABLE also_a_ghost (id TEXT); */\n\
564                   INSERT INTO note (body) VALUES ('CREATE TABLE quoted_ghost (id TEXT)');\n\
565                   CREATE TABLE real_one (id TEXT);";
566        assert_eq!(created_tables(sql), ["real_one"]);
567    }
568
569    #[test]
570    fn a_temporary_table_is_not_the_modules_table() {
571        // It is gone with the connection, so no export could read it and no
572        // erasure could miss it. Requiring it in `tables()` would put a name
573        // in the export list that names nothing.
574        let sql = "CREATE TEMP TABLE scratch (id TEXT);\n\
575                   CREATE TEMPORARY TABLE also_scratch (id TEXT);\n\
576                   CREATE GLOBAL TEMPORARY TABLE still_scratch (id TEXT);\n\
577                   CREATE TABLE kept (id TEXT);";
578        assert_eq!(created_tables(sql), ["kept"]);
579    }
580
581    #[test]
582    fn the_rebuild_pattern_leaves_one_table() {
583        // `auth-core 0003` and `waitlist 0005`, both on main and both
584        // unchangeable: SQLite cannot relax a NOT NULL, so the table is
585        // rebuilt under another name and renamed over the original.
586        let sql = "CREATE TABLE IF NOT EXISTS single_use_tokens_rebuild (id TEXT);\n\
587                   INSERT INTO single_use_tokens_rebuild SELECT * FROM single_use_tokens;\n\
588                   DROP TABLE single_use_tokens;\n\
589                   ALTER TABLE single_use_tokens_rebuild RENAME TO single_use_tokens;";
590        assert_eq!(created_tables(sql), ["single_use_tokens"]);
591    }
592
593    #[test]
594    fn a_renamed_column_does_not_rename_the_table() {
595        let sql = "CREATE TABLE notes (id TEXT, body TEXT);\n\
596                   ALTER TABLE notes RENAME COLUMN body TO text;\n\
597                   ALTER TABLE notes ADD COLUMN amr TEXT;";
598        assert_eq!(created_tables(sql), ["notes"]);
599    }
600
601    #[test]
602    fn a_table_created_and_dropped_is_not_left_behind() {
603        let sql = "CREATE TABLE gone (id TEXT);\n\
604                   CREATE TABLE kept (id TEXT);\n\
605                   DROP TABLE IF EXISTS gone;\n\
606                   DROP INDEX kept_by_id;";
607        assert_eq!(created_tables(sql), ["kept"]);
608    }
609
610    #[test]
611    fn the_same_table_twice_is_reported_once() {
612        // Two migrations may both guard with IF NOT EXISTS.
613        let sql = "CREATE TABLE IF NOT EXISTS notes (id TEXT);\n\
614                   CREATE TABLE IF NOT EXISTS NOTES (id TEXT);";
615        assert_eq!(created_tables(sql), ["notes"]);
616    }
617
618    #[test]
619    fn a_name_the_scan_cannot_read_is_returned_rather_than_dropped() {
620        // The safe direction to be uncertain in: it fails the comparison
621        // loudly instead of escaping it silently.
622        assert_eq!(
623            created_tables("CREATE TABLE public.users (id TEXT);"),
624            ["public.users"]
625        );
626    }
627
628    #[test]
629    fn truncated_or_malformed_sql_does_not_panic() {
630        for sql in [
631            "CREATE",
632            "CREATE TABLE",
633            "CREATE TABLE IF NOT EXISTS",
634            "DROP TABLE",
635            "ALTER TABLE notes RENAME",
636            "ALTER TABLE notes RENAME TO",
637            "-- CREATE TABLE never_closed",
638            "CREATE TABLE t (id TEXT); -- naïve ünicode ✓",
639        ] {
640            let _ = created_tables(sql);
641        }
642    }
643}
644
645#[cfg(test)]
646mod card_data_tests {
647    use super::{card_data_hit, lint_card_data};
648
649    #[test]
650    fn a_card_number_column_is_flagged() {
651        let sql = "CREATE TABLE payment (id TEXT PRIMARY KEY, card_number TEXT)";
652        assert_eq!(lint_card_data(sql).len(), 1);
653        assert_eq!(lint_card_data(sql)[0].0, "card_number");
654        assert!(card_data_hit("cvv").is_some());
655        assert!(card_data_hit("exp_month").is_some());
656    }
657
658    #[test]
659    fn card_data_only_in_a_comment_or_string_is_not_flagged() {
660        // Documenting the rule must not trip it.
661        let sql = "CREATE TABLE t (id TEXT) -- never store card_number here\n";
662        assert!(lint_card_data(sql).is_empty());
663        let sql2 = "INSERT INTO note (body) VALUES ('do not store card_number')";
664        assert!(lint_card_data(sql2).is_empty());
665    }
666
667    #[test]
668    fn ambiguous_words_do_not_false_positive() {
669        // A music track, an audio pan, a session expiry are all legitimate.
670        for sql in [
671            "CREATE TABLE song (id TEXT, track INTEGER)",
672            "CREATE TABLE mix (id TEXT, pan REAL)",
673            "CREATE TABLE session (id TEXT, session_expiry TEXT)",
674            "CREATE TABLE t (id TEXT, token_expiry TEXT)",
675        ] {
676            assert!(lint_card_data(sql).is_empty(), "false positive on: {sql}");
677        }
678    }
679}