feat(sqlite): support RC4 Legacy databases
This commit is contained in:
19 files changed
+187
-49
No files matched your search
Generated
+9
-2
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+9
-1
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in new issue
Block a user