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")]
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#[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 let normalized: String = stripped
140 .chars()
141 .filter(|c| !matches!(c, '"' | '`' | '[' | ']'))
142 .collect();
143 let rest = normalized.trim_start();
144 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#[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#[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#[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#[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#[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#[cfg(feature = "sqlite")]
275#[derive(Debug, Clone)]
276pub struct DbCandidate {
277 pub path: PathBuf,
279 pub size_bytes: u64,
281 pub webview_internal: bool,
283}
284
285#[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 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#[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#[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 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 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 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 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 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 assert!(query(&path, r#"PRAGMA "main".table_info(users)"#, &[], None).is_ok());
677 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 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 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 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 let dir = tempfile::tempdir().unwrap();
877 let root = dir.path();
878 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); 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}