1#[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
65pub const 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 "track2",
92 "magstripe",
93 "magnetic_stripe",
94 "cardholder",
101 "expiry_month",
102 "expiry_year",
103];
104
105#[must_use]
110pub fn card_data_hit(text: &str) -> Option<&'static str> {
111 let hay = text.to_ascii_lowercase();
112 CARD_DATA.iter().copied().find(|frag| hay.contains(frag))
113}
114
115#[must_use]
119pub fn lint_card_data(sql: &str) -> Vec<(&'static str, &'static str)> {
120 match card_data_hit(&strip_non_ddl(sql)) {
121 Some(frag) => vec![(
122 frag,
123 "looks like card data; with a normal Stripe integration the card never \
124 reaches a backend — store only Stripe's identifiers",
125 )],
126 None => Vec::new(),
127 }
128}
129
130const CREATE_MODIFIERS: &[&str] = &[
139 "temp",
140 "temporary",
141 "unlogged",
142 "global",
143 "local",
144 "virtual",
145];
146
147#[must_use]
210pub fn created_tables(sql: &str) -> Vec<String> {
211 let ddl = strip_non_ddl(sql);
212 let words: Vec<&str> = ddl
213 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '"' || c == '.'))
214 .filter(|word| !word.is_empty())
215 .collect();
216
217 let mut live: Vec<String> = Vec::new();
218 let mut i = 0;
219 while i < words.len() {
220 let word = words[i];
221 i += 1;
222 if word.eq_ignore_ascii_case("create") {
223 create_table(&words, &mut i, &mut live);
224 } else if word.eq_ignore_ascii_case("drop") {
225 drop_table(&words, &mut i, &mut live);
226 } else if word.eq_ignore_ascii_case("alter") {
227 rename_table(&words, &mut i, &mut live);
228 }
229 }
230 live
231}
232
233fn word_is(words: &[&str], at: usize, keyword: &str) -> bool {
236 words
237 .get(at)
238 .is_some_and(|word| word.eq_ignore_ascii_case(keyword))
239}
240
241fn create_table(words: &[&str], i: &mut usize, live: &mut Vec<String>) {
243 let mut temporary = false;
244 while CREATE_MODIFIERS
245 .iter()
246 .any(|modifier| word_is(words, *i, modifier))
247 {
248 temporary |= word_is(words, *i, "temp") || word_is(words, *i, "temporary");
249 *i += 1;
250 }
251 if !word_is(words, *i, "table") {
252 return;
253 }
254 *i += 1;
255 skip_keywords(words, i, &["if", "not", "exists"]);
256 let Some(name) = words.get(*i).copied() else {
257 return;
258 };
259 *i += 1;
260 if !temporary {
261 add(live, name);
262 }
263}
264
265fn drop_table(words: &[&str], i: &mut usize, live: &mut Vec<String>) {
267 if !word_is(words, *i, "table") {
268 return;
269 }
270 *i += 1;
271 skip_keywords(words, i, &["if", "exists"]);
272 if let Some(name) = words.get(*i).copied() {
273 *i += 1;
274 remove(live, name);
275 }
276}
277
278fn rename_table(words: &[&str], i: &mut usize, live: &mut Vec<String>) {
282 if !word_is(words, *i, "table") {
283 return;
284 }
285 *i += 1;
286 skip_keywords(words, i, &["if", "exists"]);
287 let Some(from) = words.get(*i).copied() else {
288 return;
289 };
290 *i += 1;
291 if !(word_is(words, *i, "rename") && word_is(words, *i + 1, "to")) {
292 return;
293 }
294 let Some(to) = words.get(*i + 2).copied() else {
295 return;
296 };
297 *i += 3;
298 remove(live, from);
299 add(live, to);
300}
301
302fn skip_keywords(words: &[&str], i: &mut usize, keywords: &[&str]) {
305 let matched = keywords
306 .iter()
307 .enumerate()
308 .all(|(offset, keyword)| word_is(words, *i + offset, keyword));
309 if matched {
310 *i += keywords.len();
311 }
312}
313
314fn add(live: &mut Vec<String>, name: &str) {
316 let name = unquote(name);
317 if !live.iter().any(|table| table.eq_ignore_ascii_case(&name)) {
318 live.push(name);
319 }
320}
321
322fn remove(live: &mut Vec<String>, name: &str) {
326 let name = unquote(name);
327 live.retain(|table| !table.eq_ignore_ascii_case(&name));
328}
329
330fn unquote(name: &str) -> String {
333 name.strip_prefix('"')
334 .and_then(|rest| rest.strip_suffix('"'))
335 .unwrap_or(name)
336 .to_owned()
337}
338
339fn strip_non_ddl(sql: &str) -> String {
348 let bytes = sql.as_bytes();
349 let mut out = String::with_capacity(sql.len());
350 let mut i = 0;
351 while i < bytes.len() {
352 if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
354 while i < bytes.len() && bytes[i] != b'\n' {
355 out.push(' ');
356 i += 1;
357 }
358 continue;
359 }
360 if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
362 let mut depth = 1_usize;
363 out.push_str(" ");
364 i += 2;
365 while i < bytes.len() && depth > 0 {
366 if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
367 depth += 1;
368 out.push_str(" ");
369 i += 2;
370 } else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
371 depth -= 1;
372 out.push_str(" ");
373 i += 2;
374 } else {
375 out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
376 i += 1;
377 }
378 }
379 continue;
380 }
381 if bytes[i] == b'\'' {
383 out.push(' ');
384 i += 1;
385 while i < bytes.len() {
386 if bytes[i] == b'\'' {
387 if bytes.get(i + 1) == Some(&b'\'') {
388 out.push_str(" ");
389 i += 2;
390 continue;
391 }
392 out.push(' ');
393 i += 1;
394 break;
395 }
396 out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
397 i += 1;
398 }
399 continue;
400 }
401 let start = i;
403 i += 1;
404 while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
405 i += 1;
406 }
407 out.push_str(&sql[start..i]);
408 }
409 out
410}
411
412#[cfg(test)]
413mod tests {
414 use super::lint_portable_sql;
415
416 #[test]
417 fn clean_portable_sql_passes() {
418 let sql = "CREATE TABLE t (id TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0, \
419 created_at TEXT NOT NULL, UNIQUE(id));";
420 assert!(lint_portable_sql(sql).is_empty());
421 }
422
423 #[test]
424 fn every_banned_token_is_flagged() {
425 let sql = "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, \
426 at datetime('now'), s SERIAL, n NOW(), j json_extract(x,'$'), c `col`);";
427 let found = lint_portable_sql(sql);
428 let tokens: Vec<&str> = found.iter().map(|(token, _)| *token).collect();
429 assert_eq!(
430 tokens,
431 [
432 "AUTOINCREMENT",
433 "datetime(",
434 "SERIAL",
435 "NOW()",
436 "json_extract",
437 "`"
438 ]
439 );
440 }
441
442 #[test]
443 fn matching_is_case_insensitive() {
444 assert!(
445 lint_portable_sql("SELECT autoincrement FROM t;")
446 .iter()
447 .any(|(token, _)| *token == "AUTOINCREMENT")
448 );
449 }
450
451 #[test]
452 fn prose_in_a_comment_is_not_ddl() {
453 let sql = "-- A small content store. `cms_item` is the working copy;\n\
457 -- `cms_revision` is append-only history (stored as BLOB\n\
458 -- in some other database, but not here).\n\
459 CREATE TABLE cms_item (id TEXT PRIMARY KEY);";
460 assert_eq!(lint_portable_sql(sql), vec![]);
461 }
462
463 #[test]
464 fn a_block_comment_is_not_ddl_and_may_nest() {
465 let sql = "/* uses `backticks` and /* nests, mentioning SERIAL */ still inside */ \
466 CREATE TABLE t (id TEXT PRIMARY KEY);";
467 assert_eq!(lint_portable_sql(sql), vec![]);
468 }
469
470 #[test]
471 fn a_string_literal_is_data_not_ddl() {
472 let sql = "INSERT INTO kinds (name) VALUES ('blob'), ('serial'), \
474 ('it''s NOW() in prose');";
475 assert_eq!(lint_portable_sql(sql), vec![]);
476 }
477
478 #[test]
479 fn stripping_comments_does_not_hide_real_ddl() {
480 let sql = "-- a note about ids\n\
483 CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, c `col` TEXT);";
484 let tokens: Vec<&str> = lint_portable_sql(sql)
485 .iter()
486 .map(|(token, _)| *token)
487 .collect();
488 assert_eq!(tokens, ["AUTOINCREMENT", "`"]);
489 }
490
491 #[test]
492 fn a_double_quoted_identifier_is_still_ddl() {
493 let tokens: Vec<&str> = lint_portable_sql(r#"CREATE TABLE t ("data" BLOB);"#)
496 .iter()
497 .map(|(token, _)| *token)
498 .collect();
499 assert_eq!(tokens, ["BLOB"]);
500 }
501
502 #[test]
503 fn an_unterminated_comment_or_literal_does_not_panic() {
504 assert_eq!(lint_portable_sql("/* never closed"), vec![]);
506 assert_eq!(lint_portable_sql("SELECT 'never closed"), vec![]);
507 assert_eq!(lint_portable_sql("-- never newline"), vec![]);
508 assert_eq!(
510 lint_portable_sql("-- naïve ünicode ✓\nCREATE TABLE t (id TEXT);"),
511 vec![]
512 );
513 }
514
515 #[test]
516 fn now_without_parens_is_not_flagged() {
517 assert!(lint_portable_sql("SELECT now FROM t;").is_empty());
520 }
521}
522
523#[cfg(test)]
524mod created_tables_tests {
525 use super::created_tables;
526
527 #[test]
528 fn a_plain_create_is_found() {
529 assert_eq!(
530 created_tables("CREATE TABLE deletion_jobs (id TEXT PRIMARY KEY);"),
531 ["deletion_jobs"]
532 );
533 assert_eq!(
534 created_tables("create table if not exists users(id TEXT);"),
535 ["users"]
536 );
537 assert_eq!(
539 created_tables(r#"CREATE TABLE "users"(id TEXT);"#),
540 ["users"]
541 );
542 }
543
544 #[test]
545 fn every_create_in_one_set_is_found_in_order() {
546 let sql = "CREATE TABLE a (id TEXT);\n\
550 CREATE TABLE IF NOT EXISTS b (id TEXT);\n\
551 CREATE UNLOGGED TABLE c (id TEXT);";
552 assert_eq!(created_tables(sql), ["a", "b", "c"]);
553 }
554
555 #[test]
556 fn an_index_view_or_trigger_is_not_a_table() {
557 let sql = "CREATE UNIQUE INDEX deletion_jobs_confirmation_code ON deletion_jobs (code);\n\
561 CREATE INDEX by_status ON deletion_jobs (status);\n\
562 CREATE VIEW live_jobs AS SELECT * FROM deletion_jobs;\n\
563 CREATE TRIGGER t AFTER INSERT ON deletion_jobs BEGIN SELECT 1; END;";
564 assert_eq!(created_tables(sql), Vec::<String>::new());
565 }
566
567 #[test]
568 fn a_create_table_in_a_comment_or_a_string_is_not_ddl() {
569 let sql = "-- CREATE TABLE ghost (id TEXT) is what this replaces.\n\
573 /* CREATE TABLE also_a_ghost (id TEXT); */\n\
574 INSERT INTO note (body) VALUES ('CREATE TABLE quoted_ghost (id TEXT)');\n\
575 CREATE TABLE real_one (id TEXT);";
576 assert_eq!(created_tables(sql), ["real_one"]);
577 }
578
579 #[test]
580 fn a_temporary_table_is_not_the_modules_table() {
581 let sql = "CREATE TEMP TABLE scratch (id TEXT);\n\
585 CREATE TEMPORARY TABLE also_scratch (id TEXT);\n\
586 CREATE GLOBAL TEMPORARY TABLE still_scratch (id TEXT);\n\
587 CREATE TABLE kept (id TEXT);";
588 assert_eq!(created_tables(sql), ["kept"]);
589 }
590
591 #[test]
592 fn the_rebuild_pattern_leaves_one_table() {
593 let sql = "CREATE TABLE IF NOT EXISTS single_use_tokens_rebuild (id TEXT);\n\
597 INSERT INTO single_use_tokens_rebuild SELECT * FROM single_use_tokens;\n\
598 DROP TABLE single_use_tokens;\n\
599 ALTER TABLE single_use_tokens_rebuild RENAME TO single_use_tokens;";
600 assert_eq!(created_tables(sql), ["single_use_tokens"]);
601 }
602
603 #[test]
604 fn a_renamed_column_does_not_rename_the_table() {
605 let sql = "CREATE TABLE notes (id TEXT, body TEXT);\n\
606 ALTER TABLE notes RENAME COLUMN body TO text;\n\
607 ALTER TABLE notes ADD COLUMN amr TEXT;";
608 assert_eq!(created_tables(sql), ["notes"]);
609 }
610
611 #[test]
612 fn a_table_created_and_dropped_is_not_left_behind() {
613 let sql = "CREATE TABLE gone (id TEXT);\n\
614 CREATE TABLE kept (id TEXT);\n\
615 DROP TABLE IF EXISTS gone;\n\
616 DROP INDEX kept_by_id;";
617 assert_eq!(created_tables(sql), ["kept"]);
618 }
619
620 #[test]
621 fn the_same_table_twice_is_reported_once() {
622 let sql = "CREATE TABLE IF NOT EXISTS notes (id TEXT);\n\
624 CREATE TABLE IF NOT EXISTS NOTES (id TEXT);";
625 assert_eq!(created_tables(sql), ["notes"]);
626 }
627
628 #[test]
629 fn a_name_the_scan_cannot_read_is_returned_rather_than_dropped() {
630 assert_eq!(
633 created_tables("CREATE TABLE public.users (id TEXT);"),
634 ["public.users"]
635 );
636 }
637
638 #[test]
639 fn truncated_or_malformed_sql_does_not_panic() {
640 for sql in [
641 "CREATE",
642 "CREATE TABLE",
643 "CREATE TABLE IF NOT EXISTS",
644 "DROP TABLE",
645 "ALTER TABLE notes RENAME",
646 "ALTER TABLE notes RENAME TO",
647 "-- CREATE TABLE never_closed",
648 "CREATE TABLE t (id TEXT); -- naïve ünicode ✓",
649 ] {
650 let _ = created_tables(sql);
651 }
652 }
653}
654
655#[cfg(test)]
656mod card_data_tests {
657 use super::{card_data_hit, lint_card_data};
658
659 #[test]
660 fn a_card_number_column_is_flagged() {
661 let sql = "CREATE TABLE payment (id TEXT PRIMARY KEY, card_number TEXT)";
662 assert_eq!(lint_card_data(sql).len(), 1);
663 assert_eq!(lint_card_data(sql)[0].0, "card_number");
664 assert!(card_data_hit("cvv").is_some());
665 assert!(card_data_hit("exp_month").is_some());
666 }
667
668 #[test]
669 fn card_data_only_in_a_comment_or_string_is_not_flagged() {
670 let sql = "CREATE TABLE t (id TEXT) -- never store card_number here\n";
672 assert!(lint_card_data(sql).is_empty());
673 let sql2 = "INSERT INTO note (body) VALUES ('do not store card_number')";
674 assert!(lint_card_data(sql2).is_empty());
675 }
676
677 #[test]
678 fn ambiguous_words_do_not_false_positive() {
679 for sql in [
681 "CREATE TABLE song (id TEXT, track INTEGER)",
682 "CREATE TABLE mix (id TEXT, pan REAL)",
683 "CREATE TABLE session (id TEXT, session_expiry TEXT)",
684 "CREATE TABLE t (id TEXT, token_expiry TEXT)",
685 ] {
686 assert!(lint_card_data(sql).is_empty(), "false positive on: {sql}");
687 }
688 }
689}