From 1ed20e424ecca6ab37a67a5f2d5e08c47e7a51ce Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 25 Aug 2026 23:37:52 +0800 Subject: [PATCH] feat(sqlite): support RC4 Legacy databases --- Cargo.lock | 11 +- agents/drivers/duckdb/Cargo.lock | 10 +- apps/desktop/src/App.vue | 2 +- apps/desktop/src/composables/useFileDrop.ts | 4 +- apps/desktop/src/i18n/locales/en.ts | 2 +- apps/desktop/src/i18n/locales/es.ts | 2 +- apps/desktop/src/i18n/locales/it.ts | 2 +- apps/desktop/src/i18n/locales/ja.ts | 2 +- apps/desktop/src/i18n/locales/ko.ts | 2 +- apps/desktop/src/i18n/locales/pt-BR.ts | 2 +- apps/desktop/src/i18n/locales/zh-CN.ts | 2 +- apps/desktop/src/i18n/locales/zh-TW.ts | 2 +- .../__tests__/connectionErrorHints.spec.ts | 10 +- .../lib/connection/connectionErrorHints.ts | 5 + .../connectionStore.savePassword.spec.ts | 29 ++++ apps/desktop/src/stores/connectionStore.ts | 11 +- crates/dbx-core/Cargo.toml | 5 +- crates/dbx-core/src/db/sqlite.rs | 129 ++++++++++++++---- src-tauri/src/commands/connection.rs | 4 +- 19 files changed, 187 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d322980ae8..e35af947ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2096,6 +2096,7 @@ dependencies = [ "insta", "json5", "jsonwebtoken", + "libsqlite3-hotbundle", "log", "minijinja", "mongodb", @@ -4610,14 +4611,20 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-hotbundle" +version = "1.510300.0" +source = "git+https://github.com/nikescar/libsqlite3-hotbundle.git?rev=a208c7ef1447a02bf5ecb8c6a297aca012d41244#a208c7ef1447a02bf5ecb8c6a297aca012d41244" +dependencies = [ + "cc", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", - "openssl-sys", "pkg-config", "vcpkg", ] diff --git a/agents/drivers/duckdb/Cargo.lock b/agents/drivers/duckdb/Cargo.lock index 2119d735f0..f66e33c3b8 100644 --- a/agents/drivers/duckdb/Cargo.lock +++ b/agents/drivers/duckdb/Cargo.lock @@ -1252,6 +1252,7 @@ dependencies = [ "insta", "json5", "jsonwebtoken", + "libsqlite3-hotbundle", "log", "minijinja", "mongodb", @@ -2833,13 +2834,20 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-hotbundle" +version = "1.510300.0" +source = "git+https://github.com/nikescar/libsqlite3-hotbundle.git?rev=a208c7ef1447a02bf5ecb8c6a297aca012d41244#a208c7ef1447a02bf5ecb8c6a297aca012d41244" +dependencies = [ + "cc", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", "pkg-config", "vcpkg", ] diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index ba0401009c..17cafe09c5 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -1661,7 +1661,7 @@ async function openDbFilePath(path: string) { password: "", }; await connectionStore.addConnection(config); - void connectionStore.connect(config); + await connectionStore.connect(config); toast(t("welcome.fileOpened", { name })); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index cb4b7ff0a1..4b284b02ad 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -109,10 +109,10 @@ export function useFileDrop() { }; try { await connectionStore.addConnection(config); - void connectionStore.connect(config); + await connectionStore.connect(config); toast(t("welcome.fileOpened", { name })); } catch (e: any) { - toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); + toast(t("welcome.fileOpenFailed", { name, message: e?.message || String(e) }), 5000); } } }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 04ff8f6533..fb1e95b054 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -436,7 +436,7 @@ export default { createDuckDbFile: "Create DuckDB file", createSqliteFile: "Create SQLite database", memoryDatabasePathHint: "Use :memory: to create an in-memory SQLite or DuckDB database.", - sqliteCipherKey: "SQLCipher Key", + sqliteCipherKey: "SQLite Encryption Key", sqliteCipherKeyPlaceholder: "Leave empty for unencrypted SQLite", sqliteExtensions: "SQLite Extensions", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b65090a27c..638b2bc8c5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -438,7 +438,7 @@ export default withEnglishFallback({ createDuckDbFile: "Crear archivo DuckDB", createSqliteFile: "Crear base de datos SQLite", memoryDatabasePathHint: "Usa :memory: para crear una base de datos SQLite o DuckDB en memoria.", - sqliteCipherKey: "Clave SQLCipher", + sqliteCipherKey: "Clave de cifrado de SQLite", sqliteCipherKeyPlaceholder: "Déjalo vacío para SQLite sin cifrar", sqliteExtensions: "Extensiones de SQLite", sqliteExtensionsPlaceholder: "/ruta/a/regexp.dylib\n/ruta/a/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 522b5de47f..3e5c0d05aa 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -437,7 +437,7 @@ export default withEnglishFallback({ createDuckDbFile: "Crea file DuckDB", createSqliteFile: "Crea database SQLite", memoryDatabasePathHint: "Usa :memory: per creare un database SQLite o DuckDB in memoria.", - sqliteCipherKey: "Chiave SQLCipher", + sqliteCipherKey: "Chiave di crittografia SQLite", sqliteCipherKeyPlaceholder: "Lascia vuoto per SQLite non cifrato", sqliteExtensions: "Estensioni SQLite", sqliteExtensionsPlaceholder: "/percorso/per/regexp.dylib\n/percorso/per/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e9668603ab..2f2b4f6d04 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -437,7 +437,7 @@ export default withEnglishFallback({ createDuckDbFile: "DuckDBファイルを作成", createSqliteFile: "SQLiteデータベースを作成", memoryDatabasePathHint: "インメモリのSQLiteまたはDuckDBデータベースを作成するには :memory: を使用してください。", - sqliteCipherKey: "SQLCipherキー", + sqliteCipherKey: "SQLite暗号化キー", sqliteCipherKeyPlaceholder: "暗号化されていないSQLiteでは空のままにします", sqliteExtensions: "SQLite拡張", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 2a9b5b65d9..b9db45c821 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -430,7 +430,7 @@ export default withEnglishFallback({ createDuckDbFile: "DuckDB 파일 만들기", createSqliteFile: "SQLite 데이터베이스 만들기", memoryDatabasePathHint: "인메모리 SQLite 또는 DuckDB 데이터베이스를 만들려면 :memory:를 사용하세요.", - sqliteCipherKey: "SQLCipher 키", + sqliteCipherKey: "SQLite 암호화 키", sqliteCipherKeyPlaceholder: "암호화하지 않은 SQLite는 비워 두세요", sqliteExtensions: "SQLite 확장", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 806011ce06..53194ab37d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -438,7 +438,7 @@ export default withEnglishFallback({ createDuckDbFile: "Criar arquivo DuckDB", createSqliteFile: "Criar banco de dados SQLite", memoryDatabasePathHint: "Use :memory: para criar um banco de dados SQLite ou DuckDB na memória.", - sqliteCipherKey: "Chave SQLCipher", + sqliteCipherKey: "Chave de criptografia do SQLite", sqliteCipherKeyPlaceholder: "Deixe vazio para SQLite não criptografado", sqliteExtensions: "Extensões SQLite", sqliteExtensionsPlaceholder: "/caminho/para/regexp.dylib\n/caminho/para/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4814958f00..7b8c58efb3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -361,7 +361,7 @@ export default withEnglishFallback({ createDuckDbFile: "新建 DuckDB 文件", createSqliteFile: "新建 SQLite 数据库", memoryDatabasePathHint: "输入 :memory: 可创建 SQLite 或 DuckDB 内存数据库。", - sqliteCipherKey: "SQLCipher 密码(密钥)", + sqliteCipherKey: "SQLite 加密密码", sqliteCipherKeyPlaceholder: "未加密 SQLite 留空", sqliteExtensions: "SQLite 扩展库", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index ac9e885a90..b430fca737 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -438,7 +438,7 @@ export default withEnglishFallback({ createDuckDbFile: "建立 DuckDB 檔案", createSqliteFile: "建立 SQLite 資料庫", memoryDatabasePathHint: "輸入 :memory: 可建立 SQLite 或 DuckDB 記憶體資料庫。", - sqliteCipherKey: "SQLCipher 密碼(密鑰)", + sqliteCipherKey: "SQLite 加密密碼", sqliteCipherKeyPlaceholder: "未加密 SQLite 留空", sqliteExtensions: "SQLite 擴充庫", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", diff --git a/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts index 28d3d19d93..00a9fe628d 100644 --- a/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts +++ b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; +import { appendConnectionErrorHints, isSqliteMissingEncryptionPasswordFailure } from "@/lib/connection/connectionErrorHints"; import type { ConnectionConfig } from "@/types/database"; function mysqlConfig(urlParams: string | undefined): ConnectionConfig { @@ -40,6 +40,14 @@ const t = (key: string) => { }; describe("appendConnectionErrorHints", () => { + it("recognizes an encrypted SQLite file that was first opened without a password", () => { + const config = { ...mysqlConfig(undefined), db_type: "sqlite" as const, host: "/tmp/encrypted.db", port: 0 }; + + expect(isSqliteMissingEncryptionPasswordFailure(config, "Selected file is not a valid SQLite database file.")).toBe(true); + expect(isSqliteMissingEncryptionPasswordFailure({ ...config, password: "wrong" }, "Selected file is not a valid SQLite database file.")).toBe(false); + expect(isSqliteMissingEncryptionPasswordFailure(config, "SQLite connection failed: unable to open database file")).toBe(false); + }); + it("adds a MySQL TLS hint for non-disabled TLS failures", () => { const message = appendConnectionErrorHints(mysqlConfig("ssl-mode=preferred"), "MySQL connection failed: TLS handshake failed", t); diff --git a/apps/desktop/src/lib/connection/connectionErrorHints.ts b/apps/desktop/src/lib/connection/connectionErrorHints.ts index 2a546235b9..80428552ef 100644 --- a/apps/desktop/src/lib/connection/connectionErrorHints.ts +++ b/apps/desktop/src/lib/connection/connectionErrorHints.ts @@ -72,6 +72,11 @@ export function isMysqlMissingPasswordFailure(config: ConnectionConfig, message: return /access denied for user[\s\S]*using password:\s*no/i.test(message); } +export function isSqliteMissingEncryptionPasswordFailure(config: ConnectionConfig, message: string): boolean { + if (config.db_type !== "sqlite" || config.password) return false; + return /Selected file is not a valid SQLite database file/i.test(message); +} + export function isJdbcMissingRuntimeDependencyError(message: string): boolean { return /Missing Java class|NoClassDefFoundError|ClassNotFoundException/i.test(message); } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts index de8677d839..e88a730a87 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts @@ -297,6 +297,35 @@ describe("connectionStore save_password opt-out", () => { expect(store.getConfig("mysql-1")?.password).toBe("typed-pw"); }); + it("prompts and retries when an encrypted SQLite file is opened without a password", async () => { + const connectDb = vi.fn().mockRejectedValueOnce(new Error("Selected file is not a valid SQLite database file.")).mockResolvedValueOnce("sqlite-1"); + installApiMocks({ connectDb }); + installPasswordPromptMock(); + requestPassword.mockResolvedValue({ password: "123456", rememberPassword: true }); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ + id: "sqlite-1", + name: "Encrypted SQLite", + db_type: "sqlite", + host: "/tmp/encrypted.db", + port: 0, + username: "", + database: undefined, + save_password: true, + password: "", + }); + store.connections = [connection]; + + await store.connect(connection); + + expect(requestPassword).toHaveBeenCalledWith({ connectionId: "sqlite-1", connectionName: "Encrypted SQLite" }); + expect(connectDb).toHaveBeenCalledTimes(2); + expect(connectDb).toHaveBeenNthCalledWith(1, expect.objectContaining({ password: "" }), expect.any(Number)); + expect(connectDb).toHaveBeenNthCalledWith(2, expect.objectContaining({ password: "123456" }), expect.any(Number)); + expect(store.getConfig("sqlite-1")?.password).toBe("123456"); + }); + it("does not persist a recovered password after the connection config changes", async () => { const connected = deferred(); const connectDb = vi diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 6c727b1c83..011eafa6b7 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -141,7 +141,7 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; -import { appendConnectionErrorHints, isMysqlMissingPasswordFailure } from "@/lib/connection/connectionErrorHints"; +import { appendConnectionErrorHints, isMysqlMissingPasswordFailure, isSqliteMissingEncryptionPasswordFailure } from "@/lib/connection/connectionErrorHints"; import { connectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; @@ -3630,12 +3630,13 @@ export const useConnectionStore = defineStore("connection", () => { rebuildTreeNodes(); } - async function connectDbWithMissingPasswordRetry(config: ConnectionConfig, localAttempt: number): Promise<{ config: ConnectionConfig; id: string; rememberPassword: boolean }> { + async function connectDbWithPasswordRetry(config: ConnectionConfig, localAttempt: number): Promise<{ config: ConnectionConfig; id: string; rememberPassword: boolean }> { try { const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); return { config, id, rememberPassword: false }; } catch (error) { - if (!isMysqlMissingPasswordFailure(config, connectionErrorMessage(error))) throw error; + const message = connectionErrorMessage(error); + if (!isMysqlMissingPasswordFailure(config, message) && !isSqliteMissingEncryptionPasswordFailure(config, message)) throw error; const prompted = await ensureConnectionPassword(config, true); config = prompted.config; ensureLocalConnectionAttemptActive(config.id, localAttempt); @@ -3661,7 +3662,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureSqlServerLegacyCompatibilityComponentInstalled(config); } ensureLocalConnectionAttemptActive(config.id, localAttempt); - const connection = await connectDbWithMissingPasswordRetry(config, localAttempt); + const connection = await connectDbWithPasswordRetry(config, localAttempt); config = connection.config; rememberPassword ||= connection.rememberPassword; const id = connection.id; @@ -3904,7 +3905,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureSqlServerLegacyCompatibilityComponentInstalled(config); } ensureLocalConnectionAttemptActive(connectionId, localAttempt); - const connection = await connectDbWithMissingPasswordRetry(config, localAttempt); + const connection = await connectDbWithPasswordRetry(config, localAttempt); config = connection.config; rememberPassword ||= connection.rememberPassword; const id = connection.id; diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 9604a6d4bd..136670e70c 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -9,7 +9,7 @@ default = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "system duckdb-sidecar = [] dynamodb = ["dep:aws-sdk-dynamodb"] mq-admin = ["dep:rumqttc"] -sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"] +sqlite-sqlcipher = [] openapi = ["dep:utoipa"] system-fonts = ["font-kit"] @@ -42,7 +42,8 @@ tokio-postgres-rustls = "0.13" postgres-openssl = "0.5" openssl = { version = "0.10", features = ["vendored"] } webpki-roots = "0.26" -rusqlite = { version = "0.32", features = ["bundled", "load_extension", "backup", "functions", "column_decltype"] } +rusqlite = { version = "0.32", default-features = false, features = ["load_extension", "backup", "functions", "column_decltype"] } +libsqlite3-hotbundle = { git = "https://github.com/nikescar/libsqlite3-hotbundle.git", rev = "a208c7ef1447a02bf5ecb8c6a297aca012d41244", features = ["cipher-sqlcipher"] } mysql_async = { version = "0.37", default-features = false, features = ["default-rustls", "client_ed25519", "chrono", "rust_decimal"] } sqlparser = { version = "0.62.0", features = ["visitor"] } redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tls-rustls-insecure", "tokio-rustls-comp", "sentinel", "cluster-async"] } diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index e1f11d8b04..c5ca15b24c 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -20,6 +20,8 @@ use crate::types::{ }; const SQLITE_DATABASE_HEADER: &[u8; 16] = b"SQLite format 3\0"; +// Probe the common modern and legacy sizes first, then the remaining supported powers of two. +const SQLITE_LEGACY_PAGE_SIZES: &[i64] = &[4096, 1024, 512, 2048, 8192, 16384, 32768, 65536]; #[derive(Clone)] pub struct SqliteHandle { @@ -72,7 +74,7 @@ pub fn validate_persistent_attachments(main_path: &str, cipher_key: &str, has_at } if !cipher_key.is_empty() { return Err( - "Persistent SQLite attachments are unavailable for SQLCipher connections because each attached database requires an explicit key." + "Persistent SQLite attachments are unavailable for encrypted connections because each attached database requires an explicit key." .to_string(), ); } @@ -136,7 +138,7 @@ fn open_sqlite_handle( ) -> Result { let is_memory = is_memory_database_path(path); let encrypted = cipher_key.as_deref().is_some_and(|key| !key.is_empty()); - ensure_sqlcipher_available(encrypted)?; + ensure_sqlite_encryption_available(encrypted)?; if !is_memory && !create_if_missing { validate_file_path(path, is_network_path)?; } @@ -148,12 +150,25 @@ fn open_sqlite_handle( validate_existing_sqlite_file(path)?; } - let sqlcipher_attempts: &[Option] = if encrypted { &[None, Some(3), Some(2), Some(1)] } else { &[None] }; + let cipher_attempts = if !encrypted { + vec![SqliteCipherAttempt::Plain] + } else if create_if_missing { + vec![SqliteCipherAttempt::SqlCipher(4)] + } else { + let mut attempts = vec![ + SqliteCipherAttempt::SqlCipher(4), + SqliteCipherAttempt::SqlCipher(3), + SqliteCipherAttempt::SqlCipher(2), + SqliteCipherAttempt::SqlCipher(1), + ]; + attempts.extend(SQLITE_LEGACY_PAGE_SIZES.iter().copied().map(SqliteCipherAttempt::Rc4Legacy)); + attempts + }; let mut unlock_error: Option = None; - for compatibility in sqlcipher_attempts { + for attempt in cipher_attempts { let conn = open_sqlite_connection(path, create_if_missing)?; - if let Err(err) = apply_sqlcipher_key(&conn, cipher_key.as_deref(), *compatibility) { + if let Err(err) = apply_sqlite_cipher_key(&conn, cipher_key.as_deref(), attempt) { unlock_error = Some(err); continue; } @@ -164,7 +179,15 @@ fn open_sqlite_handle( return Ok(SqliteHandle { conn: Arc::new(Mutex::new(conn)) }); } - Err(unlock_error.unwrap_or_else(|| "SQLCipher database unlock failed.".to_string())) + Err(unlock_error.unwrap_or_else(|| "Encrypted SQLite database unlock failed.".to_string())) +} + +#[derive(Clone, Copy)] +#[cfg_attr(not(feature = "sqlite-sqlcipher"), allow(dead_code))] +enum SqliteCipherAttempt { + Plain, + SqlCipher(i64), + Rc4Legacy(i64), } fn open_sqlite_connection(path: &str, create_if_missing: bool) -> Result { @@ -194,48 +217,61 @@ fn sqlite_cipher_key(cipher_key: &str) -> Option { } #[cfg(feature = "sqlite-sqlcipher")] -fn ensure_sqlcipher_available(_encrypted: bool) -> Result<(), String> { +fn ensure_sqlite_encryption_available(_encrypted: bool) -> Result<(), String> { Ok(()) } #[cfg(not(feature = "sqlite-sqlcipher"))] -fn ensure_sqlcipher_available(encrypted: bool) -> Result<(), String> { +fn ensure_sqlite_encryption_available(encrypted: bool) -> Result<(), String> { if encrypted { - Err("SQLCipher support is not compiled in this build. Rebuild with the sqlite-sqlcipher feature.".to_string()) + Err("Encrypted SQLite support is not compiled in this build. Rebuild with the sqlite-sqlcipher feature." + .to_string()) } else { Ok(()) } } #[cfg(feature = "sqlite-sqlcipher")] -fn apply_sqlcipher_key(conn: &Connection, cipher_key: Option<&str>, compatibility: Option) -> Result<(), String> { +fn apply_sqlite_cipher_key( + conn: &Connection, + cipher_key: Option<&str>, + attempt: SqliteCipherAttempt, +) -> Result<(), String> { let Some(cipher_key) = cipher_key.filter(|key| !key.is_empty()) else { return Ok(()); }; - // SQLCipher requires the key before the first schema read; the verification - // query turns wrong keys into an immediate connection error. - conn.pragma_update(None, "key", cipher_key).map_err(|e| format!("SQLCipher key setup failed: {e}"))?; - if let Some(compatibility) = compatibility { - conn.pragma_update(None, "cipher_compatibility", compatibility) - .map_err(|e| format!("SQLCipher compatibility setup failed: {e}"))?; + match attempt { + SqliteCipherAttempt::Plain => {} + SqliteCipherAttempt::SqlCipher(compatibility) => { + conn.pragma_update(None, "cipher", "sqlcipher").map_err(|e| format!("SQLite cipher setup failed: {e}"))?; + conn.pragma_update(None, "legacy", compatibility) + .map_err(|e| format!("SQLCipher compatibility setup failed: {e}"))?; + } + SqliteCipherAttempt::Rc4Legacy(page_size) => { + conn.pragma_update(None, "cipher", "rc4").map_err(|e| format!("SQLite cipher setup failed: {e}"))?; + conn.pragma_update(None, "legacy", 1).map_err(|e| format!("RC4 compatibility setup failed: {e}"))?; + conn.pragma_update(None, "legacy_page_size", page_size) + .map_err(|e| format!("RC4 legacy page size setup failed: {e}"))?; + } } - verify_sqlcipher_key(conn) - .map_err(|e| format!("SQLCipher database unlock failed. Check the SQLite password/key and file type: {e}"))?; + conn.pragma_update(None, "key", cipher_key).map_err(|e| format!("SQLite encryption key setup failed: {e}"))?; + verify_sqlite_encryption_key(conn) + .map_err(|e| format!("Encrypted SQLite database unlock failed. Check the password/key and cipher: {e}"))?; Ok(()) } #[cfg(not(feature = "sqlite-sqlcipher"))] -fn apply_sqlcipher_key( +fn apply_sqlite_cipher_key( _conn: &Connection, _cipher_key: Option<&str>, - _compatibility: Option, + _attempt: SqliteCipherAttempt, ) -> Result<(), String> { Ok(()) } #[cfg(feature = "sqlite-sqlcipher")] -fn verify_sqlcipher_key(conn: &Connection) -> Result<(), rusqlite::Error> { +fn verify_sqlite_encryption_key(conn: &Connection) -> Result<(), rusqlite::Error> { conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) } @@ -568,7 +604,7 @@ mod tests { assert!(memory_error.contains("in-memory main database"), "{memory_error}"); let cipher_error = validate_persistent_attachments("/tmp/main.sqlite", "secret", true).unwrap_err(); - assert!(cipher_error.contains("SQLCipher"), "{cipher_error}"); + assert!(cipher_error.contains("encrypted"), "{cipher_error}"); } #[tokio::test] @@ -723,7 +759,7 @@ mod tests { Ok(_) => panic!("wrong key must fail"), Err(err) => err, }; - assert!(wrong_key.contains("SQLCipher database unlock failed")); + assert!(wrong_key.contains("Encrypted SQLite database unlock failed")); let _ = std::fs::remove_file(path); } @@ -738,8 +774,9 @@ mod tests { let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE) .expect("create legacy-compatible encrypted sqlite"); + conn.pragma_update(None, "cipher", "sqlcipher").expect("select SQLCipher"); + conn.pragma_update(None, "legacy", 3).expect("set SQLCipher compatibility"); conn.pragma_update(None, "key", key).expect("set SQLCipher key"); - conn.pragma_update(None, "cipher_compatibility", 3).expect("set SQLCipher compatibility"); conn.execute_batch("CREATE TABLE t (name TEXT); INSERT INTO t VALUES ('legacy');") .expect("write legacy-compatible encrypted sqlite"); } @@ -754,6 +791,48 @@ mod tests { let _ = std::fs::remove_file(path); } + #[cfg(feature = "sqlite-sqlcipher")] + #[tokio::test] + async fn encrypted_sqlite_key_opens_and_updates_1024_page_rc4_legacy_database() { + let path = std::env::temp_dir().join(format!("dbx-rc4-legacy-{}.db", uuid::Uuid::new_v4())); + let key = "123456"; + let page_size = 1024; + + { + let conn = + Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE) + .expect("create RC4-compatible encrypted sqlite"); + conn.pragma_update(None, "cipher", "rc4").expect("select RC4"); + conn.pragma_update(None, "legacy", 1).expect("set RC4 legacy mode"); + conn.pragma_update(None, "legacy_page_size", page_size).expect("set RC4 legacy page size"); + conn.pragma_update(None, "key", key).expect("set RC4 key"); + conn.execute_batch("CREATE TABLE t (name TEXT); INSERT INTO t VALUES ('navicat-compatible');") + .expect("write RC4-compatible encrypted sqlite"); + } + + { + let reopened = connect_path_with_cipher_key_and_extensions(path.to_str().unwrap(), key, Vec::new()) + .await + .expect("open RC4-compatible encrypted sqlite"); + let result = execute_query(&reopened, "SELECT name FROM t").await.expect("read RC4 database"); + assert_eq!(result.rows[0][0], serde_json::json!("navicat-compatible")); + execute_query(&reopened, "INSERT INTO t VALUES ('written-by-dbx')").await.expect("update RC4 database"); + } + + let conn = Connection::open(&path).expect("reopen RC4 database independently"); + conn.pragma_update(None, "cipher", "rc4").expect("select RC4"); + conn.pragma_update(None, "legacy", 1).expect("set RC4 legacy mode"); + conn.pragma_update(None, "legacy_page_size", page_size).expect("set RC4 legacy page size"); + conn.pragma_update(None, "key", key).expect("set RC4 key"); + let reopened_page_size: i64 = + conn.pragma_query_value(None, "page_size", |row| row.get(0)).expect("read page size"); + assert_eq!(reopened_page_size, page_size); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM t", [], |row| row.get(0)).expect("verify RC4 writes"); + assert_eq!(count, 2); + + let _ = std::fs::remove_file(path); + } + #[cfg(not(feature = "sqlite-sqlcipher"))] #[tokio::test] async fn sqlcipher_key_requires_sqlcipher_feature() { @@ -765,7 +844,7 @@ mod tests { Err(err) => err, }; - assert!(err.contains("SQLCipher support is not compiled")); + assert!(err.contains("Encrypted SQLite support is not compiled")); } #[test] diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index eba281b3b0..8d2cf2bb24 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -380,7 +380,7 @@ mod tests { Ok(_) => panic!("SQLCipher attachments must be rejected"), Err(error) => error, }; - assert!(error.contains("SQLCipher"), "{error}"); + assert!(error.contains("encrypted connections"), "{error}"); } #[tokio::test] @@ -600,7 +600,7 @@ mod tests { Ok(_) => panic!("wrong SQLCipher key must fail"), Err(err) => err, }; - assert!(wrong_key.contains("SQLCipher database unlock failed")); + assert!(wrong_key.contains("Encrypted SQLite database unlock failed")); let missing_key = match connect_sqlite_from_config(&sqlite_config(&path, "")).await { Ok(_) => panic!("missing SQLCipher key must fail"),