Skip to main content

syncular_client/
query_guard.rs

1//! The raw-query guard (DESIGN-queries.md I3), mirrored from the TS core's
2//! `query-guard.ts` — the two cores enforce the same rule so `query` behaves
3//! identically through every binding:
4//!
5//! 1. READ-ONLY: only `select / with / explain / pragma / values` may run.
6//!    A write against the local mirror bypasses the outbox (SPEC §7.1) and
7//!    silently diverges from the server — writes go through `mutate`.
8//! 2. ONE STATEMENT: `sqlite3_prepare` parses only the first statement and
9//!    silently ignores the tail; instead of ignoring, reject loudly.
10//!
11//! Engine-internal reads use the connection directly and never route here.
12
13const READ_ONLY_VERBS: [&str; 5] = ["select", "with", "explain", "pragma", "values"];
14
15/// Assert `sql` is one read-only statement; `Err(message)` otherwise.
16pub 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        // SQLite allows `WITH … DELETE/INSERT/UPDATE`; only a SELECT/VALUES
47        // main statement is a read.
48        match main_verb_after_with(head) {
49            Some(main) if main == "select" || main == "values" => {}
50            _ => return reject(),
51        }
52    }
53    Ok(())
54}
55
56/// The main verb of a `WITH …` statement. CTE bodies live inside
57/// parentheses, and a bare keyword cannot be a CTE name, so the first
58/// paren-depth-0 keyword after the clause is the main verb.
59fn 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
121/// Split into top-level statements at unquoted `;`, skipping string
122/// literals, quoted/bracketed identifiers and comments. Statements that are
123/// only whitespace/comments are dropped.
124fn 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; // doubled quote escapes itself
144                        } 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
174/// Strip leading whitespace and comments.
175fn 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}