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"];
14const PUBLIC_SYNC_VERSION: &str = "_sync_version";
15const NATIVE_SYNC_VERSION: &str = "_syncular_version";
16
17/// Lower the app-facing row-version pseudo-column to the Rust client's private
18/// physical column. The TypeScript client stores `_sync_version` directly;
19/// Rust keeps `_syncular_version` for SSP2 SQLite-image parity, but both query
20/// surfaces must accept the same authored SQL.
21///
22/// Only SQL identifiers are rewritten. Single-quoted values and comments are
23/// copied byte-for-byte so application data cannot be changed by lowering.
24pub(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
104/// Assert `sql` is one read-only statement; `Err(message)` otherwise.
105pub 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        // SQLite allows `WITH … DELETE/INSERT/UPDATE`; only a SELECT/VALUES
136        // main statement is a read.
137        match main_verb_after_with(head) {
138            Some(main) if main == "select" || main == "values" => {}
139            _ => return reject(),
140        }
141    }
142    Ok(())
143}
144
145/// The main verb of a `WITH …` statement. CTE bodies live inside
146/// parentheses, and a bare keyword cannot be a CTE name, so the first
147/// paren-depth-0 keyword after the clause is the main verb.
148fn 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
210/// Split into top-level statements at unquoted `;`, skipping string
211/// literals, quoted/bracketed identifiers and comments. Statements that are
212/// only whitespace/comments are dropped.
213fn 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; // doubled quote escapes itself
233                        } 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
263/// Strip leading whitespace and comments.
264fn 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}