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/// Discover `SQLite` database files in a directory (non-recursive, max depth 2).
78#[cfg(feature = "sqlite")]
79#[must_use]
80pub fn discover_databases(dir: &Path) -> Vec<PathBuf> {
81    let mut results = Vec::new();
82    discover_recursive(dir, 0, 2, &mut results);
83    results
84}
85
86#[cfg(feature = "sqlite")]
87fn discover_recursive(dir: &Path, depth: u32, max_depth: u32, results: &mut Vec<PathBuf>) {
88    let Ok(entries) = std::fs::read_dir(dir) else {
89        return;
90    };
91    for entry in entries.flatten() {
92        let path = entry.path();
93        if path.is_symlink() {
94            continue;
95        }
96        if path.is_file() {
97            if let Some(ext) = path.extension().and_then(|e| e.to_str())
98                && matches!(ext, "sqlite" | "sqlite3" | "db" | "sdb")
99            {
100                results.push(path);
101            }
102        } else if path.is_dir() && depth < max_depth {
103            discover_recursive(&path, depth + 1, max_depth, results);
104        }
105    }
106}
107
108/// File basenames (matched case-insensitively, with or without a `SQLite` extension)
109/// that are browser-engine internal databases, never the application's own DB
110/// (Chromium/WebKit profile stores). Selecting one of these is the audit/red-team
111/// "wrong database" bug: an agent would confidently inspect `WebView` state instead of
112/// the app's data.
113#[cfg(feature = "sqlite")]
114const WEBVIEW_DB_BASENAMES: &[&str] = &[
115    "cookies",
116    "quotamanager",
117    "web data",
118    "history",
119    "favicons",
120    "top sites",
121    "login data",
122    "network action predictor",
123    "transportsecurity",
124    "trust tokens",
125    "sharedstorage",
126    "reporting and ntp",
127    "media history",
128    "affiliation database",
129    "site characteristics database",
130    "webdata",
131];
132
133/// Directory names (matched case-insensitively, anywhere in the path) that belong to a
134/// `WebView`/browser engine's private storage area. Any `.db`/`.sqlite` under one of these
135/// is an engine internal, not the app DB.
136#[cfg(feature = "sqlite")]
137const WEBVIEW_DIR_NAMES: &[&str] = &[
138    "ebwebview",
139    "wkwebview",
140    "webkit",
141    "local storage",
142    "indexeddb",
143    "session storage",
144    "service worker",
145    "gpucache",
146    "code cache",
147    "blob_storage",
148    "shared proto db",
149    "websql",
150];
151
152/// Whether a discovered database path is a `WebView`/browser-engine internal store rather
153/// than the application's own database (audit / red-team "wrong DB" finding).
154#[cfg(feature = "sqlite")]
155#[must_use]
156pub fn is_webview_internal(path: &Path) -> bool {
157    if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
158        let name = name.to_ascii_lowercase();
159        if WEBVIEW_DB_BASENAMES.iter().any(|n| name == *n) {
160            return true;
161        }
162    }
163    path.components().any(|c| {
164        let seg = c.as_os_str().to_string_lossy().to_ascii_lowercase();
165        WEBVIEW_DIR_NAMES.iter().any(|d| seg == *d)
166    })
167}
168
169/// A discovered database candidate with the metadata needed to disambiguate which DB the
170/// application actually uses.
171#[cfg(feature = "sqlite")]
172#[derive(Debug, Clone)]
173pub struct DbCandidate {
174    /// Absolute path to the discovered database file.
175    pub path: PathBuf,
176    /// File size in bytes (0 if it could not be stat'd).
177    pub size_bytes: u64,
178    /// Whether this is a `WebView`/browser-engine internal store rather than an app DB.
179    pub webview_internal: bool,
180}
181
182/// Classify every database discovered under `dirs`, returning application candidates first
183/// (non-`WebView`, largest by size — the substantial app DB outranks incidental ones) and
184/// `WebView` internals last. De-duplicates paths discovered via overlapping roots.
185#[cfg(feature = "sqlite")]
186#[must_use]
187pub fn classify_databases(dirs: &[PathBuf]) -> Vec<DbCandidate> {
188    let mut seen = std::collections::HashSet::new();
189    let mut candidates: Vec<DbCandidate> = Vec::new();
190    for dir in dirs {
191        for path in discover_databases(dir) {
192            let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
193            if !seen.insert(key) {
194                continue;
195            }
196            let size_bytes = std::fs::metadata(&path).map_or(0, |m| m.len());
197            let webview_internal = is_webview_internal(&path);
198            candidates.push(DbCandidate {
199                path,
200                size_bytes,
201                webview_internal,
202            });
203        }
204    }
205    // Application DBs first, then by size descending (larger ⇒ more likely the real DB).
206    candidates.sort_by(|a, b| {
207        a.webview_internal
208            .cmp(&b.webview_internal)
209            .then(b.size_bytes.cmp(&a.size_bytes))
210    });
211    candidates
212}
213
214/// Select the single most likely application database from `dirs`, excluding `WebView`
215/// internals.
216///
217/// # Errors
218/// Returns `Err` with a diagnostic when no application database is found — either no
219/// databases at all, or only `WebView`/browser-engine internal stores (the error lists
220/// the skipped internals so the caller can tell an agent to register the real DB
221/// directory via `db_search_paths` or pass an explicit `path`).
222#[cfg(feature = "sqlite")]
223pub fn select_app_database(dirs: &[PathBuf]) -> Result<PathBuf, String> {
224    let candidates = classify_databases(dirs);
225    if let Some(app) = candidates.iter().find(|c| !c.webview_internal) {
226        return Ok(app.path.clone());
227    }
228    if candidates.is_empty() {
229        let dirs_str = dirs
230            .iter()
231            .map(|d| d.display().to_string())
232            .collect::<Vec<_>>()
233            .join(", ");
234        return Err(format!("no SQLite databases found in: {dirs_str}"));
235    }
236    let internals = candidates
237        .iter()
238        .map(|c| c.path.display().to_string())
239        .collect::<Vec<_>>()
240        .join(", ");
241    Err(format!(
242        "only WebView/browser-engine internal databases were found ({internals}); none looks \
243         like an application database. Register the app's DB directory via \
244         VictauriBuilder::db_search_paths, or pass an explicit `path`."
245    ))
246}
247
248/// Execute a read-only SQL query against a `SQLite` database.
249///
250/// # Errors
251///
252/// Returns an error if the query is not read-only, the database cannot be opened,
253/// or the query fails.
254#[cfg(feature = "sqlite")]
255pub fn query(
256    db_path: &Path,
257    sql: &str,
258    params: &[serde_json::Value],
259    max_rows: Option<usize>,
260) -> Result<serde_json::Value, String> {
261    if !is_read_only(sql) {
262        return Err(
263            "only SELECT, PRAGMA, EXPLAIN, and WITH queries are allowed (read-only access)"
264                .to_string(),
265        );
266    }
267
268    // Defence in depth: the connection is opened READ_ONLY (SQLite rejects
269    // actual writes), but explicitly reject the write form of PRAGMA
270    // (`PRAGMA name = value`) so the read-only contract is self-evident and
271    // not solely reliant on the open flags. The read forms `PRAGMA name` and
272    // `PRAGMA name(arg)` remain allowed.
273    if is_pragma_write(sql) {
274        return Err(
275            "PRAGMA writes (PRAGMA name = value) are not allowed (read-only access)".to_string(),
276        );
277    }
278
279    let cleaned = strip_sql_comments(sql);
280    if cleaned.contains(';') {
281        let parts: Vec<&str> = cleaned
282            .split(';')
283            .filter(|s| !s.trim().is_empty())
284            .collect();
285        if parts.len() > 1 {
286            return Err(
287                "stacked queries (multiple statements separated by ;) are not allowed".to_string(),
288            );
289        }
290    }
291
292    let max_rows = max_rows.unwrap_or(MAX_ROWS_DEFAULT).min(MAX_ROWS_LIMIT);
293
294    let conn = rusqlite::Connection::open_with_flags(
295        db_path,
296        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
297    )
298    .map_err(|e| format!("failed to open database: {e}"))?;
299
300    // 5 second query timeout
301    conn.busy_timeout(std::time::Duration::from_secs(5))
302        .map_err(|e| format!("failed to set timeout: {e}"))?;
303
304    let mut stmt = conn
305        .prepare(sql)
306        .map_err(|e| format!("failed to prepare query: {e}"))?;
307
308    let column_names: Vec<String> = stmt
309        .column_names()
310        .iter()
311        .map(|s| (*s).to_string())
312        .collect();
313    let column_count = column_names.len();
314
315    let sqlite_params: Vec<Box<dyn rusqlite::types::ToSql>> =
316        params.iter().map(json_to_sql).collect();
317    let param_refs: Vec<&dyn rusqlite::types::ToSql> = sqlite_params.iter().map(|b| &**b).collect();
318
319    let mut rows_out: Vec<serde_json::Value> = Vec::new();
320    let mut rows = stmt
321        .query(param_refs.as_slice())
322        .map_err(|e| format!("query execution failed: {e}"))?;
323
324    while let Some(row) = rows.next().map_err(|e| format!("row read failed: {e}"))? {
325        if rows_out.len() >= max_rows {
326            break;
327        }
328        let mut obj = serde_json::Map::new();
329        for (i, col_name) in column_names.iter().enumerate().take(column_count) {
330            let value = row_value_to_json(row, i);
331            obj.insert(col_name.clone(), value);
332        }
333        rows_out.push(serde_json::Value::Object(obj));
334    }
335
336    let truncated = rows_out.len() == max_rows;
337
338    Ok(serde_json::json!({
339        "columns": column_names,
340        "rows": rows_out,
341        "row_count": rows_out.len(),
342        "truncated": truncated,
343        "max_rows": max_rows,
344    }))
345}
346
347#[cfg(feature = "sqlite")]
348fn json_to_sql(val: &serde_json::Value) -> Box<dyn rusqlite::types::ToSql> {
349    match val {
350        serde_json::Value::Null => Box::new(rusqlite::types::Null),
351        serde_json::Value::Bool(b) => Box::new(*b),
352        serde_json::Value::Number(n) => {
353            if let Some(i) = n.as_i64() {
354                Box::new(i)
355            } else if let Some(f) = n.as_f64() {
356                Box::new(f)
357            } else {
358                Box::new(n.to_string())
359            }
360        }
361        serde_json::Value::String(s) => Box::new(s.clone()),
362        other => Box::new(other.to_string()),
363    }
364}
365
366#[cfg(feature = "sqlite")]
367fn row_value_to_json(row: &rusqlite::Row, idx: usize) -> serde_json::Value {
368    use rusqlite::types::ValueRef;
369    match row.get_ref(idx) {
370        Ok(ValueRef::Null) => serde_json::Value::Null,
371        Ok(ValueRef::Integer(i)) => serde_json::json!(i),
372        Ok(ValueRef::Real(f)) => serde_json::json!(f),
373        Ok(ValueRef::Text(t)) => {
374            let s = String::from_utf8_lossy(t);
375            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&s)
376                && (parsed.is_object() || parsed.is_array())
377            {
378                return parsed;
379            }
380            serde_json::Value::String(s.into_owned())
381        }
382        Ok(ValueRef::Blob(b)) => {
383            use base64::Engine;
384            serde_json::json!({
385                "__blob": true,
386                "size": b.len(),
387                "base64": base64::engine::general_purpose::STANDARD.encode(b),
388            })
389        }
390        Err(_) => serde_json::Value::Null,
391    }
392}
393
394#[cfg(all(test, feature = "sqlite"))]
395mod tests {
396    use super::*;
397
398    fn create_test_db() -> (tempfile::NamedTempFile, PathBuf) {
399        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
400        let path = file.path().to_path_buf();
401        let conn = rusqlite::Connection::open(&path).unwrap();
402        conn.execute_batch(
403            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, score REAL);
404             INSERT INTO users VALUES (1, 'Alice', 95.5);
405             INSERT INTO users VALUES (2, 'Bob', 87.0);
406             INSERT INTO users VALUES (3, 'Charlie', 92.3);",
407        )
408        .unwrap();
409        (file, path)
410    }
411
412    #[test]
413    fn select_all_rows() {
414        let (_f, path) = create_test_db();
415        let result = query(&path, "SELECT * FROM users", &[], None).unwrap();
416        assert_eq!(result["row_count"], 3);
417        assert_eq!(
418            result["columns"],
419            serde_json::json!(["id", "name", "score"])
420        );
421        assert_eq!(result["rows"][0]["name"], "Alice");
422        assert_eq!(result["rows"][1]["name"], "Bob");
423    }
424
425    #[test]
426    fn select_with_params() {
427        let (_f, path) = create_test_db();
428        let result = query(
429            &path,
430            "SELECT name FROM users WHERE score > ?",
431            &[serde_json::json!(90.0)],
432            None,
433        )
434        .unwrap();
435        assert_eq!(result["row_count"], 2);
436    }
437
438    #[test]
439    fn max_rows_truncation() {
440        let (_f, path) = create_test_db();
441        let result = query(&path, "SELECT * FROM users", &[], Some(2)).unwrap();
442        assert_eq!(result["row_count"], 2);
443        assert_eq!(result["truncated"], true);
444    }
445
446    #[test]
447    fn rejects_insert() {
448        let (_f, path) = create_test_db();
449        let err = query(
450            &path,
451            "INSERT INTO users VALUES (4, 'Eve', 99.0)",
452            &[],
453            None,
454        )
455        .unwrap_err();
456        assert!(err.contains("read-only"));
457    }
458
459    #[test]
460    fn rejects_delete() {
461        let (_f, path) = create_test_db();
462        let err = query(&path, "DELETE FROM users", &[], None).unwrap_err();
463        assert!(err.contains("read-only"));
464    }
465
466    #[test]
467    fn rejects_drop() {
468        let (_f, path) = create_test_db();
469        let err = query(&path, "DROP TABLE users", &[], None).unwrap_err();
470        assert!(err.contains("read-only"));
471    }
472
473    #[test]
474    fn rejects_update() {
475        let (_f, path) = create_test_db();
476        let err = query(&path, "UPDATE users SET name = 'X'", &[], None).unwrap_err();
477        assert!(err.contains("read-only"));
478    }
479
480    #[test]
481    fn pragma_works() {
482        let (_f, path) = create_test_db();
483        let result = query(&path, "PRAGMA table_info(users)", &[], None).unwrap();
484        assert!(result["row_count"].as_u64().unwrap() >= 3);
485    }
486
487    #[test]
488    fn pragma_read_allowed() {
489        let (_f, path) = create_test_db();
490        assert!(query(&path, "PRAGMA journal_mode", &[], None).is_ok());
491        assert!(query(&path, "PRAGMA user_version", &[], None).is_ok());
492    }
493
494    #[test]
495    fn rejects_pragma_write_form() {
496        let (_f, path) = create_test_db();
497        for sql in [
498            "PRAGMA journal_mode=DELETE",
499            "PRAGMA journal_mode = WAL",
500            "PRAGMA user_version=12345",
501            "  pragma  synchronous = 0 ",
502        ] {
503            let err = query(&path, sql, &[], None).unwrap_err();
504            assert!(err.contains("PRAGMA writes"), "expected block for: {sql}");
505        }
506    }
507
508    #[test]
509    fn is_pragma_write_ignores_equals_in_strings() {
510        // A read-form PRAGMA whose argument contains '=' inside quotes is not a write.
511        assert!(!is_pragma_write("PRAGMA table_info('a=b')"));
512        assert!(is_pragma_write("PRAGMA foo = 'a=b'"));
513        assert!(!is_pragma_write("SELECT 1 = 1"));
514    }
515
516    #[test]
517    fn with_cte_works() {
518        let (_f, path) = create_test_db();
519        let result = query(
520            &path,
521            "WITH top AS (SELECT * FROM users WHERE score > 90) SELECT name FROM top",
522            &[],
523            None,
524        )
525        .unwrap();
526        assert_eq!(result["row_count"], 2);
527    }
528
529    #[test]
530    fn nonexistent_db_fails() {
531        let err = query(Path::new("/nonexistent/db.sqlite"), "SELECT 1", &[], None).unwrap_err();
532        assert!(err.contains("failed to open"));
533    }
534
535    #[test]
536    fn json_column_parsed() {
537        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
538        let path = file.path().to_path_buf();
539        let conn = rusqlite::Connection::open(&path).unwrap();
540        conn.execute_batch(
541            r#"CREATE TABLE config (key TEXT, value TEXT);
542               INSERT INTO config VALUES ('settings', '{"theme":"dark","lang":"en"}');"#,
543        )
544        .unwrap();
545        let result = query(&path, "SELECT * FROM config", &[], None).unwrap();
546        assert!(result["rows"][0]["value"].is_object());
547        assert_eq!(result["rows"][0]["value"]["theme"], "dark");
548    }
549
550    #[test]
551    fn discover_finds_sqlite_files() {
552        let dir = tempfile::tempdir().unwrap();
553        std::fs::File::create(dir.path().join("app.sqlite")).unwrap();
554        std::fs::File::create(dir.path().join("cache.db")).unwrap();
555        std::fs::File::create(dir.path().join("readme.txt")).unwrap();
556        let sub = dir.path().join("subdir");
557        std::fs::create_dir(&sub).unwrap();
558        std::fs::File::create(sub.join("deep.sqlite3")).unwrap();
559
560        let dbs = discover_databases(dir.path());
561        assert_eq!(dbs.len(), 3);
562    }
563
564    #[test]
565    fn rejects_comment_bypass_block() {
566        let (_f, path) = create_test_db();
567        let err = query(&path, "/* sneaky */DELETE FROM users", &[], None).unwrap_err();
568        assert!(err.contains("read-only"));
569    }
570
571    #[test]
572    fn rejects_line_comment_bypass() {
573        let (_f, path) = create_test_db();
574        let err = query(&path, "-- comment\nDELETE FROM users", &[], None).unwrap_err();
575        assert!(err.contains("read-only"));
576    }
577
578    #[test]
579    fn rejects_stacked_queries() {
580        let (_f, path) = create_test_db();
581        let err = query(&path, "SELECT 1; DROP TABLE users", &[], None).unwrap_err();
582        assert!(err.contains("stacked queries"));
583    }
584
585    #[test]
586    fn allows_trailing_semicolon() {
587        let (_f, path) = create_test_db();
588        let result = query(&path, "SELECT * FROM users;", &[], None).unwrap();
589        assert_eq!(result["row_count"], 3);
590    }
591
592    #[test]
593    fn allows_select_with_block_comment() {
594        let (_f, path) = create_test_db();
595        let result = query(
596            &path,
597            "/* filter */ SELECT name FROM users WHERE id = 1",
598            &[],
599            None,
600        )
601        .unwrap();
602        assert_eq!(result["row_count"], 1);
603        assert_eq!(result["rows"][0]["name"], "Alice");
604    }
605
606    #[test]
607    fn rejects_empty_query() {
608        let (_f, path) = create_test_db();
609        let err = query(&path, "", &[], None).unwrap_err();
610        assert!(err.contains("read-only"));
611    }
612
613    #[test]
614    fn rejects_comment_only_query() {
615        let (_f, path) = create_test_db();
616        let err = query(&path, "/* just a comment */", &[], None).unwrap_err();
617        assert!(err.contains("read-only"));
618    }
619
620    #[test]
621    fn rejects_nested_comment_bypass() {
622        let (_f, path) = create_test_db();
623        let err = query(
624            &path,
625            "/* outer /* inner */ still comment */ DROP TABLE users",
626            &[],
627            None,
628        )
629        .unwrap_err();
630        assert!(err.contains("read-only"));
631    }
632
633    #[test]
634    fn blob_column_base64() {
635        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
636        let path = file.path().to_path_buf();
637        let conn = rusqlite::Connection::open(&path).unwrap();
638        conn.execute_batch("CREATE TABLE blobs (id INTEGER, data BLOB)")
639            .unwrap();
640        conn.execute("INSERT INTO blobs VALUES (1, X'DEADBEEF')", [])
641            .unwrap();
642        let result = query(&path, "SELECT * FROM blobs", &[], None).unwrap();
643        assert!(result["rows"][0]["data"]["__blob"].as_bool().unwrap());
644        assert_eq!(result["rows"][0]["data"]["size"], 4);
645    }
646
647    // ── WebView-internal exclusion + app-DB selection (audit / red-team "wrong DB") ──
648
649    fn write_sqlite(path: &Path, rows: usize) {
650        if let Some(parent) = path.parent() {
651            std::fs::create_dir_all(parent).unwrap();
652        }
653        let conn = rusqlite::Connection::open(path).unwrap();
654        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, blob TEXT)")
655            .unwrap();
656        for i in 0..rows {
657            conn.execute("INSERT INTO t (blob) VALUES (?)", [format!("row-{i}")])
658                .unwrap();
659        }
660    }
661
662    #[test]
663    fn flags_webview_internal_stores() {
664        assert!(is_webview_internal(Path::new(
665            "/app/EBWebView/Default/Cookies"
666        )));
667        assert!(is_webview_internal(Path::new(
668            "/app/EBWebView/Default/QuotaManager"
669        )));
670        assert!(is_webview_internal(Path::new(
671            "/Users/x/Library/WebKit/IndexedDB/file__0.indexeddb.sqlite3"
672        )));
673        assert!(is_webview_internal(Path::new(
674            "/app/Local Storage/leveldb.db"
675        )));
676        assert!(is_webview_internal(Path::new("/app/data/web data")));
677        // Real application DBs are NOT flagged.
678        assert!(!is_webview_internal(Path::new("/app/data/4da.db")));
679        assert!(!is_webview_internal(Path::new("/app/data/app.sqlite")));
680        assert!(!is_webview_internal(Path::new("/app/notes.db")));
681    }
682
683    #[test]
684    fn selects_app_db_over_webview_internals() {
685        // Reproduces the red-team layout: a WebView profile dir full of engine SQLite
686        // files sitting next to the real (larger) application DB. The selector must pick
687        // the app DB, never Cookies/QuotaManager.
688        let dir = tempfile::tempdir().unwrap();
689        let root = dir.path();
690        // Engine internals: flagged either by basename (Cookies/QuotaManager) or by living
691        // under a WebView profile dir (EBWebView). Real Chromium files are extensionless
692        // (and thus skipped by the extension filter entirely); we give them a recognized
693        // extension here precisely to prove the denylist also catches the extensioned forms
694        // (e.g. WebKit `*.indexeddb.sqlite3`).
695        write_sqlite(&root.join("EBWebView/Default/Cookies.db"), 1);
696        write_sqlite(&root.join("EBWebView/Default/QuotaManager.sqlite"), 1);
697        write_sqlite(&root.join("app.sqlite"), 200); // the real app DB (largest)
698
699        let selected = select_app_database(&[root.to_path_buf()]).unwrap();
700        assert_eq!(selected.file_name().unwrap(), "app.sqlite");
701
702        let classified = classify_databases(&[root.to_path_buf()]);
703        assert!(!classified[0].webview_internal, "app DB must rank first");
704        assert_eq!(classified[0].path.file_name().unwrap(), "app.sqlite");
705        assert!(
706            classified.iter().filter(|c| c.webview_internal).count() >= 2,
707            "Cookies + QuotaManager must be tagged as internal"
708        );
709    }
710
711    #[test]
712    fn errors_clearly_when_only_webview_internals_present() {
713        let dir = tempfile::tempdir().unwrap();
714        let root = dir.path();
715        write_sqlite(&root.join("EBWebView/Default/Cookies.db"), 1);
716        write_sqlite(&root.join("EBWebView/Default/QuotaManager.sqlite"), 1);
717
718        let err = select_app_database(&[root.to_path_buf()]).unwrap_err();
719        assert!(
720            err.contains("WebView") && err.contains("db_search_paths"),
721            "error should name the cause and the fix: {err}"
722        );
723    }
724
725    #[test]
726    fn larger_app_db_outranks_smaller_one() {
727        let dir = tempfile::tempdir().unwrap();
728        let root = dir.path();
729        write_sqlite(&root.join("small.db"), 1);
730        write_sqlite(&root.join("big.db"), 500);
731        let selected = select_app_database(&[root.to_path_buf()]).unwrap();
732        assert_eq!(selected.file_name().unwrap(), "big.db");
733    }
734}