#[must_use]
pub fn lint_portable_sql(sql: &str) -> Vec<(&'static str, &'static str)> {
const BANNED: &[(&str, &str)] = &[
(
"AUTOINCREMENT",
"SQLite-only; use plain INTEGER PRIMARY KEY (ULID ids instead of autoincrement)",
),
(
"datetime(",
"dialect function; store ISO-8601 TEXT and compute in code",
),
("SERIAL", "Postgres-only; use TEXT ULID ids"),
(
"NOW()",
"dialect function; bind an ISO-8601 timestamp instead",
),
(
"json_extract",
"SQLite-only JSON function; parse JSON in code",
),
(
"`",
"backtick quoting is MySQL/SQLite; use double quotes or none",
),
(
"BLOB",
"SQLite-only type; Postgres has BYTEA — ship a migrations/postgres override",
),
];
let haystack = strip_non_ddl(sql).to_ascii_lowercase();
BANNED
.iter()
.filter(|(token, _)| {
if *token == "`" {
haystack.contains('`')
} else {
haystack.contains(&token.to_ascii_lowercase())
}
})
.copied()
.collect()
}
const CARD_DATA: &[&str] = &[
"card_number",
"cardnumber",
"card_no",
"cardno",
"primary_account_number",
"full_pan",
"cvv",
"cvc",
"cvv2",
"cvc2",
"card_cvv",
"card_cvc",
"exp_month",
"exp_year",
"card_expiry",
"expiry_date",
"expiration_date",
"track_data",
"magstripe",
"magnetic_stripe",
];
#[must_use]
pub fn card_data_hit(text: &str) -> Option<&'static str> {
let hay = text.to_ascii_lowercase();
CARD_DATA.iter().copied().find(|frag| hay.contains(frag))
}
#[must_use]
pub fn lint_card_data(sql: &str) -> Vec<(&'static str, &'static str)> {
match card_data_hit(&strip_non_ddl(sql)) {
Some(frag) => vec![(
frag,
"looks like card data; with a normal Stripe integration the card never \
reaches a backend — store only Stripe's identifiers",
)],
None => Vec::new(),
}
}
fn strip_non_ddl(sql: &str) -> String {
let bytes = sql.as_bytes();
let mut out = String::with_capacity(sql.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
while i < bytes.len() && bytes[i] != b'\n' {
out.push(' ');
i += 1;
}
continue;
}
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
let mut depth = 1_usize;
out.push_str(" ");
i += 2;
while i < bytes.len() && depth > 0 {
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
depth += 1;
out.push_str(" ");
i += 2;
} else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
depth -= 1;
out.push_str(" ");
i += 2;
} else {
out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
i += 1;
}
}
continue;
}
if bytes[i] == b'\'' {
out.push(' ');
i += 1;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
out.push_str(" ");
i += 2;
continue;
}
out.push(' ');
i += 1;
break;
}
out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
i += 1;
}
continue;
}
let start = i;
i += 1;
while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
i += 1;
}
out.push_str(&sql[start..i]);
}
out
}
#[cfg(test)]
mod tests {
use super::lint_portable_sql;
#[test]
fn clean_portable_sql_passes() {
let sql = "CREATE TABLE t (id TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0, \
created_at TEXT NOT NULL, UNIQUE(id));";
assert!(lint_portable_sql(sql).is_empty());
}
#[test]
fn every_banned_token_is_flagged() {
let sql = "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, \
at datetime('now'), s SERIAL, n NOW(), j json_extract(x,'$'), c `col`);";
let found = lint_portable_sql(sql);
let tokens: Vec<&str> = found.iter().map(|(token, _)| *token).collect();
assert_eq!(
tokens,
[
"AUTOINCREMENT",
"datetime(",
"SERIAL",
"NOW()",
"json_extract",
"`"
]
);
}
#[test]
fn matching_is_case_insensitive() {
assert!(
lint_portable_sql("SELECT autoincrement FROM t;")
.iter()
.any(|(token, _)| *token == "AUTOINCREMENT")
);
}
#[test]
fn prose_in_a_comment_is_not_ddl() {
let sql = "-- A small content store. `cms_item` is the working copy;\n\
-- `cms_revision` is append-only history (stored as BLOB\n\
-- in some other database, but not here).\n\
CREATE TABLE cms_item (id TEXT PRIMARY KEY);";
assert_eq!(lint_portable_sql(sql), vec![]);
}
#[test]
fn a_block_comment_is_not_ddl_and_may_nest() {
let sql = "/* uses `backticks` and /* nests, mentioning SERIAL */ still inside */ \
CREATE TABLE t (id TEXT PRIMARY KEY);";
assert_eq!(lint_portable_sql(sql), vec![]);
}
#[test]
fn a_string_literal_is_data_not_ddl() {
let sql = "INSERT INTO kinds (name) VALUES ('blob'), ('serial'), \
('it''s NOW() in prose');";
assert_eq!(lint_portable_sql(sql), vec![]);
}
#[test]
fn stripping_comments_does_not_hide_real_ddl() {
let sql = "-- a note about ids\n\
CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, c `col` TEXT);";
let tokens: Vec<&str> = lint_portable_sql(sql)
.iter()
.map(|(token, _)| *token)
.collect();
assert_eq!(tokens, ["AUTOINCREMENT", "`"]);
}
#[test]
fn a_double_quoted_identifier_is_still_ddl() {
let tokens: Vec<&str> = lint_portable_sql(r#"CREATE TABLE t ("data" BLOB);"#)
.iter()
.map(|(token, _)| *token)
.collect();
assert_eq!(tokens, ["BLOB"]);
}
#[test]
fn an_unterminated_comment_or_literal_does_not_panic() {
assert_eq!(lint_portable_sql("/* never closed"), vec![]);
assert_eq!(lint_portable_sql("SELECT 'never closed"), vec![]);
assert_eq!(lint_portable_sql("-- never newline"), vec![]);
assert_eq!(
lint_portable_sql("-- naïve ünicode ✓\nCREATE TABLE t (id TEXT);"),
vec![]
);
}
#[test]
fn now_without_parens_is_not_flagged() {
assert!(lint_portable_sql("SELECT now FROM t;").is_empty());
}
}
#[cfg(test)]
mod card_data_tests {
use super::{card_data_hit, lint_card_data};
#[test]
fn a_card_number_column_is_flagged() {
let sql = "CREATE TABLE payment (id TEXT PRIMARY KEY, card_number TEXT)";
assert_eq!(lint_card_data(sql).len(), 1);
assert_eq!(lint_card_data(sql)[0].0, "card_number");
assert!(card_data_hit("cvv").is_some());
assert!(card_data_hit("exp_month").is_some());
}
#[test]
fn card_data_only_in_a_comment_or_string_is_not_flagged() {
let sql = "CREATE TABLE t (id TEXT) -- never store card_number here\n";
assert!(lint_card_data(sql).is_empty());
let sql2 = "INSERT INTO note (body) VALUES ('do not store card_number')";
assert!(lint_card_data(sql2).is_empty());
}
#[test]
fn ambiguous_words_do_not_false_positive() {
for sql in [
"CREATE TABLE song (id TEXT, track INTEGER)",
"CREATE TABLE mix (id TEXT, pan REAL)",
"CREATE TABLE session (id TEXT, session_expiry TEXT)",
"CREATE TABLE t (id TEXT, token_expiry TEXT)",
] {
assert!(lint_card_data(sql).is_empty(), "false positive on: {sql}");
}
}
}