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
65const 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#[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#[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
120const CREATE_MODIFIERS: &[&str] = &[
129 "temp",
130 "temporary",
131 "unlogged",
132 "global",
133 "local",
134 "virtual",
135];
136
137#[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
223fn 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
231fn 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
255fn 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
268fn 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
292fn 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
304fn 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
312fn remove(live: &mut Vec<String>, name: &str) {
316 let name = unquote(name);
317 live.retain(|table| !table.eq_ignore_ascii_case(&name));
318}
319
320fn unquote(name: &str) -> String {
323 name.strip_prefix('"')
324 .and_then(|rest| rest.strip_suffix('"'))
325 .unwrap_or(name)
326 .to_owned()
327}
328
329fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}