Skip to main content

victauri_plugin/
database.rs

1#[cfg(feature = "sqlite")]
2use std::path::{Path, PathBuf};
3
4#[cfg(feature = "sqlite")]
5const MAX_ROWS_DEFAULT: usize = 100;
6#[cfg(feature = "sqlite")]
7const MAX_ROWS_LIMIT: usize = 10_000;
8
9#[cfg(feature = "sqlite")]
10static READ_ONLY_PREFIXES: &[&str] = &["select", "pragma", "explain", "with"];
11
12#[cfg(feature = "sqlite")]
13fn strip_sql_comments(sql: &str) -> String {
14    let mut result = String::with_capacity(sql.len());
15    let bytes = sql.as_bytes();
16    let len = bytes.len();
17    let mut i = 0;
18    while i < len {
19        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
20            while i < len && bytes[i] != b'\n' {
21                i += 1;
22            }
23        } else if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
24            i += 2;
25            while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
26                i += 1;
27            }
28            if i + 1 < len {
29                i += 2;
30            }
31            result.push(' ');
32        } else {
33            result.push(bytes[i] as char);
34            i += 1;
35        }
36    }
37    result
38}
39
40#[cfg(feature = "sqlite")]
41fn is_read_only(sql: &str) -> bool {
42    let cleaned = strip_sql_comments(sql);
43    let trimmed = cleaned.trim_start().to_lowercase();
44    if trimmed.is_empty() {
45        return false;
46    }
47    READ_ONLY_PREFIXES
48        .iter()
49        .any(|prefix| trimmed.starts_with(prefix))
50}
51
52/// Returns true if `sql` is the write form of a PRAGMA (`PRAGMA name = value`).
53///
54/// The read forms (`PRAGMA name`, `PRAGMA name(arg)`) are not flagged. An `=`
55/// is only significant when it appears outside of any quoted string.
56#[cfg(feature = "sqlite")]
57fn is_pragma_write(sql: &str) -> bool {
58    let cleaned = strip_sql_comments(sql);
59    let trimmed = cleaned.trim_start();
60    if !trimmed.to_lowercase().starts_with("pragma") {
61        return false;
62    }
63    let bytes = trimmed.as_bytes();
64    let mut in_single = false;
65    let mut in_double = false;
66    for &b in bytes {
67        match b {
68            b'\'' if !in_double => in_single = !in_single,
69            b'"' if !in_single => in_double = !in_double,
70            b'=' if !in_single && !in_double => return true,
71            _ => {}
72        }
73    }
74    false
75}
76
77/// Read-only / introspection PRAGMAs permitted on the user-facing `query` path.
78///
79/// A positive allowlist (audit C10): even without an `=`, some PRAGMAs have side
80/// effects (`wal_checkpoint`, `optimize`, `incremental_vacuum`, `shrink_memory`,
81/// `wal_checkpoint(TRUNCATE)`). The `READ_ONLY` open flag already blocks real
82/// writes, but allowlisting the PRAGMA name makes the read-only contract explicit
83/// and refuses side-effecting introspection outright.
84#[cfg(feature = "sqlite")]
85static SAFE_PRAGMAS: &[&str] = &[
86    "table_info",
87    "table_xinfo",
88    "table_list",
89    "index_list",
90    "index_info",
91    "index_xinfo",
92    "foreign_key_list",
93    "foreign_key_check",
94    "collation_list",
95    "database_list",
96    "compile_options",
97    "function_list",
98    "module_list",
99    "pragma_list",
100    "journal_mode",
101    "journal_size_limit",
102    "page_count",
103    "page_size",
104    "max_page_count",
105    "schema_version",
106    "user_version",
107    "application_id",
108    "data_version",
109    "freelist_count",
110    "cache_size",
111    "encoding",
112    "auto_vacuum",
113    "busy_timeout",
114    "wal_autocheckpoint",
115    "legacy_file_format",
116    "locking_mode",
117    "secure_delete",
118    "synchronous",
119    "temp_store",
120    "mmap_size",
121    "cache_spill",
122    "cell_size_check",
123    "integrity_check",
124    "quick_check",
125    "stats",
126];
127
128/// Extract the lowercased PRAGMA name from a `PRAGMA [schema.]name ...` statement,
129/// tolerating an optional `schema.` qualifier. Returns `None` for a non-PRAGMA or
130/// a malformed one.
131#[cfg(feature = "sqlite")]
132fn pragma_name(sql: &str) -> Option<String> {
133    let cleaned = strip_sql_comments(sql);
134    let lower = cleaned.trim_start().to_lowercase();
135    let stripped = lower.strip_prefix("pragma")?.trim_start();
136    // Normalize away SQL identifier-quoting so quoted forms like `PRAGMA "main".table_info`
137    // or `PRAGMA [main].table_info` parse to the same name as the bare form (avoids a
138    // false-positive block of a legitimate quoted read PRAGMA).
139    let normalized: String = stripped
140        .chars()
141        .filter(|c| !matches!(c, '"' | '`' | '[' | ']'))
142        .collect();
143    let rest = normalized.trim_start();
144    // Optional `schema.` qualifier: only treat the part before the first '.' as a
145    // schema when it's a bare identifier (no '(', '=', whitespace) — otherwise the
146    // '.' belongs to a quoted arg and `rest` already starts with the name.
147    let after_schema = match rest.split_once('.') {
148        Some((maybe_schema, tail))
149            if !maybe_schema.is_empty()
150                && maybe_schema
151                    .chars()
152                    .all(|c| c.is_alphanumeric() || c == '_') =>
153        {
154            tail
155        }
156        _ => rest,
157    };
158    let name: String = after_schema
159        .trim_start()
160        .chars()
161        .take_while(|c| c.is_alphanumeric() || *c == '_')
162        .collect();
163    if name.is_empty() { None } else { Some(name) }
164}
165
166/// True if `sql` is a PRAGMA whose name is NOT on [`SAFE_PRAGMAS`] (a malformed
167/// PRAGMA is also rejected). Non-PRAGMA statements are not flagged here.
168#[cfg(feature = "sqlite")]
169fn is_disallowed_pragma(sql: &str) -> bool {
170    let cleaned = strip_sql_comments(sql);
171    if !cleaned.trim_start().to_lowercase().starts_with("pragma") {
172        return false;
173    }
174    match pragma_name(sql) {
175        Some(name) => !SAFE_PRAGMAS.contains(&name.as_str()),
176        None => true,
177    }
178}
179
180/// Discover `SQLite` database files in a directory (non-recursive, max depth 2).
181#[cfg(feature = "sqlite")]
182#[must_use]
183pub fn discover_databases(dir: &Path) -> Vec<PathBuf> {
184    let mut results = Vec::new();
185    discover_recursive(dir, 0, 2, &mut results);
186    results
187}
188
189#[cfg(feature = "sqlite")]
190fn discover_recursive(dir: &Path, depth: u32, max_depth: u32, results: &mut Vec<PathBuf>) {
191    let Ok(entries) = std::fs::read_dir(dir) else {
192        return;
193    };
194    for entry in entries.flatten() {
195        let path = entry.path();
196        if path.is_symlink() {
197            continue;
198        }
199        if path.is_file() {
200            if let Some(ext) = path.extension().and_then(|e| e.to_str())
201                && matches!(ext, "sqlite" | "sqlite3" | "db" | "sdb")
202            {
203                results.push(path);
204            }
205        } else if path.is_dir() && depth < max_depth {
206            discover_recursive(&path, depth + 1, max_depth, results);
207        }
208    }
209}
210
211/// File basenames (matched case-insensitively, with or without a `SQLite` extension)
212/// that are browser-engine internal databases, never the application's own DB
213/// (Chromium/WebKit profile stores). Selecting one of these is the audit/red-team
214/// "wrong database" bug: an agent would confidently inspect `WebView` state instead of
215/// the app's data.
216#[cfg(feature = "sqlite")]
217const WEBVIEW_DB_BASENAMES: &[&str] = &[
218    "cookies",
219    "quotamanager",
220    "web data",
221    "history",
222    "favicons",
223    "top sites",
224    "login data",
225    "network action predictor",
226    "transportsecurity",
227    "trust tokens",
228    "sharedstorage",
229    "reporting and ntp",
230    "media history",
231    "affiliation database",
232    "site characteristics database",
233    "webdata",
234];
235
236/// Directory names (matched case-insensitively, anywhere in the path) that belong to a
237/// `WebView`/browser engine's private storage area. Any `.db`/`.sqlite` under one of these
238/// is an engine internal, not the app DB.
239#[cfg(feature = "sqlite")]
240const WEBVIEW_DIR_NAMES: &[&str] = &[
241    "ebwebview",
242    "wkwebview",
243    "webkit",
244    "local storage",
245    "indexeddb",
246    "session storage",
247    "service worker",
248    "gpucache",
249    "code cache",
250    "blob_storage",
251    "shared proto db",
252    "websql",
253];
254
255/// Whether a discovered database path is a `WebView`/browser-engine internal store rather
256/// than the application's own database (audit / red-team "wrong DB" finding).
257#[cfg(feature = "sqlite")]
258#[must_use]
259pub fn is_webview_internal(path: &Path) -> bool {
260    if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
261        let name = name.to_ascii_lowercase();
262        if WEBVIEW_DB_BASENAMES.iter().any(|n| name == *n) {
263            return true;
264        }
265    }
266    path.components().any(|c| {
267        let seg = c.as_os_str().to_string_lossy().to_ascii_lowercase();
268        WEBVIEW_DIR_NAMES.iter().any(|d| seg == *d)
269    })
270}
271
272/// A discovered database candidate with the metadata needed to disambiguate which DB the
273/// application actually uses.
274#[cfg(feature = "sqlite")]
275#[derive(Debug, Clone)]
276pub struct DbCandidate {
277    /// Absolute path to the discovered database file.
278    pub path: PathBuf,
279    /// File size in bytes (0 if it could not be stat'd).
280    pub size_bytes: u64,
281    /// Whether this is a `WebView`/browser-engine internal store rather than an app DB.
282    pub webview_internal: bool,
283}
284
285/// Classify every database discovered under `dirs`, returning application candidates first
286/// (non-`WebView`, largest by size — the substantial app DB outranks incidental ones) and
287/// `WebView` internals last. De-duplicates paths discovered via overlapping roots.
288#[cfg(feature = "sqlite")]
289#[must_use]
290pub fn classify_databases(dirs: &[PathBuf]) -> Vec<DbCandidate> {
291    let mut seen = std::collections::HashSet::new();
292    let mut candidates: Vec<DbCandidate> = Vec::new();
293    for dir in dirs {
294        for path in discover_databases(dir) {
295            let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
296            if !seen.insert(key) {
297                continue;
298            }
299            let size_bytes = std::fs::metadata(&path).map_or(0, |m| m.len());
300            let webview_internal = is_webview_internal(&path);
301            candidates.push(DbCandidate {
302                path,
303                size_bytes,
304                webview_internal,
305            });
306        }
307    }
308    // Application DBs first, then by size descending (larger ⇒ more likely the real DB).
309    candidates.sort_by(|a, b| {
310        a.webview_internal
311            .cmp(&b.webview_internal)
312            .then(b.size_bytes.cmp(&a.size_bytes))
313    });
314    candidates
315}
316
317/// Select the single most likely application database from `dirs`, excluding `WebView`
318/// internals.
319///
320/// # Errors
321/// Returns `Err` with a diagnostic when no application database is found — either no
322/// databases at all, or only `WebView`/browser-engine internal stores (the error lists
323/// the skipped internals so the caller can tell an agent to register the real DB
324/// directory via `db_search_paths` or pass an explicit `path`).
325#[cfg(feature = "sqlite")]
326pub fn select_app_database(dirs: &[PathBuf]) -> Result<PathBuf, String> {
327    let candidates = classify_databases(dirs);
328    if let Some(app) = candidates.iter().find(|c| !c.webview_internal) {
329        return Ok(app.path.clone());
330    }
331    if candidates.is_empty() {
332        let dirs_str = dirs
333            .iter()
334            .map(|d| d.display().to_string())
335            .collect::<Vec<_>>()
336            .join(", ");
337        return Err(format!("no SQLite databases found in: {dirs_str}"));
338    }
339    let internals = candidates
340        .iter()
341        .map(|c| c.path.display().to_string())
342        .collect::<Vec<_>>()
343        .join(", ");
344    Err(format!(
345        "only WebView/browser-engine internal databases were found ({internals}); none looks \
346         like an application database. Register the app's DB directory via \
347         VictauriBuilder::db_search_paths, or pass an explicit `path`."
348    ))
349}
350
351/// Execute a read-only SQL query against a `SQLite` database.
352///
353/// # Errors
354///
355/// Returns an error if the query is not read-only, the database cannot be opened,
356/// or the query fails.
357#[cfg(feature = "sqlite")]
358pub fn query(
359    db_path: &Path,
360    sql: &str,
361    params: &[serde_json::Value],
362    max_rows: Option<usize>,
363) -> Result<serde_json::Value, String> {
364    if !is_read_only(sql) {
365        return Err(
366            "only SELECT, PRAGMA, EXPLAIN, and WITH queries are allowed (read-only access)"
367                .to_string(),
368        );
369    }
370
371    // Defence in depth: the connection is opened READ_ONLY (SQLite rejects
372    // actual writes), but explicitly reject the write form of PRAGMA
373    // (`PRAGMA name = value`) so the read-only contract is self-evident and
374    // not solely reliant on the open flags. The read forms `PRAGMA name` and
375    // `PRAGMA name(arg)` remain allowed.
376    if is_pragma_write(sql) {
377        return Err(
378            "PRAGMA writes (PRAGMA name = value) are not allowed (read-only access)".to_string(),
379        );
380    }
381
382    // Positive PRAGMA allowlist (audit C10): reject side-effecting PRAGMAs
383    // (wal_checkpoint, optimize, incremental_vacuum, …) even without an `=`.
384    if is_disallowed_pragma(sql) {
385        return Err(
386            "only read-only introspection PRAGMAs are allowed (e.g. table_info, \
387             integrity_check, page_count); side-effecting PRAGMAs such as \
388             wal_checkpoint/optimize/incremental_vacuum are blocked"
389                .to_string(),
390        );
391    }
392
393    let cleaned = strip_sql_comments(sql);
394    if cleaned.contains(';') {
395        let parts: Vec<&str> = cleaned
396            .split(';')
397            .filter(|s| !s.trim().is_empty())
398            .collect();
399        if parts.len() > 1 {
400            return Err(
401                "stacked queries (multiple statements separated by ;) are not allowed".to_string(),
402            );
403        }
404    }
405
406    let max_rows = max_rows.unwrap_or(MAX_ROWS_DEFAULT).min(MAX_ROWS_LIMIT);
407
408    let conn = rusqlite::Connection::open_with_flags(
409        db_path,
410        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
411    )
412    .map_err(|e| format!("failed to open database: {e}"))?;
413
414    // 5 second query timeout
415    conn.busy_timeout(std::time::Duration::from_secs(5))
416        .map_err(|e| format!("failed to set timeout: {e}"))?;
417
418    let mut stmt = conn
419        .prepare(sql)
420        .map_err(|e| format!("failed to prepare query: {e}"))?;
421
422    let column_names: Vec<String> = stmt
423        .column_names()
424        .iter()
425        .map(|s| (*s).to_string())
426        .collect();
427    let column_count = column_names.len();
428
429    let sqlite_params: Vec<Box<dyn rusqlite::types::ToSql>> =
430        params.iter().map(json_to_sql).collect();
431    let param_refs: Vec<&dyn rusqlite::types::ToSql> = sqlite_params.iter().map(|b| &**b).collect();
432
433    let mut rows_out: Vec<serde_json::Value> = Vec::new();
434    let mut rows = stmt
435        .query(param_refs.as_slice())
436        .map_err(|e| format!("query execution failed: {e}"))?;
437
438    while let Some(row) = rows.next().map_err(|e| format!("row read failed: {e}"))? {
439        if rows_out.len() >= max_rows {
440            break;
441        }
442        let mut obj = serde_json::Map::new();
443        for (i, col_name) in column_names.iter().enumerate().take(column_count) {
444            let value = row_value_to_json(row, i);
445            obj.insert(col_name.clone(), value);
446        }
447        rows_out.push(serde_json::Value::Object(obj));
448    }
449
450    let truncated = rows_out.len() == max_rows;
451
452    Ok(serde_json::json!({
453        "columns": column_names,
454        "rows": rows_out,
455        "row_count": rows_out.len(),
456        "truncated": truncated,
457        "max_rows": max_rows,
458    }))
459}
460
461#[cfg(feature = "sqlite")]
462fn json_to_sql(val: &serde_json::Value) -> Box<dyn rusqlite::types::ToSql> {
463    match val {
464        serde_json::Value::Null => Box::new(rusqlite::types::Null),
465        serde_json::Value::Bool(b) => Box::new(*b),
466        serde_json::Value::Number(n) => {
467            if let Some(i) = n.as_i64() {
468                Box::new(i)
469            } else if let Some(f) = n.as_f64() {
470                Box::new(f)
471            } else {
472                Box::new(n.to_string())
473            }
474        }
475        serde_json::Value::String(s) => Box::new(s.clone()),
476        other => Box::new(other.to_string()),
477    }
478}
479
480#[cfg(feature = "sqlite")]
481fn row_value_to_json(row: &rusqlite::Row, idx: usize) -> serde_json::Value {
482    use rusqlite::types::ValueRef;
483    match row.get_ref(idx) {
484        Ok(ValueRef::Null) => serde_json::Value::Null,
485        Ok(ValueRef::Integer(i)) => serde_json::json!(i),
486        Ok(ValueRef::Real(f)) => serde_json::json!(f),
487        Ok(ValueRef::Text(t)) => {
488            let s = String::from_utf8_lossy(t);
489            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&s)
490                && (parsed.is_object() || parsed.is_array())
491            {
492                return parsed;
493            }
494            serde_json::Value::String(s.into_owned())
495        }
496        Ok(ValueRef::Blob(b)) => {
497            use base64::Engine;
498            serde_json::json!({
499                "__blob": true,
500                "size": b.len(),
501                "base64": base64::engine::general_purpose::STANDARD.encode(b),
502            })
503        }
504        Err(_) => serde_json::Value::Null,
505    }
506}
507
508#[cfg(all(test, feature = "sqlite"))]
509mod tests {
510    use super::*;
511
512    fn create_test_db() -> (tempfile::NamedTempFile, PathBuf) {
513        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
514        let path = file.path().to_path_buf();
515        let conn = rusqlite::Connection::open(&path).unwrap();
516        conn.execute_batch(
517            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, score REAL);
518             INSERT INTO users VALUES (1, 'Alice', 95.5);
519             INSERT INTO users VALUES (2, 'Bob', 87.0);
520             INSERT INTO users VALUES (3, 'Charlie', 92.3);",
521        )
522        .unwrap();
523        (file, path)
524    }
525
526    #[test]
527    fn select_all_rows() {
528        let (_f, path) = create_test_db();
529        let result = query(&path, "SELECT * FROM users", &[], None).unwrap();
530        assert_eq!(result["row_count"], 3);
531        assert_eq!(
532            result["columns"],
533            serde_json::json!(["id", "name", "score"])
534        );
535        assert_eq!(result["rows"][0]["name"], "Alice");
536        assert_eq!(result["rows"][1]["name"], "Bob");
537    }
538
539    #[test]
540    fn select_with_params() {
541        let (_f, path) = create_test_db();
542        let result = query(
543            &path,
544            "SELECT name FROM users WHERE score > ?",
545            &[serde_json::json!(90.0)],
546            None,
547        )
548        .unwrap();
549        assert_eq!(result["row_count"], 2);
550    }
551
552    #[test]
553    fn max_rows_truncation() {
554        let (_f, path) = create_test_db();
555        let result = query(&path, "SELECT * FROM users", &[], Some(2)).unwrap();
556        assert_eq!(result["row_count"], 2);
557        assert_eq!(result["truncated"], true);
558    }
559
560    #[test]
561    fn rejects_insert() {
562        let (_f, path) = create_test_db();
563        let err = query(
564            &path,
565            "INSERT INTO users VALUES (4, 'Eve', 99.0)",
566            &[],
567            None,
568        )
569        .unwrap_err();
570        assert!(err.contains("read-only"));
571    }
572
573    #[test]
574    fn rejects_delete() {
575        let (_f, path) = create_test_db();
576        let err = query(&path, "DELETE FROM users", &[], None).unwrap_err();
577        assert!(err.contains("read-only"));
578    }
579
580    #[test]
581    fn rejects_drop() {
582        let (_f, path) = create_test_db();
583        let err = query(&path, "DROP TABLE users", &[], None).unwrap_err();
584        assert!(err.contains("read-only"));
585    }
586
587    #[test]
588    fn rejects_update() {
589        let (_f, path) = create_test_db();
590        let err = query(&path, "UPDATE users SET name = 'X'", &[], None).unwrap_err();
591        assert!(err.contains("read-only"));
592    }
593
594    #[test]
595    fn pragma_works() {
596        let (_f, path) = create_test_db();
597        let result = query(&path, "PRAGMA table_info(users)", &[], None).unwrap();
598        assert!(result["row_count"].as_u64().unwrap() >= 3);
599    }
600
601    #[test]
602    fn pragma_read_allowed() {
603        let (_f, path) = create_test_db();
604        assert!(query(&path, "PRAGMA journal_mode", &[], None).is_ok());
605        assert!(query(&path, "PRAGMA user_version", &[], None).is_ok());
606    }
607
608    #[test]
609    fn rejects_side_effecting_pragmas() {
610        // Audit C10: side-effecting PRAGMAs without `=` are blocked by the allowlist.
611        let (_f, path) = create_test_db();
612        for sql in [
613            "PRAGMA wal_checkpoint",
614            "PRAGMA wal_checkpoint(TRUNCATE)",
615            "PRAGMA optimize",
616            "PRAGMA incremental_vacuum",
617            "PRAGMA shrink_memory",
618            "PRAGMA main.wal_checkpoint",
619            "pragma  optimize ",
620        ] {
621            let err = query(&path, sql, &[], None).unwrap_err();
622            assert!(
623                err.contains("read-only introspection PRAGMAs"),
624                "expected allowlist block for: {sql} (got: {err})"
625            );
626        }
627    }
628
629    #[test]
630    fn allows_safe_introspection_pragmas() {
631        let (_f, path) = create_test_db();
632        for sql in [
633            "PRAGMA table_info(users)",
634            "PRAGMA integrity_check",
635            "PRAGMA page_count",
636            "PRAGMA foreign_key_list(users)",
637            "PRAGMA main.table_info(users)",
638        ] {
639            assert!(
640                query(&path, sql, &[], None).is_ok(),
641                "expected ok for: {sql}"
642            );
643        }
644    }
645
646    #[test]
647    fn pragma_name_handles_schema_qualifier_and_args() {
648        assert_eq!(
649            pragma_name("PRAGMA wal_checkpoint").as_deref(),
650            Some("wal_checkpoint")
651        );
652        assert_eq!(
653            pragma_name("PRAGMA main.table_info(users)").as_deref(),
654            Some("table_info")
655        );
656        assert_eq!(
657            pragma_name("PRAGMA table_info(users)").as_deref(),
658            Some("table_info")
659        );
660        // Quoted/bracketed schema qualifiers normalize to the same name (no false block).
661        assert_eq!(
662            pragma_name(r#"PRAGMA "main".table_info(users)"#).as_deref(),
663            Some("table_info")
664        );
665        assert_eq!(
666            pragma_name("PRAGMA [main].wal_checkpoint").as_deref(),
667            Some("wal_checkpoint")
668        );
669        assert_eq!(pragma_name("SELECT 1"), None);
670    }
671
672    #[test]
673    fn quoted_schema_read_pragma_is_allowed_but_quoted_side_effect_blocked() {
674        let (_f, path) = create_test_db();
675        // A legitimate quoted-schema read PRAGMA must not be falsely blocked.
676        assert!(query(&path, r#"PRAGMA "main".table_info(users)"#, &[], None).is_ok());
677        // …but a side-effecting one stays blocked even when quoted.
678        let err = query(&path, "PRAGMA [main].wal_checkpoint", &[], None).unwrap_err();
679        assert!(err.contains("read-only introspection PRAGMAs"));
680    }
681
682    #[test]
683    fn rejects_pragma_write_form() {
684        let (_f, path) = create_test_db();
685        for sql in [
686            "PRAGMA journal_mode=DELETE",
687            "PRAGMA journal_mode = WAL",
688            "PRAGMA user_version=12345",
689            "  pragma  synchronous = 0 ",
690        ] {
691            let err = query(&path, sql, &[], None).unwrap_err();
692            assert!(err.contains("PRAGMA writes"), "expected block for: {sql}");
693        }
694    }
695
696    #[test]
697    fn is_pragma_write_ignores_equals_in_strings() {
698        // A read-form PRAGMA whose argument contains '=' inside quotes is not a write.
699        assert!(!is_pragma_write("PRAGMA table_info('a=b')"));
700        assert!(is_pragma_write("PRAGMA foo = 'a=b'"));
701        assert!(!is_pragma_write("SELECT 1 = 1"));
702    }
703
704    #[test]
705    fn with_cte_works() {
706        let (_f, path) = create_test_db();
707        let result = query(
708            &path,
709            "WITH top AS (SELECT * FROM users WHERE score > 90) SELECT name FROM top",
710            &[],
711            None,
712        )
713        .unwrap();
714        assert_eq!(result["row_count"], 2);
715    }
716
717    #[test]
718    fn nonexistent_db_fails() {
719        let err = query(Path::new("/nonexistent/db.sqlite"), "SELECT 1", &[], None).unwrap_err();
720        assert!(err.contains("failed to open"));
721    }
722
723    #[test]
724    fn json_column_parsed() {
725        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
726        let path = file.path().to_path_buf();
727        let conn = rusqlite::Connection::open(&path).unwrap();
728        conn.execute_batch(
729            r#"CREATE TABLE config (key TEXT, value TEXT);
730               INSERT INTO config VALUES ('settings', '{"theme":"dark","lang":"en"}');"#,
731        )
732        .unwrap();
733        let result = query(&path, "SELECT * FROM config", &[], None).unwrap();
734        assert!(result["rows"][0]["value"].is_object());
735        assert_eq!(result["rows"][0]["value"]["theme"], "dark");
736    }
737
738    #[test]
739    fn discover_finds_sqlite_files() {
740        let dir = tempfile::tempdir().unwrap();
741        std::fs::File::create(dir.path().join("app.sqlite")).unwrap();
742        std::fs::File::create(dir.path().join("cache.db")).unwrap();
743        std::fs::File::create(dir.path().join("readme.txt")).unwrap();
744        let sub = dir.path().join("subdir");
745        std::fs::create_dir(&sub).unwrap();
746        std::fs::File::create(sub.join("deep.sqlite3")).unwrap();
747
748        let dbs = discover_databases(dir.path());
749        assert_eq!(dbs.len(), 3);
750    }
751
752    #[test]
753    fn rejects_comment_bypass_block() {
754        let (_f, path) = create_test_db();
755        let err = query(&path, "/* sneaky */DELETE FROM users", &[], None).unwrap_err();
756        assert!(err.contains("read-only"));
757    }
758
759    #[test]
760    fn rejects_line_comment_bypass() {
761        let (_f, path) = create_test_db();
762        let err = query(&path, "-- comment\nDELETE FROM users", &[], None).unwrap_err();
763        assert!(err.contains("read-only"));
764    }
765
766    #[test]
767    fn rejects_stacked_queries() {
768        let (_f, path) = create_test_db();
769        let err = query(&path, "SELECT 1; DROP TABLE users", &[], None).unwrap_err();
770        assert!(err.contains("stacked queries"));
771    }
772
773    #[test]
774    fn allows_trailing_semicolon() {
775        let (_f, path) = create_test_db();
776        let result = query(&path, "SELECT * FROM users;", &[], None).unwrap();
777        assert_eq!(result["row_count"], 3);
778    }
779
780    #[test]
781    fn allows_select_with_block_comment() {
782        let (_f, path) = create_test_db();
783        let result = query(
784            &path,
785            "/* filter */ SELECT name FROM users WHERE id = 1",
786            &[],
787            None,
788        )
789        .unwrap();
790        assert_eq!(result["row_count"], 1);
791        assert_eq!(result["rows"][0]["name"], "Alice");
792    }
793
794    #[test]
795    fn rejects_empty_query() {
796        let (_f, path) = create_test_db();
797        let err = query(&path, "", &[], None).unwrap_err();
798        assert!(err.contains("read-only"));
799    }
800
801    #[test]
802    fn rejects_comment_only_query() {
803        let (_f, path) = create_test_db();
804        let err = query(&path, "/* just a comment */", &[], None).unwrap_err();
805        assert!(err.contains("read-only"));
806    }
807
808    #[test]
809    fn rejects_nested_comment_bypass() {
810        let (_f, path) = create_test_db();
811        let err = query(
812            &path,
813            "/* outer /* inner */ still comment */ DROP TABLE users",
814            &[],
815            None,
816        )
817        .unwrap_err();
818        assert!(err.contains("read-only"));
819    }
820
821    #[test]
822    fn blob_column_base64() {
823        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
824        let path = file.path().to_path_buf();
825        let conn = rusqlite::Connection::open(&path).unwrap();
826        conn.execute_batch("CREATE TABLE blobs (id INTEGER, data BLOB)")
827            .unwrap();
828        conn.execute("INSERT INTO blobs VALUES (1, X'DEADBEEF')", [])
829            .unwrap();
830        let result = query(&path, "SELECT * FROM blobs", &[], None).unwrap();
831        assert!(result["rows"][0]["data"]["__blob"].as_bool().unwrap());
832        assert_eq!(result["rows"][0]["data"]["size"], 4);
833    }
834
835    // ── WebView-internal exclusion + app-DB selection (audit / red-team "wrong DB") ──
836
837    fn write_sqlite(path: &Path, rows: usize) {
838        if let Some(parent) = path.parent() {
839            std::fs::create_dir_all(parent).unwrap();
840        }
841        let conn = rusqlite::Connection::open(path).unwrap();
842        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, blob TEXT)")
843            .unwrap();
844        for i in 0..rows {
845            conn.execute("INSERT INTO t (blob) VALUES (?)", [format!("row-{i}")])
846                .unwrap();
847        }
848    }
849
850    #[test]
851    fn flags_webview_internal_stores() {
852        assert!(is_webview_internal(Path::new(
853            "/app/EBWebView/Default/Cookies"
854        )));
855        assert!(is_webview_internal(Path::new(
856            "/app/EBWebView/Default/QuotaManager"
857        )));
858        assert!(is_webview_internal(Path::new(
859            "/Users/x/Library/WebKit/IndexedDB/file__0.indexeddb.sqlite3"
860        )));
861        assert!(is_webview_internal(Path::new(
862            "/app/Local Storage/leveldb.db"
863        )));
864        assert!(is_webview_internal(Path::new("/app/data/web data")));
865        // Real application DBs are NOT flagged.
866        assert!(!is_webview_internal(Path::new("/app/data/4da.db")));
867        assert!(!is_webview_internal(Path::new("/app/data/app.sqlite")));
868        assert!(!is_webview_internal(Path::new("/app/notes.db")));
869    }
870
871    #[test]
872    fn selects_app_db_over_webview_internals() {
873        // Reproduces the red-team layout: a WebView profile dir full of engine SQLite
874        // files sitting next to the real (larger) application DB. The selector must pick
875        // the app DB, never Cookies/QuotaManager.
876        let dir = tempfile::tempdir().unwrap();
877        let root = dir.path();
878        // Engine internals: flagged either by basename (Cookies/QuotaManager) or by living
879        // under a WebView profile dir (EBWebView). Real Chromium files are extensionless
880        // (and thus skipped by the extension filter entirely); we give them a recognized
881        // extension here precisely to prove the denylist also catches the extensioned forms
882        // (e.g. WebKit `*.indexeddb.sqlite3`).
883        write_sqlite(&root.join("EBWebView/Default/Cookies.db"), 1);
884        write_sqlite(&root.join("EBWebView/Default/QuotaManager.sqlite"), 1);
885        write_sqlite(&root.join("app.sqlite"), 200); // the real app DB (largest)
886
887        let selected = select_app_database(&[root.to_path_buf()]).unwrap();
888        assert_eq!(selected.file_name().unwrap(), "app.sqlite");
889
890        let classified = classify_databases(&[root.to_path_buf()]);
891        assert!(!classified[0].webview_internal, "app DB must rank first");
892        assert_eq!(classified[0].path.file_name().unwrap(), "app.sqlite");
893        assert!(
894            classified.iter().filter(|c| c.webview_internal).count() >= 2,
895            "Cookies + QuotaManager must be tagged as internal"
896        );
897    }
898
899    #[test]
900    fn errors_clearly_when_only_webview_internals_present() {
901        let dir = tempfile::tempdir().unwrap();
902        let root = dir.path();
903        write_sqlite(&root.join("EBWebView/Default/Cookies.db"), 1);
904        write_sqlite(&root.join("EBWebView/Default/QuotaManager.sqlite"), 1);
905
906        let err = select_app_database(&[root.to_path_buf()]).unwrap_err();
907        assert!(
908            err.contains("WebView") && err.contains("db_search_paths"),
909            "error should name the cause and the fix: {err}"
910        );
911    }
912
913    #[test]
914    fn larger_app_db_outranks_smaller_one() {
915        let dir = tempfile::tempdir().unwrap();
916        let root = dir.path();
917        write_sqlite(&root.join("small.db"), 1);
918        write_sqlite(&root.join("big.db"), 500);
919        let selected = select_app_database(&[root.to_path_buf()]).unwrap();
920        assert_eq!(selected.file_name().unwrap(), "big.db");
921    }
922}