const READ_ONLY_VERBS: [&str; 5] = ["select", "with", "explain", "pragma", "values"];
pub fn assert_read_only_query(sql: &str) -> Result<(), String> {
let statements = split_statements(sql);
if statements.is_empty() {
return Err("query() was given an empty statement".to_owned());
}
if statements.len() > 1 {
return Err(format!(
"query() runs a single statement, but {} were given — split them \
into separate query() calls",
statements.len()
));
}
let head = strip_leading(statements[0]);
let verb: String = head
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect::<String>()
.to_ascii_lowercase();
let reject = || {
Err(format!(
"query() is read-only — this statement writes the local database \
directly, which bypasses the sync outbox (SPEC §7.1); use \
mutate() for inserts/updates/deletes. Rejected: {}",
first_words(sql)
))
};
if !READ_ONLY_VERBS.contains(&verb.as_str()) {
return reject();
}
if verb == "with" {
match main_verb_after_with(head) {
Some(main) if main == "select" || main == "values" => {}
_ => return reject(),
}
}
Ok(())
}
fn main_verb_after_with(sql: &str) -> Option<String> {
const MAIN_VERBS: [&str; 6] = ["select", "values", "insert", "update", "delete", "replace"];
let bytes = sql.as_bytes();
let n = bytes.len();
let mut depth: i32 = 0;
let mut i = 0usize;
let mut saw_with = false;
while i < n {
match bytes[i] {
b'-' if bytes.get(i + 1) == Some(&b'-') => {
i = sql[i..].find('\n').map_or(n, |off| i + off + 1);
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
i = sql[i + 2..].find("*/").map_or(n, |off| i + 2 + off + 2);
}
q @ (b'\'' | b'"' | b'`') => {
i += 1;
while i < n {
if bytes[i] == q {
if bytes.get(i + 1) == Some(&q) {
i += 2;
} else {
i += 1;
break;
}
} else {
i += 1;
}
}
}
b'[' => {
i = sql[i + 1..].find(']').map_or(n, |off| i + 1 + off + 1);
}
b'(' => {
depth += 1;
i += 1;
}
b')' => {
depth -= 1;
i += 1;
}
c if c.is_ascii_alphabetic() || c == b'_' => {
let mut j = i + 1;
while j < n && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
let word = sql[i..j].to_ascii_lowercase();
if depth == 0 {
if !saw_with && word == "with" {
saw_with = true;
} else if saw_with && MAIN_VERBS.contains(&word.as_str()) {
return Some(word);
}
}
i = j;
}
_ => i += 1,
}
}
None
}
fn split_statements(sql: &str) -> Vec<&str> {
let bytes = sql.as_bytes();
let mut statements = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
let n = bytes.len();
while i < n {
match bytes[i] {
b'-' if bytes.get(i + 1) == Some(&b'-') => {
i = sql[i..].find('\n').map_or(n, |off| i + off + 1);
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
i = sql[i + 2..].find("*/").map_or(n, |off| i + 2 + off + 2);
}
q @ (b'\'' | b'"' | b'`') => {
i += 1;
while i < n {
if bytes[i] == q {
if bytes.get(i + 1) == Some(&q) {
i += 2; } else {
i += 1;
break;
}
} else {
i += 1;
}
}
}
b'[' => {
i = sql[i + 1..].find(']').map_or(n, |off| i + 1 + off + 1);
}
b';' => {
let piece = &sql[start..i];
if !strip_leading(piece).is_empty() {
statements.push(piece);
}
start = i + 1;
i += 1;
}
_ => i += 1,
}
}
let piece = &sql[start..];
if !strip_leading(piece).is_empty() {
statements.push(piece);
}
statements
}
fn strip_leading(sql: &str) -> &str {
let mut rest = sql.trim_start();
loop {
if let Some(after) = rest.strip_prefix("--") {
rest = after.find('\n').map_or("", |off| &after[off + 1..]);
} else if let Some(after) = rest.strip_prefix("/*") {
rest = after.find("*/").map_or("", |off| &after[off + 2..]);
} else {
return rest;
}
rest = rest.trim_start();
}
}
fn first_words(sql: &str) -> String {
let compact = sql.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.chars().count() > 72 {
let head: String = compact.chars().take(72).collect();
format!("{head}…")
} else {
compact
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_read_only_verbs() {
for sql in [
"SELECT 1",
" select * from tasks",
"WITH x AS (SELECT 1) SELECT * FROM x",
"WITH x AS (SELECT 1), y AS (SELECT 2) SELECT * FROM x, y",
"WITH RECURSIVE c(n) AS (VALUES (1)) SELECT n FROM c",
"EXPLAIN QUERY PLAN SELECT 1",
"PRAGMA table_info(tasks)",
"VALUES (1), (2)",
"-- leading comment\nSELECT 1",
"/* block */ SELECT 1",
"SELECT 1;",
"SELECT 1; -- done",
"SELECT ';' AS s",
"SELECT 'it''s; fine'",
"SELECT \"a;b\" FROM tasks",
"SELECT [a;b] FROM tasks",
] {
assert!(assert_read_only_query(sql).is_ok(), "should allow: {sql}");
}
}
#[test]
fn rejects_writes_and_multi_statements() {
for sql in [
"INSERT INTO tasks (id) VALUES ('t1')",
"UPDATE tasks SET title = 'x'",
"DELETE FROM tasks",
"DROP TABLE tasks",
"CREATE TABLE evil (id)",
"BEGIN",
"VACUUM",
"SELECT 1; DROP TABLE tasks",
"SELECT 1; SELECT 2",
"WITH t AS (SELECT 1) DELETE FROM tasks",
"WITH t AS (SELECT 1) INSERT INTO tasks (id) SELECT 'x'",
"WITH t AS (SELECT 1) UPDATE tasks SET title = 'x'",
"",
" ",
"-- only a comment",
";",
] {
assert!(assert_read_only_query(sql).is_err(), "should reject: {sql}");
}
}
}