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
120fn strip_non_ddl(sql: &str) -> String {
129 let bytes = sql.as_bytes();
130 let mut out = String::with_capacity(sql.len());
131 let mut i = 0;
132 while i < bytes.len() {
133 if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
135 while i < bytes.len() && bytes[i] != b'\n' {
136 out.push(' ');
137 i += 1;
138 }
139 continue;
140 }
141 if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
143 let mut depth = 1_usize;
144 out.push_str(" ");
145 i += 2;
146 while i < bytes.len() && depth > 0 {
147 if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
148 depth += 1;
149 out.push_str(" ");
150 i += 2;
151 } else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
152 depth -= 1;
153 out.push_str(" ");
154 i += 2;
155 } else {
156 out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
157 i += 1;
158 }
159 }
160 continue;
161 }
162 if bytes[i] == b'\'' {
164 out.push(' ');
165 i += 1;
166 while i < bytes.len() {
167 if bytes[i] == b'\'' {
168 if bytes.get(i + 1) == Some(&b'\'') {
169 out.push_str(" ");
170 i += 2;
171 continue;
172 }
173 out.push(' ');
174 i += 1;
175 break;
176 }
177 out.push(if bytes[i] == b'\n' { '\n' } else { ' ' });
178 i += 1;
179 }
180 continue;
181 }
182 let start = i;
184 i += 1;
185 while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
186 i += 1;
187 }
188 out.push_str(&sql[start..i]);
189 }
190 out
191}
192
193#[cfg(test)]
194mod tests {
195 use super::lint_portable_sql;
196
197 #[test]
198 fn clean_portable_sql_passes() {
199 let sql = "CREATE TABLE t (id TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0, \
200 created_at TEXT NOT NULL, UNIQUE(id));";
201 assert!(lint_portable_sql(sql).is_empty());
202 }
203
204 #[test]
205 fn every_banned_token_is_flagged() {
206 let sql = "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, \
207 at datetime('now'), s SERIAL, n NOW(), j json_extract(x,'$'), c `col`);";
208 let found = lint_portable_sql(sql);
209 let tokens: Vec<&str> = found.iter().map(|(token, _)| *token).collect();
210 assert_eq!(
211 tokens,
212 [
213 "AUTOINCREMENT",
214 "datetime(",
215 "SERIAL",
216 "NOW()",
217 "json_extract",
218 "`"
219 ]
220 );
221 }
222
223 #[test]
224 fn matching_is_case_insensitive() {
225 assert!(
226 lint_portable_sql("SELECT autoincrement FROM t;")
227 .iter()
228 .any(|(token, _)| *token == "AUTOINCREMENT")
229 );
230 }
231
232 #[test]
233 fn prose_in_a_comment_is_not_ddl() {
234 let sql = "-- A small content store. `cms_item` is the working copy;\n\
238 -- `cms_revision` is append-only history (stored as BLOB\n\
239 -- in some other database, but not here).\n\
240 CREATE TABLE cms_item (id TEXT PRIMARY KEY);";
241 assert_eq!(lint_portable_sql(sql), vec![]);
242 }
243
244 #[test]
245 fn a_block_comment_is_not_ddl_and_may_nest() {
246 let sql = "/* uses `backticks` and /* nests, mentioning SERIAL */ still inside */ \
247 CREATE TABLE t (id TEXT PRIMARY KEY);";
248 assert_eq!(lint_portable_sql(sql), vec![]);
249 }
250
251 #[test]
252 fn a_string_literal_is_data_not_ddl() {
253 let sql = "INSERT INTO kinds (name) VALUES ('blob'), ('serial'), \
255 ('it''s NOW() in prose');";
256 assert_eq!(lint_portable_sql(sql), vec![]);
257 }
258
259 #[test]
260 fn stripping_comments_does_not_hide_real_ddl() {
261 let sql = "-- a note about ids\n\
264 CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, c `col` TEXT);";
265 let tokens: Vec<&str> = lint_portable_sql(sql)
266 .iter()
267 .map(|(token, _)| *token)
268 .collect();
269 assert_eq!(tokens, ["AUTOINCREMENT", "`"]);
270 }
271
272 #[test]
273 fn a_double_quoted_identifier_is_still_ddl() {
274 let tokens: Vec<&str> = lint_portable_sql(r#"CREATE TABLE t ("data" BLOB);"#)
277 .iter()
278 .map(|(token, _)| *token)
279 .collect();
280 assert_eq!(tokens, ["BLOB"]);
281 }
282
283 #[test]
284 fn an_unterminated_comment_or_literal_does_not_panic() {
285 assert_eq!(lint_portable_sql("/* never closed"), vec![]);
287 assert_eq!(lint_portable_sql("SELECT 'never closed"), vec![]);
288 assert_eq!(lint_portable_sql("-- never newline"), vec![]);
289 assert_eq!(
291 lint_portable_sql("-- naïve ünicode ✓\nCREATE TABLE t (id TEXT);"),
292 vec![]
293 );
294 }
295
296 #[test]
297 fn now_without_parens_is_not_flagged() {
298 assert!(lint_portable_sql("SELECT now FROM t;").is_empty());
301 }
302}
303
304#[cfg(test)]
305mod card_data_tests {
306 use super::{card_data_hit, lint_card_data};
307
308 #[test]
309 fn a_card_number_column_is_flagged() {
310 let sql = "CREATE TABLE payment (id TEXT PRIMARY KEY, card_number TEXT)";
311 assert_eq!(lint_card_data(sql).len(), 1);
312 assert_eq!(lint_card_data(sql)[0].0, "card_number");
313 assert!(card_data_hit("cvv").is_some());
314 assert!(card_data_hit("exp_month").is_some());
315 }
316
317 #[test]
318 fn card_data_only_in_a_comment_or_string_is_not_flagged() {
319 let sql = "CREATE TABLE t (id TEXT) -- never store card_number here\n";
321 assert!(lint_card_data(sql).is_empty());
322 let sql2 = "INSERT INTO note (body) VALUES ('do not store card_number')";
323 assert!(lint_card_data(sql2).is_empty());
324 }
325
326 #[test]
327 fn ambiguous_words_do_not_false_positive() {
328 for sql in [
330 "CREATE TABLE song (id TEXT, track INTEGER)",
331 "CREATE TABLE mix (id TEXT, pan REAL)",
332 "CREATE TABLE session (id TEXT, session_expiry TEXT)",
333 "CREATE TABLE t (id TEXT, token_expiry TEXT)",
334 ] {
335 assert!(lint_card_data(sql).is_empty(), "false positive on: {sql}");
336 }
337 }
338}