feat(sqlite): support RC4 Legacy databases

This commit is contained in:
zipg authored and GitHub committed 2026-08-25 23:37:52 +08:00
1 parent 0dc1f0c14c
commit 1ed20e424e
19 files changed
+187 -49

No files matched your search

Generated
+9 -2
View File
@@ -2096,6 +2096,7 @@ dependencies = [
"insta", "insta",
"json5", "json5",
"jsonwebtoken", "jsonwebtoken",
"libsqlite3-hotbundle",
"log", "log",
"minijinja", "minijinja",
"mongodb", "mongodb",
@@ -4610,14 +4611,20 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libsqlite3-hotbundle"
version = "1.510300.0"
source = "git+https://github.com/nikescar/libsqlite3-hotbundle.git?rev=a208c7ef1447a02bf5ecb8c6a297aca012d41244#a208c7ef1447a02bf5ecb8c6a297aca012d41244"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.30.1" version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [ dependencies = [
"cc",
"openssl-sys",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
+9 -1
View File
@@ -1252,6 +1252,7 @@ dependencies = [
"insta", "insta",
"json5", "json5",
"jsonwebtoken", "jsonwebtoken",
"libsqlite3-hotbundle",
"log", "log",
"minijinja", "minijinja",
"mongodb", "mongodb",
@@ -2833,13 +2834,20 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libsqlite3-hotbundle"
version = "1.510300.0"
source = "git+https://github.com/nikescar/libsqlite3-hotbundle.git?rev=a208c7ef1447a02bf5ecb8c6a297aca012d41244#a208c7ef1447a02bf5ecb8c6a297aca012d41244"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.30.1" version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [ dependencies = [
"cc",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
+1 -1
View File
@@ -1661,7 +1661,7 @@ async function openDbFilePath(path: string) {
password: "", password: "",
}; };
await connectionStore.addConnection(config); await connectionStore.addConnection(config);
void connectionStore.connect(config); await connectionStore.connect(config);
toast(t("welcome.fileOpened", { name })); toast(t("welcome.fileOpened", { name }));
} catch (e: any) { } catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000);
+2 -2
View File
@@ -109,10 +109,10 @@ export function useFileDrop() {
}; };
try { try {
await connectionStore.addConnection(config); await connectionStore.addConnection(config);
void connectionStore.connect(config); await connectionStore.connect(config);
toast(t("welcome.fileOpened", { name })); toast(t("welcome.fileOpened", { name }));
} catch (e: any) { } catch (e: any) {
toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); toast(t("welcome.fileOpenFailed", { name, message: e?.message || String(e) }), 5000);
} }
} }
}); });
+1 -1
View File
@@ -436,7 +436,7 @@ export default {
createDuckDbFile: "Create DuckDB file", createDuckDbFile: "Create DuckDB file",
createSqliteFile: "Create SQLite database", createSqliteFile: "Create SQLite database",
memoryDatabasePathHint: "Use :memory: to create an in-memory SQLite or DuckDB 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", sqliteCipherKeyPlaceholder: "Leave empty for unencrypted SQLite",
sqliteExtensions: "SQLite Extensions", sqliteExtensions: "SQLite Extensions",
sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
+1 -1
View File
@@ -438,7 +438,7 @@ export default withEnglishFallback({
createDuckDbFile: "Crear archivo DuckDB", createDuckDbFile: "Crear archivo DuckDB",
createSqliteFile: "Crear base de datos SQLite", createSqliteFile: "Crear base de datos SQLite",
memoryDatabasePathHint: "Usa :memory: para crear una base de datos SQLite o DuckDB en memoria.", 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", sqliteCipherKeyPlaceholder: "Déjalo vacío para SQLite sin cifrar",
sqliteExtensions: "Extensiones de SQLite", sqliteExtensions: "Extensiones de SQLite",
sqliteExtensionsPlaceholder: "/ruta/a/regexp.dylib\n/ruta/a/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/ruta/a/regexp.dylib\n/ruta/a/text.dylib|sqlite3_text_init",
+1 -1
View File
@@ -437,7 +437,7 @@ export default withEnglishFallback({
createDuckDbFile: "Crea file DuckDB", createDuckDbFile: "Crea file DuckDB",
createSqliteFile: "Crea database SQLite", createSqliteFile: "Crea database SQLite",
memoryDatabasePathHint: "Usa :memory: per creare un database SQLite o DuckDB in memoria.", 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", sqliteCipherKeyPlaceholder: "Lascia vuoto per SQLite non cifrato",
sqliteExtensions: "Estensioni SQLite", sqliteExtensions: "Estensioni SQLite",
sqliteExtensionsPlaceholder: "/percorso/per/regexp.dylib\n/percorso/per/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/percorso/per/regexp.dylib\n/percorso/per/text.dylib|sqlite3_text_init",
+1 -1
View File
@@ -437,7 +437,7 @@ export default withEnglishFallback({
createDuckDbFile: "DuckDBファイルを作成", createDuckDbFile: "DuckDBファイルを作成",
createSqliteFile: "SQLiteデータベースを作成", createSqliteFile: "SQLiteデータベースを作成",
memoryDatabasePathHint: "インメモリのSQLiteまたはDuckDBデータベースを作成するには :memory: を使用してください。", memoryDatabasePathHint: "インメモリのSQLiteまたはDuckDBデータベースを作成するには :memory: を使用してください。",
sqliteCipherKey: "SQLCipherキー", sqliteCipherKey: "SQLite暗号化キー",
sqliteCipherKeyPlaceholder: "暗号化されていないSQLiteでは空のままにします", sqliteCipherKeyPlaceholder: "暗号化されていないSQLiteでは空のままにします",
sqliteExtensions: "SQLite拡張", sqliteExtensions: "SQLite拡張",
sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
+1 -1
View File
@@ -430,7 +430,7 @@ export default withEnglishFallback({
createDuckDbFile: "DuckDB 파일 만들기", createDuckDbFile: "DuckDB 파일 만들기",
createSqliteFile: "SQLite 데이터베이스 만들기", createSqliteFile: "SQLite 데이터베이스 만들기",
memoryDatabasePathHint: "인메모리 SQLite 또는 DuckDB 데이터베이스를 만들려면 :memory:를 사용하세요.", memoryDatabasePathHint: "인메모리 SQLite 또는 DuckDB 데이터베이스를 만들려면 :memory:를 사용하세요.",
sqliteCipherKey: "SQLCipher 키", sqliteCipherKey: "SQLite 암호화 키",
sqliteCipherKeyPlaceholder: "암호화하지 않은 SQLite는 비워 두세요", sqliteCipherKeyPlaceholder: "암호화하지 않은 SQLite는 비워 두세요",
sqliteExtensions: "SQLite 확장", sqliteExtensions: "SQLite 확장",
sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
+1 -1
View File
@@ -438,7 +438,7 @@ export default withEnglishFallback({
createDuckDbFile: "Criar arquivo DuckDB", createDuckDbFile: "Criar arquivo DuckDB",
createSqliteFile: "Criar banco de dados SQLite", createSqliteFile: "Criar banco de dados SQLite",
memoryDatabasePathHint: "Use :memory: para criar um banco de dados SQLite ou DuckDB na memória.", 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", sqliteCipherKeyPlaceholder: "Deixe vazio para SQLite não criptografado",
sqliteExtensions: "Extensões SQLite", sqliteExtensions: "Extensões SQLite",
sqliteExtensionsPlaceholder: "/caminho/para/regexp.dylib\n/caminho/para/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/caminho/para/regexp.dylib\n/caminho/para/text.dylib|sqlite3_text_init",
+1 -1
View File
@@ -361,7 +361,7 @@ export default withEnglishFallback({
createDuckDbFile: "新建 DuckDB 文件", createDuckDbFile: "新建 DuckDB 文件",
createSqliteFile: "新建 SQLite 数据库", createSqliteFile: "新建 SQLite 数据库",
memoryDatabasePathHint: "输入 :memory: 可创建 SQLite 或 DuckDB 内存数据库。", memoryDatabasePathHint: "输入 :memory: 可创建 SQLite 或 DuckDB 内存数据库。",
sqliteCipherKey: "SQLCipher 密码(密钥)", sqliteCipherKey: "SQLite 加密密码",
sqliteCipherKeyPlaceholder: "未加密 SQLite 留空", sqliteCipherKeyPlaceholder: "未加密 SQLite 留空",
sqliteExtensions: "SQLite 扩展库", sqliteExtensions: "SQLite 扩展库",
sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
+1 -1
View File
@@ -438,7 +438,7 @@ export default withEnglishFallback({
createDuckDbFile: "建立 DuckDB 檔案", createDuckDbFile: "建立 DuckDB 檔案",
createSqliteFile: "建立 SQLite 資料庫", createSqliteFile: "建立 SQLite 資料庫",
memoryDatabasePathHint: "輸入 :memory: 可建立 SQLite 或 DuckDB 記憶體資料庫。", memoryDatabasePathHint: "輸入 :memory: 可建立 SQLite 或 DuckDB 記憶體資料庫。",
sqliteCipherKey: "SQLCipher 密碼(密鑰)", sqliteCipherKey: "SQLite 加密密碼",
sqliteCipherKeyPlaceholder: "未加密 SQLite 留空", sqliteCipherKeyPlaceholder: "未加密 SQLite 留空",
sqliteExtensions: "SQLite 擴充庫", sqliteExtensions: "SQLite 擴充庫",
sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init", sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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"; import type { ConnectionConfig } from "@/types/database";
function mysqlConfig(urlParams: string | undefined): ConnectionConfig { function mysqlConfig(urlParams: string | undefined): ConnectionConfig {
@@ -40,6 +40,14 @@ const t = (key: string) => {
}; };
describe("appendConnectionErrorHints", () => { 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", () => { 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); const message = appendConnectionErrorHints(mysqlConfig("ssl-mode=preferred"), "MySQL connection failed: TLS handshake failed", t);
@@ -72,6 +72,11 @@ export function isMysqlMissingPasswordFailure(config: ConnectionConfig, message:
return /access denied for user[\s\S]*using password:\s*no/i.test(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 { export function isJdbcMissingRuntimeDependencyError(message: string): boolean {
return /Missing Java class|NoClassDefFoundError|ClassNotFoundException/i.test(message); return /Missing Java class|NoClassDefFoundError|ClassNotFoundException/i.test(message);
} }
@@ -297,6 +297,35 @@ describe("connectionStore save_password opt-out", () => {
expect(store.getConfig("mysql-1")?.password).toBe("typed-pw"); 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 () => { it("does not persist a recovered password after the connection config changes", async () => {
const connected = deferred<string>(); const connected = deferred<string>();
const connectDb = vi const connectDb = vi
+6 -5
View File
@@ -141,7 +141,7 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; 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 { connectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
@@ -3630,12 +3630,13 @@ export const useConnectionStore = defineStore("connection", () => {
rebuildTreeNodes(); 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 { try {
const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config);
return { config, id, rememberPassword: false }; return { config, id, rememberPassword: false };
} catch (error) { } 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); const prompted = await ensureConnectionPassword(config, true);
config = prompted.config; config = prompted.config;
ensureLocalConnectionAttemptActive(config.id, localAttempt); ensureLocalConnectionAttemptActive(config.id, localAttempt);
@@ -3661,7 +3662,7 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureSqlServerLegacyCompatibilityComponentInstalled(config); await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
} }
ensureLocalConnectionAttemptActive(config.id, localAttempt); ensureLocalConnectionAttemptActive(config.id, localAttempt);
const connection = await connectDbWithMissingPasswordRetry(config, localAttempt); const connection = await connectDbWithPasswordRetry(config, localAttempt);
config = connection.config; config = connection.config;
rememberPassword ||= connection.rememberPassword; rememberPassword ||= connection.rememberPassword;
const id = connection.id; const id = connection.id;
@@ -3904,7 +3905,7 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureSqlServerLegacyCompatibilityComponentInstalled(config); await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
} }
ensureLocalConnectionAttemptActive(connectionId, localAttempt); ensureLocalConnectionAttemptActive(connectionId, localAttempt);
const connection = await connectDbWithMissingPasswordRetry(config, localAttempt); const connection = await connectDbWithPasswordRetry(config, localAttempt);
config = connection.config; config = connection.config;
rememberPassword ||= connection.rememberPassword; rememberPassword ||= connection.rememberPassword;
const id = connection.id; const id = connection.id;
+3 -2
View File
@@ -9,7 +9,7 @@ default = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "system
duckdb-sidecar = [] duckdb-sidecar = []
dynamodb = ["dep:aws-sdk-dynamodb"] dynamodb = ["dep:aws-sdk-dynamodb"]
mq-admin = ["dep:rumqttc"] mq-admin = ["dep:rumqttc"]
sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"] sqlite-sqlcipher = []
openapi = ["dep:utoipa"] openapi = ["dep:utoipa"]
system-fonts = ["font-kit"] system-fonts = ["font-kit"]
@@ -42,7 +42,8 @@ tokio-postgres-rustls = "0.13"
postgres-openssl = "0.5" postgres-openssl = "0.5"
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
webpki-roots = "0.26" 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"] } mysql_async = { version = "0.37", default-features = false, features = ["default-rustls", "client_ed25519", "chrono", "rust_decimal"] }
sqlparser = { version = "0.62.0", features = ["visitor"] } 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"] } redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tls-rustls-insecure", "tokio-rustls-comp", "sentinel", "cluster-async"] }
+104 -25
View File
@@ -20,6 +20,8 @@ use crate::types::{
}; };
const SQLITE_DATABASE_HEADER: &[u8; 16] = b"SQLite format 3\0"; 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)] #[derive(Clone)]
pub struct SqliteHandle { 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() { if !cipher_key.is_empty() {
return Err( 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(), .to_string(),
); );
} }
@@ -136,7 +138,7 @@ fn open_sqlite_handle(
) -> Result<SqliteHandle, String> { ) -> Result<SqliteHandle, String> {
let is_memory = is_memory_database_path(path); let is_memory = is_memory_database_path(path);
let encrypted = cipher_key.as_deref().is_some_and(|key| !key.is_empty()); 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 { if !is_memory && !create_if_missing {
validate_file_path(path, is_network_path)?; validate_file_path(path, is_network_path)?;
} }
@@ -148,12 +150,25 @@ fn open_sqlite_handle(
validate_existing_sqlite_file(path)?; validate_existing_sqlite_file(path)?;
} }
let sqlcipher_attempts: &[Option<i64>] = 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<String> = None; let mut unlock_error: Option<String> = None;
for compatibility in sqlcipher_attempts { for attempt in cipher_attempts {
let conn = open_sqlite_connection(path, create_if_missing)?; 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); unlock_error = Some(err);
continue; continue;
} }
@@ -164,7 +179,15 @@ fn open_sqlite_handle(
return Ok(SqliteHandle { conn: Arc::new(Mutex::new(conn)) }); 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<Connection, String> { fn open_sqlite_connection(path: &str, create_if_missing: bool) -> Result<Connection, String> {
@@ -194,48 +217,61 @@ fn sqlite_cipher_key(cipher_key: &str) -> Option<String> {
} }
#[cfg(feature = "sqlite-sqlcipher")] #[cfg(feature = "sqlite-sqlcipher")]
fn ensure_sqlcipher_available(_encrypted: bool) -> Result<(), String> { fn ensure_sqlite_encryption_available(_encrypted: bool) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(not(feature = "sqlite-sqlcipher"))] #[cfg(not(feature = "sqlite-sqlcipher"))]
fn ensure_sqlcipher_available(encrypted: bool) -> Result<(), String> { fn ensure_sqlite_encryption_available(encrypted: bool) -> Result<(), String> {
if encrypted { 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 { } else {
Ok(()) Ok(())
} }
} }
#[cfg(feature = "sqlite-sqlcipher")] #[cfg(feature = "sqlite-sqlcipher")]
fn apply_sqlcipher_key(conn: &Connection, cipher_key: Option<&str>, compatibility: Option<i64>) -> 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 { let Some(cipher_key) = cipher_key.filter(|key| !key.is_empty()) else {
return Ok(()); return Ok(());
}; };
// SQLCipher requires the key before the first schema read; the verification match attempt {
// query turns wrong keys into an immediate connection error. SqliteCipherAttempt::Plain => {}
conn.pragma_update(None, "key", cipher_key).map_err(|e| format!("SQLCipher key setup failed: {e}"))?; SqliteCipherAttempt::SqlCipher(compatibility) => {
if let Some(compatibility) = compatibility { conn.pragma_update(None, "cipher", "sqlcipher").map_err(|e| format!("SQLite cipher setup failed: {e}"))?;
conn.pragma_update(None, "cipher_compatibility", compatibility) conn.pragma_update(None, "legacy", compatibility)
.map_err(|e| format!("SQLCipher compatibility setup failed: {e}"))?; .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) conn.pragma_update(None, "key", cipher_key).map_err(|e| format!("SQLite encryption key setup failed: {e}"))?;
.map_err(|e| format!("SQLCipher database unlock failed. Check the SQLite password/key and file type: {e}"))?; verify_sqlite_encryption_key(conn)
.map_err(|e| format!("Encrypted SQLite database unlock failed. Check the password/key and cipher: {e}"))?;
Ok(()) Ok(())
} }
#[cfg(not(feature = "sqlite-sqlcipher"))] #[cfg(not(feature = "sqlite-sqlcipher"))]
fn apply_sqlcipher_key( fn apply_sqlite_cipher_key(
_conn: &Connection, _conn: &Connection,
_cipher_key: Option<&str>, _cipher_key: Option<&str>,
_compatibility: Option<i64>, _attempt: SqliteCipherAttempt,
) -> Result<(), String> { ) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(feature = "sqlite-sqlcipher")] #[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(())) 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}"); assert!(memory_error.contains("in-memory main database"), "{memory_error}");
let cipher_error = validate_persistent_attachments("/tmp/main.sqlite", "secret", true).unwrap_err(); 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] #[tokio::test]
@@ -723,7 +759,7 @@ mod tests {
Ok(_) => panic!("wrong key must fail"), Ok(_) => panic!("wrong key must fail"),
Err(err) => err, 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); let _ = std::fs::remove_file(path);
} }
@@ -738,8 +774,9 @@ mod tests {
let conn = let conn =
Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE) Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE)
.expect("create legacy-compatible encrypted sqlite"); .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, "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');") conn.execute_batch("CREATE TABLE t (name TEXT); INSERT INTO t VALUES ('legacy');")
.expect("write legacy-compatible encrypted sqlite"); .expect("write legacy-compatible encrypted sqlite");
} }
@@ -754,6 +791,48 @@ mod tests {
let _ = std::fs::remove_file(path); 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"))] #[cfg(not(feature = "sqlite-sqlcipher"))]
#[tokio::test] #[tokio::test]
async fn sqlcipher_key_requires_sqlcipher_feature() { async fn sqlcipher_key_requires_sqlcipher_feature() {
@@ -765,7 +844,7 @@ mod tests {
Err(err) => err, Err(err) => err,
}; };
assert!(err.contains("SQLCipher support is not compiled")); assert!(err.contains("Encrypted SQLite support is not compiled"));
} }
#[test] #[test]
+2 -2
View File
@@ -380,7 +380,7 @@ mod tests {
Ok(_) => panic!("SQLCipher attachments must be rejected"), Ok(_) => panic!("SQLCipher attachments must be rejected"),
Err(error) => error, Err(error) => error,
}; };
assert!(error.contains("SQLCipher"), "{error}"); assert!(error.contains("encrypted connections"), "{error}");
} }
#[tokio::test] #[tokio::test]
@@ -600,7 +600,7 @@ mod tests {
Ok(_) => panic!("wrong SQLCipher key must fail"), Ok(_) => panic!("wrong SQLCipher key must fail"),
Err(err) => err, 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 { let missing_key = match connect_sqlite_from_config(&sqlite_config(&path, "")).await {
Ok(_) => panic!("missing SQLCipher key must fail"), Ok(_) => panic!("missing SQLCipher key must fail"),