use std::cell::RefCell;
use std::path::Path;
use sqlite_wasm_vfs::sahpool::{
install as install_sahpool, OpfsSAHPoolCfgBuilder, OpfsSAHPoolUtil,
};
use crate::{error::DbResult, Error};
const OPFS_VFS_NAME: &str = "opfs-sahpool";
const OPFS_DIRECTORY: &str = ".walletkit-opfs-sahpool";
const OPFS_MINIMUM_CAPACITY: u32 = 6;
pub(crate) const ENCRYPTED_VFS_NAME: &str = "multipleciphers-opfs-sahpool";
thread_local! {
static OPFS_POOL: RefCell<Option<OpfsSAHPoolUtil>> = const { RefCell::new(None) };
}
#[expect(
clippy::future_not_send,
reason = "OPFS and its JavaScript handles are dedicated-worker local"
)]
pub async fn install() -> DbResult<()> {
if is_installed() {
return Ok(());
}
let options = OpfsSAHPoolCfgBuilder::new()
.vfs_name(OPFS_VFS_NAME)
.directory(OPFS_DIRECTORY)
.clear_on_init(false)
.initial_capacity(OPFS_MINIMUM_CAPACITY)
.build();
let pool = install_sahpool::<sqlite_wasm_rs::WasmOsCallback>(&options, false)
.await
.map_err(|err| {
Error::new(-1, format!("failed to install OPFS SAH-pool VFS: {err}"))
})?;
pool.reserve_minimum_capacity(OPFS_MINIMUM_CAPACITY)
.await
.map_err(|err| {
Error::new(
-1,
format!("failed to reserve OPFS SAH-pool capacity: {err}"),
)
})?;
OPFS_POOL.with(|slot| {
*slot.borrow_mut() = Some(pool);
});
Ok(())
}
#[must_use]
pub fn is_installed() -> bool {
OPFS_POOL.with(|slot| slot.borrow().is_some())
}
pub fn delete_file(path: &Path) -> DbResult<()> {
OPFS_POOL.with(|slot| {
let pool = slot.borrow();
let pool = pool
.as_ref()
.ok_or_else(|| Error::new(-1, "OPFS SAH-pool VFS is not installed"))?;
let filename = path.to_string_lossy();
pool.delete_db(&filename).map_err(|err| {
Error::new(
-1,
format!("failed to delete OPFS database file {filename}: {err}"),
)
})?;
Ok(())
})
}
#[cfg(test)]
mod tests {
use std::path::Path;
use secrecy::SecretBox;
use wasm_bindgen_test::wasm_bindgen_test;
use super::{delete_file, install, OPFS_POOL};
use crate::cipher::open_encrypted;
use crate::{error::DbResult, Error};
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker);
fn export_database(path: &Path) -> DbResult<Vec<u8>> {
OPFS_POOL.with(|slot| {
let pool = slot.borrow();
let pool = pool
.as_ref()
.ok_or_else(|| Error::new(-1, "OPFS SAH-pool VFS is not installed"))?;
let filename = path.to_string_lossy();
pool.export_db(&filename).map_err(|err| {
Error::new(
-1,
format!("failed to export OPFS database file {filename}: {err}"),
)
})
})
}
fn delete_database_files(path: &Path) {
for path in [
path.to_path_buf(),
path.with_extension("sqlite-journal"),
path.with_extension("sqlite-wal"),
path.with_extension("sqlite-shm"),
] {
delete_file(&path).expect("delete database file");
}
}
#[wasm_bindgen_test]
#[expect(
clippy::future_not_send,
reason = "OPFS and its JavaScript handles are dedicated-worker local"
)]
async fn encrypted_database_persists_without_plaintext_at_rest() {
const DATABASE_PATH: &str = "walletkit-encrypted-opfs-test.sqlite";
const SECRET: &str = "walletkit-opfs-secret-marker";
install().await.expect("install persistent VFS");
let path = Path::new(DATABASE_PATH);
delete_database_files(path);
let key = SecretBox::init_with(|| [0xAB; 32]);
{
let conn = open_encrypted(path, &key, false).expect("create database");
conn.execute_batch(
"CREATE TABLE secrets (value TEXT NOT NULL);\
INSERT INTO secrets (value) VALUES ('walletkit-opfs-secret-marker');",
)
.expect("write secret");
}
let stored = export_database(path).expect("export encrypted bytes");
assert!(
!stored.starts_with(b"SQLite format 3\0"),
"encrypted database should not have the plaintext sqlite magic header"
);
assert!(
!stored
.windows(SECRET.len())
.any(|window| window == SECRET.as_bytes()),
"encrypted database should not contain the plaintext secret anywhere"
);
{
let conn = open_encrypted(path, &key, false).expect("reopen database");
let value = conn
.query_row("SELECT value FROM secrets", &[], |row| {
Ok(row.column_text(0))
})
.expect("read secret");
assert_eq!(value, SECRET);
}
let wrong_key = SecretBox::init_with(|| [0xCD; 32]);
assert!(
open_encrypted(path, &wrong_key, false).is_err(),
"should fail to open database with wrong key"
);
delete_database_files(path);
}
}