syncular_client/
query_guard.rs1const READ_ONLY_VERBS: [&str; 5] = ["select", "with", "explain", "pragma", "values"];
14
15pub fn assert_read_only_query(sql: &str) -> Result<(), String> {
17 let statements = split_statements(sql);
18 if statements.is_empty() {
19 return Err("query() was given an empty statement".to_owned());
20 }
21 if statements.len() > 1 {
22 return Err(format!(
23 "query() runs a single statement, but {} were given — split them \
24 into separate query() calls",
25 statements.len()
26 ));
27 }
28 let head = strip_leading(statements[0]);
29 let verb: String = head
30 .chars()
31 .take_while(|c| c.is_ascii_alphabetic())
32 .collect::<String>()
33 .to_ascii_lowercase();
34 let reject = || {
35 Err(format!(
36 "query() is read-only — this statement writes the local database \
37 directly, which bypasses the sync outbox (SPEC §7.1); use \
38 mutate() for inserts/updates/deletes. Rejected: {}",
39 first_words(sql)
40 ))
41 };
42 if !READ_ONLY_VERBS.contains(&verb.as_str()) {
43 return reject();
44 }
45 if verb == "with" {
46 match main_verb_after_with(head) {
49 Some(main) if main == "select" || main == "values" => {}
50 _ => return reject(),
51 }
52 }
53 Ok(())
54}
55
56fn main_verb_after_with(sql: &str) -> Option<String> {
60 const MAIN_VERBS: [&str; 6] = ["select", "values", "insert", "update", "delete", "replace"];
61 let bytes = sql.as_bytes();
62 let n = bytes.len();
63 let mut depth: i32 = 0;
64 let mut i = 0usize;
65 let mut saw_with = false;
66 while i < n {
67 match bytes[i] {
68 b'-' if bytes.get(i + 1) == Some(&b'-') => {
69 i = sql[i..].find('\n').map_or(n, |off| i + off + 1);
70 }
71 b'/' if bytes.get(i + 1) == Some(&b'*') => {
72 i = sql[i + 2..].find("*/").map_or(n, |off| i + 2 + off + 2);
73 }
74 q @ (b'\'' | b'"' | b'`') => {
75 i += 1;
76 while i < n {
77 if bytes[i] == q {
78 if bytes.get(i + 1) == Some(&q) {
79 i += 2;
80 } else {
81 i += 1;
82 break;
83 }
84 } else {
85 i += 1;
86 }
87 }
88 }
89 b'[' => {
90 i = sql[i + 1..].find(']').map_or(n, |off| i + 1 + off + 1);
91 }
92 b'(' => {
93 depth += 1;
94 i += 1;
95 }
96 b')' => {
97 depth -= 1;
98 i += 1;
99 }
100 c if c.is_ascii_alphabetic() || c == b'_' => {
101 let mut j = i + 1;
102 while j < n && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
103 j += 1;
104 }
105 let word = sql[i..j].to_ascii_lowercase();
106 if depth == 0 {
107 if !saw_with && word == "with" {
108 saw_with = true;
109 } else if saw_with && MAIN_VERBS.contains(&word.as_str()) {
110 return Some(word);
111 }
112 }
113 i = j;
114 }
115 _ => i += 1,
116 }
117 }
118 None
119}
120
121fn split_statements(sql: &str) -> Vec<&str> {
125 let bytes = sql.as_bytes();
126 let mut statements = Vec::new();
127 let mut start = 0usize;
128 let mut i = 0usize;
129 let n = bytes.len();
130 while i < n {
131 match bytes[i] {
132 b'-' if bytes.get(i + 1) == Some(&b'-') => {
133 i = sql[i..].find('\n').map_or(n, |off| i + off + 1);
134 }
135 b'/' if bytes.get(i + 1) == Some(&b'*') => {
136 i = sql[i + 2..].find("*/").map_or(n, |off| i + 2 + off + 2);
137 }
138 q @ (b'\'' | b'"' | b'`') => {
139 i += 1;
140 while i < n {
141 if bytes[i] == q {
142 if bytes.get(i + 1) == Some(&q) {
143 i += 2; } else {
145 i += 1;
146 break;
147 }
148 } else {
149 i += 1;
150 }
151 }
152 }
153 b'[' => {
154 i = sql[i + 1..].find(']').map_or(n, |off| i + 1 + off + 1);
155 }
156 b';' => {
157 let piece = &sql[start..i];
158 if !strip_leading(piece).is_empty() {
159 statements.push(piece);
160 }
161 start = i + 1;
162 i += 1;
163 }
164 _ => i += 1,
165 }
166 }
167 let piece = &sql[start..];
168 if !strip_leading(piece).is_empty() {
169 statements.push(piece);
170 }
171 statements
172}
173
174fn strip_leading(sql: &str) -> &str {
176 let mut rest = sql.trim_start();
177 loop {
178 if let Some(after) = rest.strip_prefix("--") {
179 rest = after.find('\n').map_or("", |off| &after[off + 1..]);
180 } else if let Some(after) = rest.strip_prefix("/*") {
181 rest = after.find("*/").map_or("", |off| &after[off + 2..]);
182 } else {
183 return rest;
184 }
185 rest = rest.trim_start();
186 }
187}
188
189fn first_words(sql: &str) -> String {
190 let compact = sql.split_whitespace().collect::<Vec<_>>().join(" ");
191 if compact.chars().count() > 72 {
192 let head: String = compact.chars().take(72).collect();
193 format!("{head}…")
194 } else {
195 compact
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn allows_read_only_verbs() {
205 for sql in [
206 "SELECT 1",
207 " select * from tasks",
208 "WITH x AS (SELECT 1) SELECT * FROM x",
209 "WITH x AS (SELECT 1), y AS (SELECT 2) SELECT * FROM x, y",
210 "WITH RECURSIVE c(n) AS (VALUES (1)) SELECT n FROM c",
211 "EXPLAIN QUERY PLAN SELECT 1",
212 "PRAGMA table_info(tasks)",
213 "VALUES (1), (2)",
214 "-- leading comment\nSELECT 1",
215 "/* block */ SELECT 1",
216 "SELECT 1;",
217 "SELECT 1; -- done",
218 "SELECT ';' AS s",
219 "SELECT 'it''s; fine'",
220 "SELECT \"a;b\" FROM tasks",
221 "SELECT [a;b] FROM tasks",
222 ] {
223 assert!(assert_read_only_query(sql).is_ok(), "should allow: {sql}");
224 }
225 }
226
227 #[test]
228 fn rejects_writes_and_multi_statements() {
229 for sql in [
230 "INSERT INTO tasks (id) VALUES ('t1')",
231 "UPDATE tasks SET title = 'x'",
232 "DELETE FROM tasks",
233 "DROP TABLE tasks",
234 "CREATE TABLE evil (id)",
235 "BEGIN",
236 "VACUUM",
237 "SELECT 1; DROP TABLE tasks",
238 "SELECT 1; SELECT 2",
239 "WITH t AS (SELECT 1) DELETE FROM tasks",
240 "WITH t AS (SELECT 1) INSERT INTO tasks (id) SELECT 'x'",
241 "WITH t AS (SELECT 1) UPDATE tasks SET title = 'x'",
242 "",
243 " ",
244 "-- only a comment",
245 ";",
246 ] {
247 assert!(assert_read_only_query(sql).is_err(), "should reject: {sql}");
248 }
249 }
250}