fix(sqlite): validate ssh backup and query errors

This commit is contained in:
skyler authored and GitHub committed 2026-08-30 13:30:00 +08:00
1 parent e6f8b2187a
commit 9190554e50
58 files changed
+2815 -149

No files matched your search

+65 -8
View File
@@ -384,6 +384,27 @@ mod tests {
assert!(error.contains("encrypted connections"), "{error}");
}
#[tokio::test]
async fn sqlite_ssh_config_does_not_open_the_remote_path_as_a_local_file() {
let mut config = sqlite_config(std::path::Path::new("/remote/data/app.db"), "");
config.transport_layers = serde_json::from_value(serde_json::json!([{
"type": "ssh",
"id": "hop-1",
"enabled": true,
"host": "203.0.113.10",
"port": 22,
"user": "testuser"
}]))
.expect("ssh layer");
let error = match connect_sqlite_from_config(&config).await {
Ok(_) => panic!("SSH SQLite must not open a remote path on the local filesystem"),
Err(error) => error,
};
assert!(error.contains("SSH") || error.contains("Desktop") || error.contains("worker"), "{error}");
assert!(!error.contains("File does not exist"), "{error}");
}
#[tokio::test]
async fn saving_memory_sqlite_attachments_keeps_the_live_pool_intact() {
let dir = std::env::temp_dir().join(format!("dbx-tauri-sqlite-memory-{}", uuid::Uuid::new_v4()));
@@ -869,7 +890,7 @@ pub async fn save_connections(state: State<'_, Arc<AppState>>, configs: Vec<Conn
async fn save_connection_configs(state: &AppState, configs: &[ConnectionConfig]) -> Result<(), String> {
for config in configs {
if config.db_type == DatabaseType::Sqlite {
if config.db_type == DatabaseType::Sqlite && !db::sqlite_worker::sqlite_ssh_worker_requested(config) {
db::sqlite::validate_persistent_attachments(
&config.host,
&config.password,
@@ -1003,7 +1024,34 @@ fn sqlite_extension_specs_from_config(config: &ConnectionConfig) -> Vec<db::sqli
.collect()
}
// Thin wrapper for the test suite below: production callers all go through
// `connect_sqlite_from_config_with_state` with a real AppState.
#[cfg(test)]
async fn connect_sqlite_from_config(config: &ConnectionConfig) -> Result<db::sqlite::SqliteHandle, String> {
connect_sqlite_from_config_with_state(None, config.id.as_str(), config).await
}
async fn connect_sqlite_from_config_with_state(
state: Option<&AppState>,
connection_id: &str,
config: &ConnectionConfig,
) -> Result<db::sqlite::SqliteHandle, String> {
if db::sqlite_worker::sqlite_ssh_worker_requested(config) {
let state =
state.ok_or_else(|| "Remote SQLite over SSH is only available in the DBX Desktop app".to_string())?;
let transport_layers = state.resolved_transport_layers(config).await?;
let worker = db::sqlite_worker::connect_sqlite_worker(
&state.tunnels,
&state.agent_manager,
state.storage.data_dir(),
connection_id,
config,
&transport_layers,
)
.await?;
return Ok(db::sqlite::SqliteHandle::from_worker(worker));
}
let sqlite_path = expand_tilde(&config.host);
db::sqlite::validate_persistent_attachments(&sqlite_path, &config.password, !config.attached_databases.is_empty())?;
let pool = db::sqlite::connect_path_with_cipher_key_and_extensions(
@@ -1180,10 +1228,15 @@ async fn test_connection_with_info_inner(
}
Err(e) => Err(e),
},
DatabaseType::Sqlite => match connect_sqlite_from_config(&config).await {
Ok(_) => Ok("Connection successful".to_string()),
Err(e) => Err(e),
},
DatabaseType::Sqlite => {
match connect_sqlite_from_config_with_state(Some(state.as_ref()), connection_id, &config).await {
Ok(pool) => {
pool.shutdown().await;
Ok("Connection successful".to_string())
}
Err(e) => Err(e),
}
}
DatabaseType::Redis => {
// Keep the result inside the outer lifecycle so temporary transports
// are reset after both successful and failed Redis tests.
@@ -1573,7 +1626,7 @@ pub async fn connect_db(
client_attempt: Option<u64>,
) -> Result<String, String> {
let config = config.canonicalized();
if config.db_type == DatabaseType::Sqlite {
if config.db_type == DatabaseType::Sqlite && !db::sqlite_worker::sqlite_ssh_worker_requested(&config) {
db::sqlite::validate_persistent_attachments(
&config.host,
&config.password,
@@ -1627,7 +1680,9 @@ pub async fn connect_db(
| DatabaseType::Kwdb
| DatabaseType::Questdb
| DatabaseType::OpenGauss => PoolKind::Postgres(db::postgres::connect(&url, connect_timeout).await?),
DatabaseType::Sqlite => PoolKind::Sqlite(connect_sqlite_from_config(&db_config).await?),
DatabaseType::Sqlite => PoolKind::Sqlite(
connect_sqlite_from_config_with_state(Some(state.inner().as_ref()), &id, &db_config).await?,
),
DatabaseType::Redis => {
let con = if db_config.uses_redis_cluster() {
PoolKind::Redis(db::redis_driver::RedisConnection::Cluster(
@@ -1969,7 +2024,9 @@ pub async fn connection_final_proxy_port(
if !runtime_config.has_effective_transport_layers() {
return Err("Connection has no configured transport layers".to_string());
}
if runtime_config.db_type == DatabaseType::Sqlite {
if runtime_config.db_type == DatabaseType::Sqlite
&& !db::sqlite_worker::sqlite_ssh_worker_requested(&runtime_config)
{
db::sqlite::validate_persistent_attachments(
&runtime_config.host,
&runtime_config.password,
+15 -1
View File
@@ -5,7 +5,10 @@ use tauri::State;
use dbx_core::connection::{AppState, PoolKind};
use dbx_core::db::sqlite::{is_memory_database_path, SqliteHandle};
use dbx_core::models::connection::DatabaseType;
use dbx_core::sqlite_backup::{backup_sqlite_database as backup_sqlite_database_core, SqliteBackupOptions};
use dbx_core::sqlite_backup::{
backup_sqlite_database as backup_sqlite_database_core, restore_sqlite_database as restore_sqlite_database_core,
SqliteBackupOptions,
};
#[tauri::command]
pub async fn backup_sqlite_database(
@@ -26,6 +29,17 @@ pub async fn backup_sqlite_database(
backup_sqlite_database_core(pool, SqliteBackupOptions { source_path, destination_path }).await
}
#[tauri::command]
pub async fn restore_sqlite_database(
state: State<'_, Arc<AppState>>,
connection_id: String,
source_path: String,
) -> Result<(), String> {
let pool_key = state.get_or_create_pool(&connection_id, None).await?;
let pool = existing_sqlite_pool(&state, &pool_key).await?.ok_or_else(|| "Connection is not open".to_string())?;
restore_sqlite_database_core(pool, &source_path).await
}
async fn sqlite_source_path(state: &Arc<AppState>, connection_id: &str) -> Result<String, String> {
let configs = state.configs.read().await;
let config = configs.get(connection_id).ok_or_else(|| format!("Connection config not found: {connection_id}"))?;
+2
View File
@@ -1605,6 +1605,7 @@ pub fn run() {
} else {
AppState::new_with_plugin_dir_and_app_version(storage, plugin_dir, env!("CARGO_PKG_VERSION"))
};
dbx_core::db::sqlite_worker::enable_sqlite_ssh_runtime(env!("CARGO_PKG_VERSION"));
state.set_duckdb_worker_process_isolation_enabled(desktop_settings.duckdb_worker_process_isolation);
state.set_duckdb_worker_max_processes(desktop_settings.duckdb_worker_max_processes);
let oidc_app_handle = app.handle().clone();
@@ -2174,6 +2175,7 @@ pub fn run() {
commands::fs_open::is_sqlite_database_file,
commands::fs_open::delete_database_backup_files,
commands::sqlite_backup::backup_sqlite_database,
commands::sqlite_backup::restore_sqlite_database,
commands::mongo_cmd::mongo_list_databases,
commands::mongo_cmd::mongo_list_collections,
commands::vector_cmd::vector_collection_detail,