diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index d802ff050..6f539de8d 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -69,6 +69,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; import { rankSavedSqlHistory } from "@/lib/savedSqlHistory"; import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/databaseFeatureSupport"; import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { detectDatabaseFileType } from "@/lib/databaseFileDetection"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -624,21 +625,12 @@ async function openPendingSqlFiles() { } } -const DB_EXTENSIONS = [".db", ".db3", ".sqlite", ".sqlite3", ".duckdb"]; - -function getDbTypeFromPath(path: string): "sqlite" | "duckdb" | null { - const lower = path.toLowerCase(); - if (lower.endsWith(".duckdb")) return "duckdb"; - if (DB_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "sqlite"; - return null; -} - async function openDbFilePath(path: string) { if (!isTauriRuntime()) return; await connectionStore.initFromDisk(); try { const name = path.split("/").pop()?.split("\\").pop() || path; - const dbType = getDbTypeFromPath(path); + const dbType = await detectDatabaseFileType(path); if (!dbType) return; // Check for existing connection with the same file path diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index f114e16ba..e11b058b6 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -31,6 +31,7 @@ import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/m import { copyToClipboard } from "@/lib/clipboard"; import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint"; import { prestoSqlBuiltinDriverPaths } from "@/lib/prestoSqlBuiltinDriver"; +import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/databaseFileDetection"; import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue"; import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connectionVisibleDatabases"; import { canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases"; @@ -2158,18 +2159,11 @@ async function browseEtcdTlsFile(target: "ca" | "cert" | "key") { async function browseDbFilePath() { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); - const filters = - form.value.db_type === "duckdb" - ? [{ name: "DuckDB", extensions: ["duckdb", "db"] }] - : form.value.db_type === "access" - ? [{ name: "Microsoft Access", extensions: ["accdb", "mdb"] }] - : form.value.db_type === "h2" - ? [{ name: "H2", extensions: ["db"] }] - : [{ name: "SQLite", extensions: ["db", "db3", "sqlite", "sqlite3"] }]; + const filters = form.value.db_type === "duckdb" ? [{ name: "DuckDB", extensions: ["duckdb", "db"] }] : form.value.db_type === "access" ? [{ name: "Microsoft Access", extensions: ["accdb", "mdb"] }] : form.value.db_type === "h2" ? [{ name: "H2", extensions: ["db"] }] : undefined; const selected = await open({ title: "Select Database File", multiple: false, - filters, + ...(filters ? { filters } : {}), }); if (selected && typeof selected === "string") { form.value.host = selected; @@ -2218,7 +2212,8 @@ async function createDuckDbFilePath() { } function ensureSqliteFileExtension(path: string): string { - return /\.(db|db3|sqlite|sqlite3)$/i.test(path) ? path : `${path}.db`; + const extensionPattern = new RegExp(`\\.(${SQLITE_DATABASE_FILE_EXTENSIONS.join("|")})$`, "i"); + return extensionPattern.test(path) ? path : `${path}.db`; } async function createSqliteFilePath() { @@ -2227,7 +2222,7 @@ async function createSqliteFilePath() { const selected = await save({ title: t("connection.createSqliteFile"), defaultPath: "database.db", - filters: [{ name: "SQLite", extensions: ["db", "db3", "sqlite", "sqlite3"] }], + filters: [{ name: "SQLite", extensions: SQLITE_DATABASE_FILE_EXTENSIONS }], }); if (!selected) return; diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index 77d9ab73e..6c1d40716 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -6,15 +6,7 @@ import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; import * as api from "@/lib/api"; import type { ConnectionConfig } from "@/types/database"; - -const DB_EXTENSIONS = [".db", ".db3", ".sqlite", ".sqlite3", ".duckdb"]; - -function getDbType(path: string): "sqlite" | "duckdb" | null { - const lower = path.toLowerCase(); - if (lower.endsWith(".duckdb")) return "duckdb"; - if (DB_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "sqlite"; - return null; -} +import { detectDatabaseFileType } from "@/lib/databaseFileDetection"; function isSqlFilePath(path: string): boolean { return /\.sql$/i.test(path); @@ -81,7 +73,7 @@ export function useFileDrop() { continue; } - const dbType = getDbType(path); + const dbType = await detectDatabaseFileType(path); if (!dbType) continue; const config: ConnectionConfig = { id: uuid(), diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 484d78857..d4b03cbaf 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -99,6 +99,7 @@ export const deleteSavedSqlFile = forward("deleteSavedSqlFile"); export const savedSqlStorageDir = forward("savedSqlStorageDir"); export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir"); export const revealPathInFileManager = forward("revealPathInFileManager"); +export const isSqliteDatabaseFile = forward("isSqliteDatabaseFile"); export const backupSqliteDatabase = forward("backupSqliteDatabase"); export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory"); diff --git a/apps/desktop/src/lib/databaseFileDetection.ts b/apps/desktop/src/lib/databaseFileDetection.ts new file mode 100644 index 000000000..30533d1b1 --- /dev/null +++ b/apps/desktop/src/lib/databaseFileDetection.ts @@ -0,0 +1,18 @@ +import * as api from "@/lib/api"; + +export const SQLITE_DATABASE_FILE_EXTENSIONS = ["db", "db3", "sqlite", "sqlite3", "sqlitedb"]; + +const SQLITE_DATABASE_EXTENSION_SET = new Set(SQLITE_DATABASE_FILE_EXTENSIONS.map((extension) => `.${extension}`)); + +export function databaseTypeFromKnownExtension(path: string): "sqlite" | "duckdb" | null { + const lower = path.toLowerCase(); + if (lower.endsWith(".duckdb")) return "duckdb"; + if ([...SQLITE_DATABASE_EXTENSION_SET].some((extension) => lower.endsWith(extension))) return "sqlite"; + return null; +} + +export async function detectDatabaseFileType(path: string): Promise<"sqlite" | "duckdb" | null> { + const knownType = databaseTypeFromKnownExtension(path); + if (knownType) return knownType; + return (await api.isSqliteDatabaseFile(path).catch(() => false)) ? "sqlite" : null; +} diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 446b98665..e1379124c 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -403,6 +403,10 @@ export async function revealPathInFileManager(_path: string): Promise { throw new Error("Reveal in file manager is only available in the desktop app."); } +export async function isSqliteDatabaseFile(_path: string): Promise { + return false; +} + export async function backupSqliteDatabase(_connectionId: string, _destinationPath: string): Promise { throw new Error("SQLite backup is only available in the desktop app."); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 444fdd7a5..558c83139 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1044,6 +1044,10 @@ export async function revealPathInFileManager(path: string): Promise { return invoke("reveal_path_in_file_manager", { path }); } +export async function isSqliteDatabaseFile(path: string): Promise { + return invoke("is_sqlite_database_file", { path }); +} + export async function backupSqliteDatabase(connectionId: string, destinationPath: string): Promise { return invoke("backup_sqlite_database", { connectionId, destinationPath }); } diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 500d2e461..e8265b8bb 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -2,6 +2,7 @@ use percent_encoding::percent_decode_str; use rusqlite::types::ValueRef; use rusqlite::{Connection, LoadExtensionGuard, OpenFlags}; use std::collections::HashSet; +use std::io::Read; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -10,6 +11,8 @@ use super::file_validator::validate_file_path; use crate::sql::starts_with_executable_sql_keyword; use crate::types::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo}; +const SQLITE_DATABASE_HEADER: &[u8; 16] = b"SQLite format 3\0"; + #[derive(Clone)] pub struct SqliteHandle { conn: Arc>, @@ -77,6 +80,9 @@ fn open_sqlite_handle( if !is_memory && create_if_missing { ensure_parent_dir(path)?; } + if !is_memory && !is_network_path(path) { + validate_existing_sqlite_file(path)?; + } let conn = if is_memory { Connection::open_in_memory().map_err(|e| format!("SQLite connection failed: {e}"))? @@ -100,6 +106,31 @@ fn open_sqlite_handle( Ok(SqliteHandle { conn: Arc::new(Mutex::new(conn)) }) } +pub fn path_has_sqlite_header(path: &Path) -> Result { + let mut file = std::fs::File::open(path).map_err(|e| format!("failed to open file: {e}"))?; + let mut header = [0_u8; 16]; + match file.read_exact(&mut header) { + Ok(()) => Ok(&header == SQLITE_DATABASE_HEADER), + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false), + Err(e) => Err(format!("failed to read file header: {e}")), + } +} + +fn validate_existing_sqlite_file(path: &str) -> Result<(), String> { + let path = Path::new(path); + if !path.exists() { + return Ok(()); + } + let metadata = path.metadata().map_err(|e| format!("failed to inspect SQLite database file: {e}"))?; + if metadata.len() == 0 { + return Ok(()); + } + if path_has_sqlite_header(path)? { + return Ok(()); + } + Err("Selected file is not a valid SQLite database file.".to_string()) +} + fn load_sqlite_extensions(conn: &Connection, extensions: &[SqliteExtensionSpec]) -> Result<(), String> { if extensions.is_empty() { return Ok(()); @@ -186,6 +217,48 @@ mod tests { assert_eq!(result.rows[0][0], serde_json::json!("Ada")); } + #[tokio::test] + async fn create_if_missing_rejects_existing_non_sqlite_file() { + let path = std::env::temp_dir().join(format!("dbx-not-sqlite-{}.png", uuid::Uuid::new_v4())); + std::fs::write(&path, b"\x89PNG\r\n\x1a\nnot sqlite").unwrap(); + + let err = match connect_path_create_if_missing(path.to_str().unwrap()).await { + Ok(_) => panic!("non-SQLite file should be rejected"), + Err(err) => err, + }; + + assert!(err.contains("not a valid SQLite database")); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn create_if_missing_allows_empty_custom_suffix_file() { + let path = std::env::temp_dir().join(format!("dbx-empty-sqlite-{}.conf", uuid::Uuid::new_v4())); + std::fs::write(&path, b"").unwrap(); + + let pool = connect_path_create_if_missing(path.to_str().unwrap()).await.expect("empty file can become SQLite"); + execute_query(&pool, "CREATE TABLE t (id INTEGER);").await.expect("write sqlite schema"); + + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn create_if_missing_allows_sqlite_database_with_custom_suffix() { + let path = std::env::temp_dir().join(format!("dbx-custom-sqlite-{}.conf", uuid::Uuid::new_v4())); + { + let pool = connect_path_create_if_missing(path.to_str().unwrap()).await.expect("create sqlite"); + execute_query(&pool, "CREATE TABLE t (id INTEGER);").await.expect("write sqlite schema"); + } + + let reopened = connect_path_create_if_missing(path.to_str().unwrap()).await.expect("reopen sqlite"); + let result = execute_query(&reopened, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 't';") + .await + .expect("query sqlite schema"); + assert_eq!(result.rows[0][0], serde_json::json!("t")); + + let _ = std::fs::remove_file(path); + } + #[test] fn sqlite_extension_specs_parse_repeated_and_multiline_url_params() { let params = "cache=shared&sqlite_extension=%2Fopt%2Fregexp.dylib&sqlite_extensions=%2Fopt%2Ftext.dylib%7Csqlite3_text_init%0A%2Fopt%2Fcrypto.dylib"; diff --git a/src-tauri/src/commands/fs_open.rs b/src-tauri/src/commands/fs_open.rs index 8dcd29d2b..ef1721102 100644 --- a/src-tauri/src/commands/fs_open.rs +++ b/src-tauri/src/commands/fs_open.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use dbx_core::db::sqlite::path_has_sqlite_header; use dbx_core::path_utils::expand_tilde; /// Reveal a file in the platform's file manager. @@ -75,6 +76,12 @@ pub async fn reveal_path_in_file_manager(path: String) -> Result<(), String> { reveal_in_file_manager(&resolved) } +#[tauri::command] +pub async fn is_sqlite_database_file(path: String) -> Result { + let resolved = validate_path(&path)?; + path_has_sqlite_header(&resolved) +} + #[cfg(test)] mod tests { use super::*; @@ -120,6 +127,26 @@ mod tests { assert_eq!(resolved, dir); } + #[test] + fn sqlite_header_is_detected() { + let path = std::env::temp_dir().join(format!("dbx-sqlite-header-{}.conf", uuid::Uuid::new_v4())); + std::fs::write(&path, b"SQLite format 3\0extra").unwrap(); + + assert!(path_has_sqlite_header(&path).unwrap()); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn non_sqlite_header_is_rejected() { + let path = std::env::temp_dir().join(format!("dbx-sqlite-header-{}.conf", uuid::Uuid::new_v4())); + std::fs::write(&path, b"not sqlite").unwrap(); + + assert!(!path_has_sqlite_header(&path).unwrap()); + + let _ = std::fs::remove_file(path); + } + #[test] fn tilde_is_expanded_when_home_set() { // Only run when HOME (or USERPROFILE) actually points somewhere we can diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 362785bc9..fee8da572 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -580,6 +580,7 @@ pub fn run() { commands::saved_sql::open_saved_sql_storage_dir, commands::saved_sql::sync_saved_sql_directory, commands::fs_open::reveal_path_in_file_manager, + commands::fs_open::is_sqlite_database_file, commands::sqlite_backup::backup_sqlite_database, commands::mongo_cmd::mongo_list_databases, commands::mongo_cmd::mongo_list_collections,