syncular_client/
query_guard.rs1const READ_ONLY_VERBS: [&str; 5] = ["select", "with", "explain", "pragma", "values"];
14const PUBLIC_SYNC_VERSION: &str = "_sync_version";
15const NATIVE_SYNC_VERSION: &str = "_syncular_version";
16
17pub(crate) fn lower_public_query_sql(sql: &str) -> String {
25 let bytes = sql.as_bytes();
26 let mut output = String::with_capacity(sql.len());
27 let mut copied_through = 0usize;
28 let mut i = 0usize;
29 while i < bytes.len() {
30 match bytes[i] {
31 b'-' if bytes.get(i + 1) == Some(&b'-') => {
32 i = sql[i..]
33 .find('\n')
34 .map_or(bytes.len(), |offset| i + offset + 1);
35 }
36 b'/' if bytes.get(i + 1) == Some(&b'*') => {
37 i = sql[i + 2..]
38 .find("*/")
39 .map_or(bytes.len(), |offset| i + 2 + offset + 2);
40 }
41 b'\'' => {
42 i += 1;
43 while i < bytes.len() {
44 if bytes[i] == b'\'' {
45 if bytes.get(i + 1) == Some(&b'\'') {
46 i += 2;
47 } else {
48 i += 1;
49 break;
50 }
51 } else {
52 i += 1;
53 }
54 }
55 }
56 quote @ (b'"' | b'`') => {
57 let content_start = i + 1;
58 let mut end = content_start;
59 while end < bytes.len() && bytes[end] != quote {
60 end += 1;
61 }
62 if end < bytes.len()
63 && sql[content_start..end].eq_ignore_ascii_case(PUBLIC_SYNC_VERSION)
64 {
65 output.push_str(&sql[copied_through..content_start]);
66 output.push_str(NATIVE_SYNC_VERSION);
67 copied_through = end;
68 }
69 i = (end + 1).min(bytes.len());
70 }
71 b'[' => {
72 let content_start = i + 1;
73 let end = sql[content_start..]
74 .find(']')
75 .map_or(bytes.len(), |offset| content_start + offset);
76 if end < bytes.len()
77 && sql[content_start..end].eq_ignore_ascii_case(PUBLIC_SYNC_VERSION)
78 {
79 output.push_str(&sql[copied_through..content_start]);
80 output.push_str(NATIVE_SYNC_VERSION);
81 copied_through = end;
82 }
83 i = (end + 1).min(bytes.len());
84 }
85 byte if byte.is_ascii_alphabetic() || byte == b'_' => {
86 let start = i;
87 i += 1;
88 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
89 i += 1;
90 }
91 if sql[start..i].eq_ignore_ascii_case(PUBLIC_SYNC_VERSION) {
92 output.push_str(&sql[copied_through..start]);
93 output.push_str(NATIVE_SYNC_VERSION);
94 copied_through = i;
95 }
96 }
97 _ => i += 1,
98 }
99 }
100 output.push_str(&sql[copied_through..]);
101 output
102}
103
104pub fn assert_read_only_query(sql: &str) -> Result<(), String> {
106 let statements = split_statements(sql);
107 if statements.is_empty() {
108 return Err("query() was given an empty statement".to_owned());
109 }
110 if statements.len() > 1 {
111 return Err(format!(
112 "query() runs a single statement, but {} were given — split them \
113 into separate query() calls",
114 statements.len()
115 ));
116 }
117 let head = strip_leading(statements[0]);
118 let verb: String = head
119 .chars()
120 .take_while(|c| c.is_ascii_alphabetic())
121 .collect::<String>()
122 .to_ascii_lowercase();
123 let reject = || {
124 Err(format!(
125 "query() is read-only — this statement writes the local database \
126 directly, which bypasses the sync outbox (SPEC §7.1); use \
127 mutate() for inserts/updates/deletes. Rejected: {}",
128 first_words(sql)
129 ))
130 };
131 if !READ_ONLY_VERBS.contains(&verb.as_str()) {
132 return reject();
133 }
134 if verb == "with" {
135 match main_verb_after_with(head) {
138 Some(main) if main == "select" || main == "values" => {}
139 _ => return reject(),
140 }
141 }
142 Ok(())
143}
144
145fn main_verb_after_with(sql: &str) -> Option<String> {
149 const MAIN_VERBS: [&str; 6] = ["select", "values", "insert", "update", "delete", "replace"];
150 let bytes = sql.as_bytes();
151 let n = bytes.len();
152 let mut depth: i32 = 0;
153 let mut i = 0usize;
154 let mut saw_with = false;
155 while i < n {
156 match bytes[i] {
157 b'-' if bytes.get(i + 1) == Some(&b'-') => {
158 i = sql[i..].find('\n').map_or(n, |off| i + off + 1);
159 }
160 b'/' if bytes.get(i + 1) == Some(&b'*') => {
161 i = sql[i + 2..].find("*/").map_or(n, |off| i + 2 + off + 2);
162 }
163 q @ (b'\'' | b'"' | b'`') => {
164 i += 1;
165 while i < n {
166 if bytes[i] == q {
167 if bytes.get(i + 1) == Some(&q) {
168 i += 2;
169 } else {
170 i += 1;
171 break;
172 }
173 } else {
174 i += 1;
175 }
176 }
177 }
178 b'[' => {
179 i = sql[i + 1..].find(']').map_or(n, |off| i + 1 + off + 1);
180 }
181 b'(' => {
182 depth += 1;
183 i += 1;
184 }
185 b')' => {
186 depth -= 1;
187 i += 1;
188 }
189 c if c.is_ascii_alphabetic() || c == b'_' => {
190 let mut j = i + 1;
191 while j < n && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
192 j += 1;
193 }
194 let word = sql[i..j].to_ascii_lowercase();
195 if depth == 0 {
196 if !saw_with && word == "with" {
197 saw_with = true;
198 } else if saw_with && MAIN_VERBS.contains(&word.as_str()) {
199 return Some(word);
200 }
201 }
202 i = j;
203 }
204 _ => i += 1,
205 }
206 }
207 None
208}
209
210fn split_statements(sql: &str) -> Vec<&str> {
214 let bytes = sql.as_bytes();
215 let mut statements = Vec::new();
216 let mut start = 0usize;
217 let mut i = 0usize;
218 let n = bytes.len();
219 while i < n {
220 match bytes[i] {
221 b'-' if bytes.get(i + 1) == Some(&b'-') => {
222 i = sql[i..].find('\n').map_or(n, |off| i + off + 1);
223 }
224 b'/' if bytes.get(i + 1) == Some(&b'*') => {
225 i = sql[i + 2..].find("*/").map_or(n, |off| i + 2 + off + 2);
226 }
227 q @ (b'\'' | b'"' | b'`') => {
228 i += 1;
229 while i < n {
230 if bytes[i] == q {
231 if bytes.get(i + 1) == Some(&q) {
232 i += 2; } else {
234 i += 1;
235 break;
236 }
237 } else {
238 i += 1;
239 }
240 }
241 }
242 b'[' => {
243 i = sql[i + 1..].find(']').map_or(n, |off| i + 1 + off + 1);
244 }
245 b';' => {
246 let piece = &sql[start..i];
247 if !strip_leading(piece).is_empty() {
248 statements.push(piece);
249 }
250 start = i + 1;
251 i += 1;
252 }
253 _ => i += 1,
254 }
255 }
256 let piece = &sql[start..];
257 if !strip_leading(piece).is_empty() {
258 statements.push(piece);
259 }
260 statements
261}
262
263fn strip_leading(sql: &str) -> &str {
265 let mut rest = sql.trim_start();
266 loop {
267 if let Some(after) = rest.strip_prefix("--") {
268 rest = after.find('\n').map_or("", |off| &after[off + 1..]);
269 } else if let Some(after) = rest.strip_prefix("/*") {
270 rest = after.find("*/").map_or("", |off| &after[off + 2..]);
271 } else {
272 return rest;
273 }
274 rest = rest.trim_start();
275 }
276}
277
278fn first_words(sql: &str) -> String {
279 let compact = sql.split_whitespace().collect::<Vec<_>>().join(" ");
280 if compact.chars().count() > 72 {
281 let head: String = compact.chars().take(72).collect();
282 format!("{head}…")
283 } else {
284 compact
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn allows_read_only_verbs() {
294 for sql in [
295 "SELECT 1",
296 " select * from tasks",
297 "WITH x AS (SELECT 1) SELECT * FROM x",
298 "WITH x AS (SELECT 1), y AS (SELECT 2) SELECT * FROM x, y",
299 "WITH RECURSIVE c(n) AS (VALUES (1)) SELECT n FROM c",
300 "EXPLAIN QUERY PLAN SELECT 1",
301 "PRAGMA table_info(tasks)",
302 "VALUES (1), (2)",
303 "-- leading comment\nSELECT 1",
304 "/* block */ SELECT 1",
305 "SELECT 1;",
306 "SELECT 1; -- done",
307 "SELECT ';' AS s",
308 "SELECT 'it''s; fine'",
309 "SELECT \"a;b\" FROM tasks",
310 "SELECT [a;b] FROM tasks",
311 ] {
312 assert!(assert_read_only_query(sql).is_ok(), "should allow: {sql}");
313 }
314 }
315
316 #[test]
317 fn rejects_writes_and_multi_statements() {
318 for sql in [
319 "INSERT INTO tasks (id) VALUES ('t1')",
320 "UPDATE tasks SET title = 'x'",
321 "DELETE FROM tasks",
322 "DROP TABLE tasks",
323 "CREATE TABLE evil (id)",
324 "BEGIN",
325 "VACUUM",
326 "SELECT 1; DROP TABLE tasks",
327 "SELECT 1; SELECT 2",
328 "WITH t AS (SELECT 1) DELETE FROM tasks",
329 "WITH t AS (SELECT 1) INSERT INTO tasks (id) SELECT 'x'",
330 "WITH t AS (SELECT 1) UPDATE tasks SET title = 'x'",
331 "",
332 " ",
333 "-- only a comment",
334 ";",
335 ] {
336 assert!(assert_read_only_query(sql).is_err(), "should reject: {sql}");
337 }
338 }
339
340 #[test]
341 fn lowers_only_app_facing_sync_version_identifiers() {
342 let sql = "SELECT t._sync_version AS server_version, \"_sync_version\" AS quoted \
343 FROM tasks t WHERE note = '_sync_version' /* _sync_version */ \
344 -- _sync_version\nORDER BY t.id";
345 let lowered = lower_public_query_sql(sql);
346 assert!(lowered.contains("t._syncular_version AS server_version"));
347 assert!(lowered.contains("\"_syncular_version\" AS quoted"));
348 assert!(lowered.contains("note = '_sync_version'"));
349 assert!(lowered.contains("/* _sync_version */"));
350 assert!(lowered.contains("-- _sync_version"));
351 }
352}