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#[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#[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#[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#[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#[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#[cfg(feature = "sqlite")]
172#[derive(Debug, Clone)]
173pub struct DbCandidate {
174 pub path: PathBuf,
176 pub size_bytes: u64,
178 pub webview_internal: bool,
180}
181
182#[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 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#[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#[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 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 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 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 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 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 let dir = tempfile::tempdir().unwrap();
689 let root = dir.path();
690 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); 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}