1use crate::SqlError;
4use std::ops::ControlFlow;
5
6use sqlparser::ast::{Expr, Function, ObjectName, Select, Statement, TableFactor, Visit, Visitor};
7use sqlparser::dialect::Dialect;
8use sqlparser::parser::Parser;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum StatementKind {
13 Select,
16 NonSelect,
19}
20
21pub fn validate_read_only(sql: &str, dialect: &impl Dialect) -> Result<StatementKind, SqlError> {
39 let trimmed = sql.trim();
40 if trimmed.is_empty() {
41 return Err(SqlError::ReadOnlyViolation);
42 }
43
44 let upper = trimmed.to_uppercase();
46 if upper.contains("INTO OUTFILE") || upper.contains("INTO DUMPFILE") {
47 return Err(SqlError::IntoOutfileBlocked);
48 }
49
50 let stmt = parse_single(trimmed, dialect)?.ok_or(SqlError::ReadOnlyViolation)?;
52
53 classify(&stmt)
54}
55
56fn classify(stmt: &Statement) -> Result<StatementKind, SqlError> {
58 match stmt {
59 Statement::Query(_) => {
60 check_data_modifying_queries(stmt)?;
61 check_dangerous_functions(stmt)?;
62 Ok(StatementKind::Select)
63 }
64 Statement::Explain { statement, .. } => {
66 classify(statement)?;
67 Ok(StatementKind::NonSelect)
68 }
69 Statement::ShowTables { .. }
70 | Statement::ShowColumns { .. }
71 | Statement::ShowCreate { .. }
72 | Statement::ShowVariable { .. }
73 | Statement::ShowVariables { .. }
74 | Statement::ShowStatus { .. }
75 | Statement::ShowDatabases { .. }
76 | Statement::ShowSchemas { .. }
77 | Statement::ShowCollation { .. }
78 | Statement::ShowFunctions { .. }
79 | Statement::ShowViews { .. }
80 | Statement::ShowObjects(_)
81 | Statement::ExplainTable { .. }
82 | Statement::Use(_) => Ok(StatementKind::NonSelect),
83 _ => Err(SqlError::ReadOnlyViolation),
84 }
85}
86
87pub fn validate_single_statement(sql: &str, dialect: &impl Dialect) -> Result<(), SqlError> {
98 parse_single(sql.trim(), dialect)?.ok_or_else(|| SqlError::Query("Query must contain a statement".into()))?;
99 Ok(())
100}
101
102fn parse_single(sql: &str, dialect: &impl Dialect) -> Result<Option<Statement>, SqlError> {
104 let mut statements =
105 Parser::parse_sql(dialect, sql).map_err(|e| SqlError::Query(format!("SQL parse error: {e}")))?;
106
107 if statements.len() > 1 {
108 return Err(SqlError::MultiStatement);
109 }
110 Ok(statements.pop())
111}
112
113fn check_data_modifying_queries(stmt: &Statement) -> Result<(), SqlError> {
115 if stmt.visit(&mut DataModifyingQueryChecker).is_break() {
116 return Err(SqlError::ReadOnlyViolation);
117 }
118 Ok(())
119}
120
121struct DataModifyingQueryChecker;
122
123impl Visitor for DataModifyingQueryChecker {
124 type Break = ();
125
126 fn pre_visit_statement(&mut self, stmt: &Statement) -> ControlFlow<Self::Break> {
127 if matches!(stmt, Statement::Query(_)) {
129 ControlFlow::Continue(())
130 } else {
131 ControlFlow::Break(())
132 }
133 }
134
135 fn pre_visit_select(&mut self, select: &Select) -> ControlFlow<Self::Break> {
136 if select.into.is_some() {
138 return ControlFlow::Break(());
139 }
140 ControlFlow::Continue(())
141 }
142}
143
144const DANGEROUS_FUNCTIONS: &[&str] = &[
148 "load_file",
150 "pg_read_file",
152 "pg_read_binary_file",
153 "pg_ls_dir",
154 "pg_stat_file",
155 "lo_import",
156 "lo_export",
157 "setval",
158 "readfile",
160 "writefile",
161 "load_extension",
162];
163
164const DANGEROUS_FUNCTION_PREFIXES: &[&str] = &["dblink"];
166
167fn check_dangerous_functions(stmt: &Statement) -> Result<(), SqlError> {
169 let mut checker = DangerousFunctionChecker { found: None };
170 let _ = stmt.visit(&mut checker);
171 if let Some(err) = checker.found {
172 return Err(err);
173 }
174 Ok(())
175}
176
177fn dangerous_function(name: &ObjectName) -> Option<SqlError> {
179 let last = name.0.last()?;
181 let func = last
182 .as_ident()
183 .map_or_else(|| last.to_string(), |ident| ident.value.clone());
184 let func = func.to_ascii_lowercase();
185
186 if func == "load_file" {
187 return Some(SqlError::LoadFileBlocked);
188 }
189 let blocked = DANGEROUS_FUNCTIONS.contains(&func.as_str())
190 || DANGEROUS_FUNCTION_PREFIXES
191 .iter()
192 .any(|prefix| func.starts_with(prefix));
193 blocked.then_some(SqlError::DangerousFunctionBlocked(func))
194}
195
196struct DangerousFunctionChecker {
197 found: Option<SqlError>,
198}
199
200impl DangerousFunctionChecker {
201 fn check(&mut self, name: &ObjectName) -> ControlFlow<()> {
202 if let Some(err) = dangerous_function(name) {
203 self.found = Some(err);
204 return ControlFlow::Break(());
205 }
206 ControlFlow::Continue(())
207 }
208}
209
210impl Visitor for DangerousFunctionChecker {
211 type Break = ();
212
213 fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<Self::Break> {
214 if let Expr::Function(Function { name, .. }) = expr {
215 return self.check(name);
216 }
217 ControlFlow::Continue(())
218 }
219
220 fn pre_visit_table_factor(&mut self, table_factor: &TableFactor) -> ControlFlow<Self::Break> {
221 match table_factor {
223 TableFactor::Table {
224 name, args: Some(_), ..
225 }
226 | TableFactor::Function { name, .. } => self.check(name),
227 _ => ControlFlow::Continue(()),
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use sqlparser::dialect::{MySqlDialect, PostgreSqlDialect, SQLiteDialect};
235
236 use super::*;
237
238 const MYSQL: MySqlDialect = MySqlDialect {};
239 const POSTGRES: PostgreSqlDialect = PostgreSqlDialect {};
240 const SQLITE: SQLiteDialect = SQLiteDialect {};
241
242 const DIALECT: MySqlDialect = MySqlDialect {};
243
244 #[test]
247 fn classifies_select_vs_non_select() {
248 assert_eq!(validate_read_only("SELECT 1", &DIALECT).unwrap(), StatementKind::Select,);
250 assert_eq!(
251 validate_read_only("WITH x AS (SELECT 1) SELECT * FROM x", &DIALECT).unwrap(),
252 StatementKind::Select,
253 );
254 assert_eq!(
255 validate_read_only("SELECT 1 UNION SELECT 2", &DIALECT).unwrap(),
256 StatementKind::Select,
257 );
258
259 assert_eq!(
261 validate_read_only("SHOW DATABASES", &DIALECT).unwrap(),
262 StatementKind::NonSelect,
263 );
264 assert_eq!(
265 validate_read_only("DESCRIBE users", &DIALECT).unwrap(),
266 StatementKind::NonSelect,
267 );
268 assert_eq!(
269 validate_read_only("USE app", &DIALECT).unwrap(),
270 StatementKind::NonSelect,
271 );
272 assert_eq!(
273 validate_read_only("EXPLAIN SELECT 1", &DIALECT).unwrap(),
274 StatementKind::NonSelect,
275 );
276 }
277
278 #[test]
281 fn test_select_allowed() {
282 assert!(validate_read_only("SELECT * FROM users", &DIALECT).is_ok());
283 assert!(validate_read_only("select * from users", &DIALECT).is_ok());
284 }
285
286 #[test]
287 fn test_show_allowed() {
288 assert!(validate_read_only("SHOW DATABASES", &DIALECT).is_ok());
289 assert!(validate_read_only("SHOW TABLES", &DIALECT).is_ok());
290 }
291
292 #[test]
293 fn test_describe_allowed() {
294 assert!(validate_read_only("DESC users", &DIALECT).is_ok());
296 assert!(validate_read_only("DESCRIBE users", &DIALECT).is_ok());
297 }
298
299 #[test]
300 fn test_use_allowed() {
301 assert!(validate_read_only("USE mydb", &DIALECT).is_ok());
302 }
303
304 #[test]
307 fn test_insert_blocked() {
308 assert!(matches!(
309 validate_read_only("INSERT INTO users VALUES (1)", &DIALECT),
310 Err(SqlError::ReadOnlyViolation)
311 ));
312 }
313
314 #[test]
315 fn test_update_blocked() {
316 assert!(matches!(
317 validate_read_only("UPDATE users SET name='x'", &DIALECT),
318 Err(SqlError::ReadOnlyViolation)
319 ));
320 }
321
322 #[test]
323 fn test_delete_blocked() {
324 assert!(matches!(
325 validate_read_only("DELETE FROM users", &DIALECT),
326 Err(SqlError::ReadOnlyViolation)
327 ));
328 }
329
330 #[test]
331 fn test_drop_blocked() {
332 assert!(matches!(
333 validate_read_only("DROP TABLE users", &DIALECT),
334 Err(SqlError::ReadOnlyViolation)
335 ));
336 }
337
338 #[test]
339 fn test_create_blocked() {
340 assert!(matches!(
341 validate_read_only("CREATE TABLE test (id INT)", &DIALECT),
342 Err(SqlError::ReadOnlyViolation)
343 ));
344 }
345
346 #[test]
349 fn test_comment_bypass_single_line() {
350 let result = validate_read_only("SELECT 1 -- \nDELETE FROM users", &DIALECT);
355 assert!(result.is_ok() || matches!(result, Err(SqlError::MultiStatement)));
357 }
358
359 #[test]
360 fn test_comment_bypass_multi_line() {
361 assert!(matches!(
363 validate_read_only("/* SELECT */ DELETE FROM users", &DIALECT),
364 Err(SqlError::ReadOnlyViolation)
365 ));
366 }
367
368 #[test]
371 fn test_load_file_blocked() {
372 assert!(matches!(
373 validate_read_only("SELECT LOAD_FILE('/etc/passwd')", &DIALECT),
374 Err(SqlError::LoadFileBlocked)
375 ));
376 }
377
378 #[test]
379 fn test_load_file_case_insensitive() {
380 assert!(matches!(
381 validate_read_only("SELECT load_file('/etc/passwd')", &DIALECT),
382 Err(SqlError::LoadFileBlocked)
383 ));
384 }
385
386 #[test]
387 fn test_load_file_with_spaces() {
388 assert!(matches!(
390 validate_read_only("SELECT LOAD_FILE ('/etc/passwd')", &DIALECT),
391 Err(SqlError::LoadFileBlocked)
392 ));
393 }
394
395 #[test]
398 fn test_into_outfile_blocked() {
399 assert!(matches!(
400 validate_read_only("SELECT * FROM users INTO OUTFILE '/tmp/out'", &DIALECT),
401 Err(SqlError::IntoOutfileBlocked)
402 ));
403 }
404
405 #[test]
406 fn test_into_dumpfile_blocked() {
407 assert!(matches!(
408 validate_read_only("SELECT * FROM users INTO DUMPFILE '/tmp/out'", &DIALECT),
409 Err(SqlError::IntoOutfileBlocked)
410 ));
411 }
412
413 #[test]
416 fn test_load_file_in_string_allowed() {
417 assert!(validate_read_only("SELECT 'LOAD_FILE(/etc/passwd)' FROM dual", &DIALECT).is_ok());
419 }
420
421 #[test]
424 fn test_empty_query_blocked() {
425 assert!(matches!(
426 validate_read_only("", &DIALECT),
427 Err(SqlError::ReadOnlyViolation)
428 ));
429 }
430
431 #[test]
432 fn test_comment_only_blocked() {
433 let result = validate_read_only("-- just a comment", &DIALECT);
435 assert!(result.is_err());
436 }
437
438 #[test]
441 fn test_multi_statement_blocked() {
442 assert!(matches!(
443 validate_read_only("SELECT 1; SELECT 2", &DIALECT),
444 Err(SqlError::MultiStatement)
445 ));
446 }
447
448 #[test]
449 fn test_multi_statement_injection_blocked() {
450 assert!(matches!(
451 validate_read_only("SELECT 1; DROP TABLE users", &DIALECT),
452 Err(SqlError::MultiStatement)
453 ));
454 }
455
456 #[test]
457 fn test_set_statement_blocked() {
458 assert!(matches!(
459 validate_read_only("SET @var = 1", &DIALECT),
460 Err(SqlError::ReadOnlyViolation)
461 ));
462 }
463
464 #[test]
465 fn test_malformed_sql_rejected() {
466 let result = validate_read_only("SELEC * FORM users", &DIALECT);
467 assert!(result.is_err());
468 }
469
470 #[test]
471 fn test_select_with_subquery_allowed() {
472 assert!(validate_read_only("SELECT * FROM (SELECT 1) AS t", &DIALECT).is_ok());
473 }
474
475 #[test]
476 fn test_select_with_where_allowed() {
477 assert!(validate_read_only("SELECT * FROM users WHERE id = 1", &DIALECT).is_ok());
478 }
479
480 #[test]
481 fn test_select_count_allowed() {
482 assert!(validate_read_only("SELECT COUNT(*) FROM users", &DIALECT).is_ok());
483 }
484
485 fn assert_allowed_all_dialects(sql: &str) {
488 assert!(validate_read_only(sql, &MYSQL).is_ok(), "MySQL should allow: {sql}");
489 assert!(
490 validate_read_only(sql, &POSTGRES).is_ok(),
491 "Postgres should allow: {sql}"
492 );
493 assert!(validate_read_only(sql, &SQLITE).is_ok(), "SQLite should allow: {sql}");
494 }
495
496 fn assert_blocked_all_dialects(sql: &str) {
497 assert!(validate_read_only(sql, &MYSQL).is_err(), "MySQL should block: {sql}");
498 assert!(
499 validate_read_only(sql, &POSTGRES).is_err(),
500 "Postgres should block: {sql}"
501 );
502 assert!(validate_read_only(sql, &SQLITE).is_err(), "SQLite should block: {sql}");
503 }
504
505 #[test]
506 fn select_allowed_all_dialects() {
507 assert_allowed_all_dialects("SELECT * FROM users");
508 assert_allowed_all_dialects("SELECT 1");
509 assert_allowed_all_dialects("SELECT COUNT(*) FROM t");
510 }
511
512 #[test]
513 fn insert_blocked_all_dialects() {
514 assert_blocked_all_dialects("INSERT INTO users VALUES (1)");
515 }
516
517 #[test]
518 fn update_blocked_all_dialects() {
519 assert_blocked_all_dialects("UPDATE users SET name = 'x'");
520 }
521
522 #[test]
523 fn delete_blocked_all_dialects() {
524 assert_blocked_all_dialects("DELETE FROM users");
525 }
526
527 #[test]
528 fn drop_blocked_all_dialects() {
529 assert_blocked_all_dialects("DROP TABLE users");
530 }
531
532 #[test]
533 fn create_blocked_all_dialects() {
534 assert_blocked_all_dialects("CREATE TABLE test (id INT)");
535 }
536
537 fn assert_single_statement_rules(dialect: &impl Dialect) {
540 for sql in [
541 "SELECT 1",
542 "SELECT 1;",
543 "INSERT INTO t (a) VALUES (1)",
544 "DELETE FROM t WHERE a = 1",
545 ] {
546 assert!(
547 validate_single_statement(sql, dialect).is_ok(),
548 "expected {sql:?} to be accepted"
549 );
550 }
551 for sql in [
552 "SELECT 1; CREATE TABLE readonly_probe(i int)",
553 "SELECT 1; /* x */ DROP TABLE t",
554 "INSERT INTO t (a) VALUES (1); DELETE FROM t",
555 ] {
556 assert!(
557 matches!(validate_single_statement(sql, dialect), Err(SqlError::MultiStatement)),
558 "expected MultiStatement for {sql:?}"
559 );
560 }
561 }
562
563 #[test]
564 fn single_statement_rules_all_dialects() {
565 assert_single_statement_rules(&MYSQL);
566 assert_single_statement_rules(&POSTGRES);
567 assert_single_statement_rules(&SQLITE);
568 }
569
570 #[test]
571 fn single_statement_rejects_empty_input() {
572 for sql in ["", " ", ";"] {
573 assert!(
574 matches!(validate_single_statement(sql, &POSTGRES), Err(SqlError::Query(_))),
575 "expected error for {sql:?}"
576 );
577 }
578 }
579
580 #[test]
581 fn multi_statement_blocked_all_dialects() {
582 let sql = "SELECT 1; DROP TABLE x";
583 assert!(matches!(validate_read_only(sql, &MYSQL), Err(SqlError::MultiStatement)));
584 assert!(matches!(
585 validate_read_only(sql, &POSTGRES),
586 Err(SqlError::MultiStatement)
587 ));
588 assert!(matches!(
589 validate_read_only(sql, &SQLITE),
590 Err(SqlError::MultiStatement)
591 ));
592 }
593
594 #[test]
595 fn empty_blocked_all_dialects() {
596 assert_blocked_all_dialects("");
597 assert_blocked_all_dialects(" ");
598 }
599
600 #[test]
603 fn postgres_copy_to_blocked() {
604 let result = validate_read_only("COPY users TO '/tmp/out.csv'", &POSTGRES);
605 assert!(
606 matches!(result, Err(SqlError::ReadOnlyViolation)),
607 "Postgres COPY TO should be blocked: {result:?}"
608 );
609 }
610
611 #[test]
612 fn postgres_copy_from_blocked() {
613 let result = validate_read_only("COPY users FROM '/tmp/in.csv'", &POSTGRES);
614 assert!(result.is_err(), "Postgres COPY FROM should be blocked: {result:?}");
615 }
616
617 #[test]
618 fn postgres_generate_series_allowed() {
619 assert!(validate_read_only("SELECT * FROM generate_series(1, 10)", &POSTGRES).is_ok());
620 }
621
622 #[test]
625 fn show_databases_across_dialects() {
626 assert!(validate_read_only("SHOW DATABASES", &MYSQL).is_ok());
627 let pg_result = validate_read_only("SHOW DATABASES", &POSTGRES);
628 let sqlite_result = validate_read_only("SHOW DATABASES", &SQLITE);
629 assert!(
630 pg_result.is_ok() || pg_result.is_err(),
631 "Postgres may or may not parse SHOW DATABASES"
632 );
633 assert!(
634 sqlite_result.is_ok() || sqlite_result.is_err(),
635 "SQLite may or may not parse SHOW DATABASES"
636 );
637 if let Err(e) = &pg_result {
638 assert!(
639 !matches!(e, SqlError::ReadOnlyViolation),
640 "SHOW DATABASES should not be classified as a write: {e}"
641 );
642 }
643 }
644
645 #[test]
648 fn unicode_cyrillic_semicolon_not_misclassified() {
649 let sql = "SELECT 1\u{037E} DROP TABLE users";
650 let result = validate_read_only(sql, &MYSQL);
651 assert!(
652 result.is_err(),
653 "SQL with Cyrillic question mark should not silently succeed as single SELECT"
654 );
655 }
656
657 #[test]
658 fn unicode_fullwidth_semicolon_not_misclassified() {
659 let sql = "SELECT 1\u{FF1B} DROP TABLE users";
660 let result = validate_read_only(sql, &MYSQL);
661 assert!(
662 result.is_err() || validate_read_only(sql, &MYSQL).is_ok(),
663 "fullwidth semicolon is a single token, not a statement separator"
664 );
665 }
666
667 #[test]
670 fn dangerous_functions_blocked_all_dialects() {
671 for sql in [
672 "SELECT pg_read_file('/etc/passwd')",
673 "SELECT pg_read_binary_file('/etc/passwd')",
674 "SELECT * FROM pg_ls_dir('/')",
675 "SELECT pg_ls_dir('/')",
676 "SELECT pg_stat_file('/etc/passwd')",
677 "SELECT lo_import('/etc/passwd')",
678 "SELECT lo_export(1, '/tmp/x')",
679 "SELECT setval('s', 1)",
680 "SELECT dblink_connect('host=attacker.example')",
681 "SELECT * FROM dblink('host=x', 'SELECT 1') AS t(a int)",
682 "SELECT readfile('/etc/passwd')",
683 "SELECT writefile('/tmp/x', 'data')",
684 "SELECT load_extension('/tmp/evil.so')",
685 "SELECT PG_READ_FILE('/etc/passwd')",
686 "SELECT pg_catalog.pg_read_file('/etc/passwd')",
687 "SELECT \"pg_read_file\"('/etc/passwd')",
688 "SELECT 1 FROM users WHERE id IN (SELECT setval('s', 1))",
689 ] {
690 for (name, result) in [
691 ("MySQL", validate_read_only(sql, &MYSQL)),
692 ("Postgres", validate_read_only(sql, &POSTGRES)),
693 ("SQLite", validate_read_only(sql, &SQLITE)),
694 ] {
695 assert!(result.is_err(), "{name} should block {sql:?}, got {result:?}");
697 assert!(
698 !matches!(result, Err(SqlError::ReadOnlyViolation)),
699 "{name} should flag the function in {sql:?}"
700 );
701 }
702 }
703 assert!(matches!(
704 validate_read_only("SELECT pg_read_file('/etc/passwd')", &POSTGRES),
705 Err(SqlError::DangerousFunctionBlocked(f)) if f == "pg_read_file"
706 ));
707 }
708
709 #[test]
710 fn load_file_quoted_or_qualified_blocked() {
711 for sql in [
712 "SELECT `LOAD_FILE`('/etc/passwd')",
713 "SELECT mysql.LOAD_FILE('/etc/passwd')",
714 ] {
715 assert!(
716 matches!(validate_read_only(sql, &MYSQL), Err(SqlError::LoadFileBlocked)),
717 "expected LoadFileBlocked for {sql:?}"
718 );
719 }
720 }
721
722 #[test]
723 fn dangerous_function_names_in_strings_or_columns_allowed() {
724 for sql in [
725 "SELECT 'pg_read_file(/etc/passwd)'",
726 "SELECT setval_count FROM users",
727 "SELECT * FROM generate_series(1, 10)",
728 "SELECT nextval_hint, upper(name) FROM users",
729 ] {
730 assert!(
731 validate_read_only(sql, &POSTGRES).is_ok(),
732 "expected {sql:?} to be allowed"
733 );
734 }
735 }
736
737 #[test]
740 fn explain_of_write_blocked_all_dialects() {
741 for sql in [
742 "EXPLAIN DELETE FROM users",
743 "EXPLAIN ANALYZE DELETE FROM users",
744 "EXPLAIN ANALYZE INSERT INTO users VALUES (1)",
745 "EXPLAIN ANALYZE UPDATE users SET admin = true",
746 "EXPLAIN ANALYZE DROP TABLE users",
747 ] {
748 for result in [
749 validate_read_only(sql, &MYSQL),
750 validate_read_only(sql, &POSTGRES),
751 validate_read_only(sql, &SQLITE),
752 ] {
753 assert!(result.is_err(), "expected {sql:?} to be blocked, got {result:?}");
754 }
755 }
756 }
757
758 #[test]
759 fn postgres_explain_options_of_write_blocked() {
760 for sql in [
761 "EXPLAIN (ANALYZE) UPDATE users SET admin = true",
762 "EXPLAIN (ANALYZE TRUE, FORMAT JSON) DELETE FROM users",
763 "EXPLAIN ANALYZE WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x",
764 "EXPLAIN ANALYZE SELECT * INTO evil FROM users",
765 ] {
766 assert!(
767 matches!(validate_read_only(sql, &POSTGRES), Err(SqlError::ReadOnlyViolation)),
768 "expected ReadOnlyViolation for {sql:?}"
769 );
770 }
771 }
772
773 #[test]
774 fn explain_of_read_allowed() {
775 for sql in ["EXPLAIN SELECT 1", "EXPLAIN ANALYZE SELECT * FROM users"] {
776 assert_eq!(validate_read_only(sql, &MYSQL).unwrap(), StatementKind::NonSelect);
777 assert_eq!(validate_read_only(sql, &POSTGRES).unwrap(), StatementKind::NonSelect);
778 }
779 assert_eq!(
780 validate_read_only("EXPLAIN (ANALYZE, FORMAT JSON) SELECT 1", &POSTGRES).unwrap(),
781 StatementKind::NonSelect,
782 );
783 assert_eq!(
784 validate_read_only("EXPLAIN QUERY PLAN SELECT 1", &SQLITE).unwrap(),
785 StatementKind::NonSelect,
786 );
787 }
788
789 #[test]
790 fn explain_checks_dangerous_functions() {
791 assert!(matches!(
792 validate_read_only("EXPLAIN ANALYZE SELECT LOAD_FILE('/etc/passwd')", &MYSQL),
793 Err(SqlError::LoadFileBlocked)
794 ));
795 }
796
797 #[test]
798 fn postgres_data_modifying_query_blocked() {
799 for sql in [
800 "WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x",
801 "WITH x AS (UPDATE users SET a = 1 RETURNING *) SELECT * FROM x",
802 "WITH x AS (INSERT INTO users VALUES (1) RETURNING *) SELECT 1",
803 "SELECT * FROM (WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x) AS t",
804 "SELECT * INTO evil FROM users",
805 ] {
806 assert!(
807 matches!(validate_read_only(sql, &POSTGRES), Err(SqlError::ReadOnlyViolation)),
808 "expected ReadOnlyViolation for {sql:?}"
809 );
810 }
811 }
812
813 #[test]
814 fn null_byte_in_sql() {
815 let sql = "SELECT 1\x00; DROP TABLE x";
816 let result = validate_read_only(sql, &MYSQL);
817 assert!(result.is_err(), "SQL with null byte should be rejected: {result:?}");
818 }
819}