diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c511055b..daf5af16 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6433,7 +6433,7 @@ dependencies = [ [[package]] name = "sqlkit" -version = "0.7.8" +version = "0.7.9" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 353dbe83..09f403b0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -58,10 +58,10 @@ tiberius = { version = "0.12", default-features = false, features = [ "rust_decimal" ] } tokio-util = { version = "0.7", features = [ "compat" ] } -rusqlite = { version = "0.32", features = [ - "bundled", - "chrono" -] } +rusqlite = { version = "0.32", default-features = false, features = [ "chrono" ] } +# C library bundling is selected by cargo features: +# default → rusqlite/bundled (SQLite) +# sqlcipher → rusqlite/bundled-sqlcipher (SQLCipher, encrypted SQLite) uuid = { version = "1", features = [ "v4" ] } tauri-plugin-store = "2" tauri-plugin-dialog = "2" @@ -109,5 +109,8 @@ mockall = "0.13" tempfile = "3" [features] -default = [] +default = [ "rusqlite/bundled" ] ohos = [] +# Use SQLCipher (encrypted SQLite) instead of plain SQLite. +# Build with: cargo build --no-default-features --features sqlcipher +sqlcipher = [ "rusqlite/bundled-sqlcipher" ] diff --git a/src-tauri/src/commands/helpers.rs b/src-tauri/src/commands/helpers.rs index 85de96c1..56ddca36 100644 --- a/src-tauri/src/commands/helpers.rs +++ b/src-tauri/src/commands/helpers.rs @@ -149,6 +149,7 @@ fn db_type_to_enum(db_type: &str) -> Result Ok(DatabaseType::MySQL), "sqlserver" | "mssql" => Ok(DatabaseType::SqlServer), "sqlite" => Ok(DatabaseType::SQLite), + "sqlcipher" => Ok(DatabaseType::SQLCipher), "duckdb" | "duck_db" | "duck" => Ok(DatabaseType::DuckDb), "clickhouse" => Ok(DatabaseType::ClickHouse), "firebird" => Ok(DatabaseType::Firebird), @@ -218,7 +219,9 @@ fn db_type_to_enum(db_type: &str) -> Result bool { matches!( db_type, - crate::database::DatabaseType::SQLite | crate::database::DatabaseType::DuckDb + crate::database::DatabaseType::SQLite + | crate::database::DatabaseType::SQLCipher + | crate::database::DatabaseType::DuckDb ) } diff --git a/src-tauri/src/database/config.rs b/src-tauri/src/database/config.rs index c0b7c164..a38def11 100644 --- a/src-tauri/src/database/config.rs +++ b/src-tauri/src/database/config.rs @@ -24,6 +24,8 @@ pub enum DatabaseType { SqlServer, /// SQLite. SQLite, + /// SQLCipher — encrypted SQLite variant (same adapter, requires encryption key). + SQLCipher, /// DuckDB — JDBC bridge. DuckDb, /// ClickHouse (HTTP protocol). diff --git a/src-tauri/src/database/sqlite.rs b/src-tauri/src/database/sqlite.rs index a4668e87..f19f1f87 100644 --- a/src-tauri/src/database/sqlite.rs +++ b/src-tauri/src/database/sqlite.rs @@ -47,19 +47,23 @@ const MEMORY_DB: &str = ":memory:"; /// /// SQLite has limited concurrency support compared to client-server databases. /// This pool manages connections with proper synchronization for thread-safety. +/// For SQLCipher, an encryption key is stored and applied via `PRAGMA key` on +/// each newly created connection. pub struct SQLitePool { connections: Arc>>>>, max_connections: usize, db_path: Option, + encryption_key: Option, } impl SQLitePool { /// Create a new SQLite connection pool. - fn new(max_connections: usize, db_path: Option) -> Self { + fn new(max_connections: usize, db_path: Option, encryption_key: Option) -> Self { Self { connections: Arc::new(Mutex::new(Vec::new())), max_connections, db_path, + encryption_key, } } @@ -110,8 +114,22 @@ impl SQLitePool { })? }; + // Apply SQLCipher encryption key if provided + // + // SQLCipher uses `PRAGMA key` to set the passphrase. When compiled with + // the `sqlcipher` cargo feature, rusqlite links against SQLCipher instead + // of SQLite, and this pragma activates encryption. Without the feature + // (default SQLite build), the pragma is silently ignored. + if let Some(ref key) = self.encryption_key { + conn.execute(&format!("PRAGMA key = '{}'", key.replace('\'', "''")), []) + .map_err(|e| { + DbError::Configuration(format!("Failed to set SQLCipher encryption key: {}", e)) + })?; + } + // Enable Write-Ahead Logging (WAL) mode for better concurrency // WAL mode allows multiple readers and one writer to proceed concurrently + // Note: SQLCipher with WAL requires the same key to be set on each connection. if self.db_path.is_some() { conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(())) .map_err(|e| DbError::Configuration(format!("Failed to enable WAL mode: {}", e)))?; @@ -201,12 +219,14 @@ impl ConnectionPool for SQLitePool { /// SQLite database adapter. /// -/// Supports both file-based and in-memory SQLite databases with proper -/// thread-safety through connection pooling and Write-Ahead Logging (WAL) mode. +/// Supports both file-based and in-memory SQLite databases (and SQLCipher +/// encrypted databases) with proper thread-safety through connection pooling +/// and Write-Ahead Logging (WAL) mode. pub struct SQLiteAdapter { config: ConnectionConfig, pool: Option>, db_path: Option, + encryption_key: Option, } impl SQLiteAdapter { @@ -215,6 +235,8 @@ impl SQLiteAdapter { /// The database path can be specified in the config.database field: /// - For file-based databases: Use a file path (e.g., "/path/to/db.sqlite") /// - For in-memory databases: Use ":memory:" or leave database as None + /// + /// For SQLCipher, the encryption passphrase can be provided via `config.password`. pub fn new(config: ConnectionConfig) -> Self { let db_path = config.database.as_ref().and_then(|db| { if db == MEMORY_DB { @@ -224,10 +246,17 @@ impl SQLiteAdapter { } }); + let encryption_key = if config.db_type == crate::database::config::DatabaseType::SQLCipher { + config.password.clone() + } else { + None + }; + Self { config, pool: None, db_path, + encryption_key, } } @@ -422,7 +451,11 @@ impl DatabaseAdapter for SQLiteAdapter { self.config.pool_config.max_connections as usize }; - let pool = SQLitePool::new(max_connections, self.db_path.clone()); + let pool = SQLitePool::new( + max_connections, + self.db_path.clone(), + self.encryption_key.clone(), + ); // Test the connection by creating one let conn = pool.get_conn().await?; diff --git a/src-tauri/src/database/strategy.rs b/src-tauri/src/database/strategy.rs index ea7c28f3..86ffa02a 100644 --- a/src-tauri/src/database/strategy.rs +++ b/src-tauri/src/database/strategy.rs @@ -59,7 +59,7 @@ pub fn resolve_effective_type(db: DatabaseType) -> ConnectionStrategy { // Other native adapters SqlServer => ConnectionStrategy::Native(CoreDatabaseType::SqlServer), - SQLite => ConnectionStrategy::Native(CoreDatabaseType::SQLite), + SQLite | SQLCipher => ConnectionStrategy::Native(CoreDatabaseType::SQLite), ClickHouse => ConnectionStrategy::Native(CoreDatabaseType::ClickHouse), // JDBC bridge (Java subprocess) @@ -131,7 +131,7 @@ pub fn default_port(db: DatabaseType) -> Option { Databend => Some(3307), ManticoreSearch => Some(9306), SqlServer => Some(1433), - SQLite => None, + SQLite | SQLCipher => None, DuckDb => None, ClickHouse => Some(8123), Firebird => Some(3050), diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8286a937..590660f8 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -132,6 +132,7 @@ impl ServerConfig { "mysql" => Ok(DatabaseType::MySQL), "sqlserver" | "mssql" => Ok(DatabaseType::SqlServer), "sqlite" => Ok(DatabaseType::SQLite), + "sqlcipher" => Ok(DatabaseType::SQLCipher), "duckdb" | "duck" => Ok(DatabaseType::DuckDb), "clickhouse" => Ok(DatabaseType::ClickHouse), "firebird" => Ok(DatabaseType::Firebird), @@ -209,8 +210,8 @@ impl ServerConfig { } let db_lower = self.db_type.to_lowercase(); - // DuckDB is file-based, uses JDBC bridge with jdbc:duckdb:{filepath} - if db_lower == "sqlite" || db_lower == "duckdb" || db_lower == "duck" { + // SQLite, SQLCipher, and DuckDB are file-based + if db_lower == "sqlite" || db_lower == "sqlcipher" || db_lower == "duckdb" || db_lower == "duck" { config = config.with_database(&self.host); } else if let Some(ref database) = self.database { config = config.with_database(database); diff --git a/src-tauri/src/transfer/ddl.rs b/src-tauri/src/transfer/ddl.rs index 303a042b..d27d3af2 100644 --- a/src-tauri/src/transfer/ddl.rs +++ b/src-tauri/src/transfer/ddl.rs @@ -15,7 +15,9 @@ pub fn generate_ddl_for_engine( match engine { DatabaseType::PostgreSQL => generate_postgres_ddl(schema, table, columns, options), DatabaseType::MySQL => generate_mysql_ddl(schema, table, columns, options), - DatabaseType::SQLite => generate_sqlite_ddl(schema, table, columns, options), + DatabaseType::SQLite | DatabaseType::SQLCipher => { + generate_sqlite_ddl(schema, table, columns, options) + } DatabaseType::SqlServer => generate_sqlserver_ddl(schema, table, columns, options), _ => generate_generic_ddl(schema, table, columns, options), } diff --git a/src-tauri/src/transfer/migration.rs b/src-tauri/src/transfer/migration.rs index 9545ae2a..748bb776 100644 --- a/src-tauri/src/transfer/migration.rs +++ b/src-tauri/src/transfer/migration.rs @@ -631,7 +631,7 @@ fn map_type_to_engine(source_type: &str, target_engine: DatabaseType) -> String "bytea" => "BLOB".to_string(), _ => source_type.to_string(), }, - DatabaseType::SQLite => match source_lower.as_str() { + DatabaseType::SQLite | DatabaseType::SQLCipher => match source_lower.as_str() { "int" | "integer" | "bigint" | "smallint" | "tinyint" => "INTEGER".to_string(), "varchar" | "char" | "text" => "TEXT".to_string(), "datetime" | "timestamp" | "date" => "TEXT".to_string(), diff --git a/src/assets/images/database-icons/sqlcipher-logo.svg b/src/assets/images/database-icons/sqlcipher-logo.svg new file mode 100644 index 00000000..559bd428 --- /dev/null +++ b/src/assets/images/database-icons/sqlcipher-logo.svg @@ -0,0 +1 @@ + diff --git a/src/components/connections/ServerFormDialog.vue b/src/components/connections/ServerFormDialog.vue index f7011a96..cf6874e7 100644 --- a/src/components/connections/ServerFormDialog.vue +++ b/src/components/connections/ServerFormDialog.vue @@ -52,6 +52,7 @@ const defaultPorts: Record = { [DatabaseType.MYSQL]: 3306, [DatabaseType.MARIADB]: 3306, [DatabaseType.SQLITE]: 0, + [DatabaseType.SQLCIPHER]: 0, [DatabaseType.SQLSERVER]: 1433, [DatabaseType.DUCKDB]: 0, [DatabaseType.CLICKHOUSE]: 8123, @@ -314,21 +315,29 @@ function handleDatabaseTypeChange(value: string) { else { formData.value.oracleOptions = undefined } - if (type === DatabaseType.SQLITE || type === DatabaseType.DUCKDB) { + if (type === DatabaseType.SQLITE || type === DatabaseType.SQLCIPHER || type === DatabaseType.DUCKDB) { formData.value.host = '' formData.value.port = 0 - formData.value.username = '' - formData.value.password = '' - sqliteTab.value = 'file' + if (type === DatabaseType.SQLITE || type === DatabaseType.DUCKDB) { + formData.value.username = '' + formData.value.password = '' + } + sqliteTab.value = type === DatabaseType.SQLITE ? 'file' : 'file' savedFilePath.value = '' } } const isFileBased = computed(() => formData.value.type === DatabaseType.SQLITE + || formData.value.type === DatabaseType.SQLCIPHER || formData.value.type === DatabaseType.DUCKDB, ) +// SQLCipher is file-based but also needs a password (encryption key) +const isEncryptedFileDb = computed(() => + formData.value.type === DatabaseType.SQLCIPHER, +) + const isJdbcDb = computed(() => isJdbcDatabase(formData.value.type)) const dbTypeI18nKeyMap: Record = { @@ -507,7 +516,7 @@ async function selectDatabaseFile() { const selected = await openDialog({ multiple: false, filters: [ - { name: 'SQLite', extensions: ['db', 'sqlite', 'sqlite3'] }, + { name: 'Database', extensions: ['db', 'sqlite', 'sqlite3', 'sqlcipher', 'db3'] }, { name: 'DuckDB', extensions: ['duckdb', 'db'] }, { name: 'All Files', extensions: ['*'] }, ], @@ -762,8 +771,11 @@ function handleSave() { return } - // Save recent database path for SQLite - if (formData.value.type === DatabaseType.SQLITE && formData.value.host !== ':memory:') { + // Save recent database path for file-based databases + if ( + (formData.value.type === DatabaseType.SQLITE || formData.value.type === DatabaseType.SQLCIPHER) + && formData.value.host !== ':memory:' + ) { saveRecentDatabase(formData.value.host) } @@ -1102,83 +1114,174 @@ function handleSave() { - +
- - - - - {{ t('components.serverForm.sqlite.modes.file') }} - - - {{ t('components.serverForm.sqlite.modes.inMemory') }} - - - - - - -
- -
- - + + + + + - - -

- {{ t('components.serverForm.sqlite.inMemoryHint') }} + +

- -
+ +
+ +
+ + +
+
+
= { MYSQL: { icon: mysqlLogo, color: 'bg-orange-100 dark:bg-orange-900/30' }, MARIADB: { icon: mariadbLogo, color: 'bg-purple-100 dark:bg-purple-900/30' }, SQLITE: { icon: sqliteLogo, color: 'bg-green-100 dark:bg-green-900/30' }, + SQLCIPHER: { icon: sqlcipherLogo, color: 'bg-green-100 dark:bg-green-900/30' }, SQLSERVER: { icon: sqlserverLogo, color: 'bg-red-100 dark:bg-red-900/30' }, DUCKDB: { icon: duckdbLogo, color: 'bg-yellow-100 dark:bg-yellow-900/30' }, CLICKHOUSE: { icon: clickhouseLogo, color: 'bg-red-100 dark:bg-red-900/30' }, diff --git a/src/lang/enUS.ts b/src/lang/enUS.ts index 9a82ef9f..bd8d2f66 100644 --- a/src/lang/enUS.ts +++ b/src/lang/enUS.ts @@ -913,6 +913,7 @@ export const enUS = { databaseType: 'Database Type', host: 'Host', databaseFilePath: 'Database File Path', + encryptionKey: 'Encryption Key', port: 'Port', database: 'Database', username: 'Username', @@ -926,6 +927,7 @@ export const enUS = { selectType: 'Select database type', host: 'localhost', filePath: '/path/to/database.db', + encryptionKey: 'Enter encryption passphrase', database: 'database_name', username: 'username', password: '••••••••', @@ -936,6 +938,7 @@ export const enUS = { 'mysql': 'MySQL', 'mariadb': 'MariaDB', 'sqlite': 'SQLite', + 'sqlcipher': 'SQLCipher', 'duckdb': 'DuckDB', 'clickhouse': 'ClickHouse', 'cockroachdb': 'CockroachDB', diff --git a/src/lang/zhCN.ts b/src/lang/zhCN.ts index e2a9bc33..88f34fee 100644 --- a/src/lang/zhCN.ts +++ b/src/lang/zhCN.ts @@ -913,6 +913,7 @@ export const zhCN = { databaseType: '数据库类型', host: '主机地址', databaseFilePath: '数据库文件路径', + encryptionKey: '加密密钥', port: '端口', database: '数据库', username: '用户名', @@ -936,6 +937,7 @@ export const zhCN = { 'mysql': 'MySQL', 'mariadb': 'MariaDB', 'sqlite': 'SQLite', + 'sqlcipher': 'SQLCipher', 'duckdb': 'DuckDB', 'clickhouse': 'ClickHouse', 'cockroachdb': 'CockroachDB', diff --git a/src/store/connectionStore.ts b/src/store/connectionStore.ts index 9ba08945..12f312a0 100644 --- a/src/store/connectionStore.ts +++ b/src/store/connectionStore.ts @@ -9,6 +9,7 @@ export enum DatabaseType { POSTGRESQL = 'POSTGRESQL', MARIADB = 'MARIADB', SQLITE = 'SQLITE', + SQLCIPHER = 'SQLCIPHER', SQLSERVER = 'SQLSERVER', DUCKDB = 'DUCKDB', CLICKHOUSE = 'CLICKHOUSE', @@ -81,6 +82,7 @@ export const dbTypeToBackend: Record = { [DatabaseType.MYSQL]: MYSQL_BACKEND, [DatabaseType.MARIADB]: 'mariadb', [DatabaseType.SQLITE]: 'SQLite', + [DatabaseType.SQLCIPHER]: 'sqlcipher', [DatabaseType.SQLSERVER]: 'SqlServer', [DatabaseType.DUCKDB]: 'duckdb', [DatabaseType.CLICKHOUSE]: 'clickhouse', @@ -151,6 +153,7 @@ export const dbTypeFromBackend: Record = { 'mariadb': DatabaseType.MARIADB, 'SqlServer': DatabaseType.SQLSERVER, 'SQLite': DatabaseType.SQLITE, + 'sqlcipher': DatabaseType.SQLCIPHER, 'duckdb': DatabaseType.DUCKDB, 'clickhouse': DatabaseType.CLICKHOUSE, 'cockroachdb': DatabaseType.COCKROACHDB,