feat(sqlite): support SQLCipher encrypted databases

This commit is contained in:
t8y2 committed 2026-07-06 14:24:28 +08:00
1 parent 92ef1ac3cf
commit e84fb12fcc
6 files changed
+139 -9

No files matched your search

+6 -1
View File
@@ -973,7 +973,12 @@ impl AppState {
})
.collect();
PoolKind::Sqlite(
db::sqlite::connect_path_with_extensions(&expand_tilde(&db_config.host), extensions).await?,
db::sqlite::connect_path_with_cipher_key_and_extensions(
&expand_tilde(&db_config.host),
&db_config.password,
extensions,
)
.await?,
)
}
DatabaseType::Rqlite => {
+114 -6
View File
@@ -40,34 +40,50 @@ impl SqliteHandle {
}
pub async fn connect_path(path: &str) -> Result<SqliteHandle, String> {
connect_path_with_options(path, false, Vec::new()).await
connect_path_with_options(path, false, None, Vec::new()).await
}
pub async fn connect_path_with_extensions(
path: &str,
extensions: Vec<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
connect_path_with_options(path, false, extensions).await
connect_path_with_options(path, false, None, extensions).await
}
pub async fn connect_path_with_cipher_key_and_extensions(
path: &str,
cipher_key: &str,
extensions: Vec<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
connect_path_with_options(path, false, sqlite_cipher_key(cipher_key), extensions).await
}
pub async fn connect_path_create_if_missing(path: &str) -> Result<SqliteHandle, String> {
connect_path_with_options(path, true, Vec::new()).await
connect_path_with_options(path, true, None, Vec::new()).await
}
pub async fn connect_path_create_if_missing_with_extensions(
path: &str,
extensions: Vec<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
connect_path_with_options(path, true, extensions).await
connect_path_with_options(path, true, None, extensions).await
}
pub async fn connect_path_create_if_missing_with_cipher_key(
path: &str,
cipher_key: &str,
) -> Result<SqliteHandle, String> {
connect_path_with_options(path, true, sqlite_cipher_key(cipher_key), Vec::new()).await
}
async fn connect_path_with_options(
path: &str,
create_if_missing: bool,
cipher_key: Option<String>,
extensions: Vec<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
let path = path.to_string();
tokio::task::spawn_blocking(move || open_sqlite_handle(&path, create_if_missing, extensions))
tokio::task::spawn_blocking(move || open_sqlite_handle(&path, create_if_missing, cipher_key, extensions))
.await
.map_err(|e| e.to_string())?
}
@@ -75,9 +91,12 @@ async fn connect_path_with_options(
fn open_sqlite_handle(
path: &str,
create_if_missing: bool,
cipher_key: Option<String>,
extensions: Vec<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
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)?;
if !is_memory && !create_if_missing {
validate_file_path(path, is_network_path)?;
}
@@ -85,7 +104,7 @@ fn open_sqlite_handle(
if !is_memory && create_if_missing {
ensure_parent_dir(path)?;
}
if !is_memory && !is_network_path(path) {
if !is_memory && !is_network_path(path) && !encrypted {
validate_existing_sqlite_file(path)?;
}
@@ -105,6 +124,7 @@ fn open_sqlite_handle(
}
};
apply_sqlcipher_key(&conn, cipher_key.as_deref())?;
conn.busy_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?;
load_sqlite_extensions(&conn, &extensions)?;
register_sqlite_compat_functions(&conn)?;
@@ -112,6 +132,47 @@ fn open_sqlite_handle(
Ok(SqliteHandle { conn: Arc::new(Mutex::new(conn)) })
}
fn sqlite_cipher_key(cipher_key: &str) -> Option<String> {
if cipher_key.is_empty() {
None
} else {
Some(cipher_key.to_string())
}
}
#[cfg(feature = "sqlite-sqlcipher")]
fn ensure_sqlcipher_available(_encrypted: bool) -> Result<(), String> {
Ok(())
}
#[cfg(not(feature = "sqlite-sqlcipher"))]
fn ensure_sqlcipher_available(encrypted: bool) -> Result<(), String> {
if encrypted {
Err("SQLCipher 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>) -> 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}"))?;
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|e| format!("SQLCipher database unlock failed. Check the SQLite password/key and file type: {e}"))?;
Ok(())
}
#[cfg(not(feature = "sqlite-sqlcipher"))]
fn apply_sqlcipher_key(_conn: &Connection, _cipher_key: Option<&str>) -> Result<(), String> {
Ok(())
}
fn register_sqlite_compat_functions(conn: &Connection) -> Result<(), String> {
let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS;
@@ -463,6 +524,53 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[cfg(feature = "sqlite-sqlcipher")]
#[tokio::test]
async fn sqlcipher_key_creates_and_reopens_encrypted_database() {
let path = std::env::temp_dir().join(format!("dbx-sqlcipher-{}.db", uuid::Uuid::new_v4()));
let key = "secret key";
{
let pool = connect_path_create_if_missing_with_cipher_key(path.to_str().unwrap(), key)
.await
.expect("create encrypted sqlite");
execute_query(&pool, "CREATE TABLE t (name TEXT); INSERT INTO t VALUES ('encrypted');")
.await
.expect("write encrypted sqlite");
}
assert!(!path_has_sqlite_header(&path).expect("inspect encrypted header"));
let reopened = connect_path_with_cipher_key_and_extensions(path.to_str().unwrap(), key, Vec::new())
.await
.expect("reopen encrypted sqlite");
let result = execute_query(&reopened, "SELECT name FROM t").await.expect("read encrypted sqlite");
assert_eq!(result.rows[0][0], serde_json::json!("encrypted"));
let wrong_key =
match connect_path_with_cipher_key_and_extensions(path.to_str().unwrap(), "wrong key", Vec::new()).await {
Ok(_) => panic!("wrong key must fail"),
Err(err) => err,
};
assert!(wrong_key.contains("SQLCipher database unlock failed"));
let _ = std::fs::remove_file(path);
}
#[cfg(not(feature = "sqlite-sqlcipher"))]
#[tokio::test]
async fn sqlcipher_key_requires_sqlcipher_feature() {
let err =
match connect_path_with_cipher_key_and_extensions("/tmp/dbx-missing-sqlcipher.db", "secret", Vec::new())
.await
{
Ok(_) => panic!("SQLCipher key should require feature support"),
Err(err) => err,
};
assert!(err.contains("SQLCipher support is not compiled"));
}
#[test]
fn sqlite_extension_specs_parse_repeated_and_multiline_url_params() {
let params = "cache=shared&sqlite_extension=%2Fopt%2Fregexp.dylib&sqlite_extensions=%2Fopt%2Ftext.dylib%7Csqlite3_text_init%0A%2Fopt%2Fcrypto.dylib";