From 5e9d57a9bac31667ce3d2a0d2d2945ac48dc7b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garci=CC=81a?= Date: Sun, 8 Jun 2025 21:15:05 +0200 Subject: [PATCH 1/2] Implement SDK managed state # Conflicts: # Cargo.lock # crates/bitwarden-state/Cargo.toml # crates/bitwarden-state/src/registry.rs # crates/bitwarden-state/src/repository.rs # crates/bitwarden-vault/src/cipher/cipher.rs # Conflicts: # crates/bitwarden-vault/src/vault_client.rs # Conflicts: # Cargo.lock # crates/bitwarden-state/Cargo.toml Use bundled sqlite to solve compile issues Update readme Don't stringify the data in indexeddb Remove the comment Update readme --- Cargo.lock | 95 +++++++++++ .../src/platform/state_client.rs | 26 ++- crates/bitwarden-state/Cargo.toml | 13 ++ crates/bitwarden-state/README.md | 63 ++++++- crates/bitwarden-state/src/lib.rs | 2 + crates/bitwarden-state/src/registry.rs | 76 ++++++++- crates/bitwarden-state/src/repository.rs | 40 ++++- .../src/sdk_managed/indexed_db.rs | 159 ++++++++++++++++++ crates/bitwarden-state/src/sdk_managed/mod.rs | 95 +++++++++++ .../bitwarden-state/src/sdk_managed/sqlite.rs | 102 +++++++++++ crates/bitwarden-threading/src/lib.rs | 2 +- .../src/thread_bound_runner.rs | 10 +- crates/bitwarden-uniffi/src/error.rs | 3 + crates/bitwarden-uniffi/src/platform/mod.rs | 20 ++- crates/bitwarden-vault/src/cipher/cipher.rs | 2 +- .../src/platform/mod.rs | 21 ++- 16 files changed, 712 insertions(+), 17 deletions(-) create mode 100644 crates/bitwarden-state/src/sdk_managed/indexed_db.rs create mode 100644 crates/bitwarden-state/src/sdk_managed/mod.rs create mode 100644 crates/bitwarden-state/src/sdk_managed/sqlite.rs diff --git a/Cargo.lock b/Cargo.lock index 3e25d4cec..180258f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,8 +605,16 @@ name = "bitwarden-state" version = "1.0.0" dependencies = [ "async-trait", + "bitwarden-error", + "bitwarden-threading", + "indexed-db", + "js-sys", + "rusqlite", + "serde", + "serde_json", "thiserror 1.0.69", "tokio", + "tsify-next", ] [[package]] @@ -1572,6 +1580,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.13.0" @@ -1605,6 +1625,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1870,6 +1896,18 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] [[package]] name = "heck" @@ -2173,6 +2211,20 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "indexed-db" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f4ecbb6cd50773303683617a93fc2782267d2c94546e9545ec4190eb69aa1a" +dependencies = [ + "futures-channel", + "futures-util", + "pin-project-lite", + "scoped-tls", + "thiserror 2.0.12", + "web-sys", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2370,6 +2422,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libsqlite3-sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2824,6 +2887,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plain" version = "0.2.3" @@ -3296,6 +3365,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" +dependencies = [ + "bitflags 2.9.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -3481,6 +3564,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4628,6 +4717,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/crates/bitwarden-core/src/platform/state_client.rs b/crates/bitwarden-core/src/platform/state_client.rs index 566b9dea7..33a6807f5 100644 --- a/crates/bitwarden-core/src/platform/state_client.rs +++ b/crates/bitwarden-core/src/platform/state_client.rs @@ -1,6 +1,9 @@ use std::sync::Arc; -use bitwarden_state::repository::{Repository, RepositoryItem}; +use bitwarden_state::{ + registry::StateRegistryError, + repository::{Repository, RepositoryItem, RepositoryItemData}, +}; use crate::Client; @@ -25,4 +28,25 @@ impl StateClient { pub fn get_client_managed(&self) -> Option>> { self.client.internal.repository_map.get_client_managed() } + + /// Initialize the database for SDK managed repositories. + pub async fn initialize_database( + &self, + repositories: Vec, + ) -> Result<(), StateRegistryError> { + self.client + .internal + .repository_map + .initialize_database(repositories) + .await + } + + /// Get a SDK managed state repository for a specific type, if it exists. + pub fn get_sdk_managed< + T: RepositoryItem + serde::ser::Serialize + serde::de::DeserializeOwned, + >( + &self, + ) -> Result, StateRegistryError> { + self.client.internal.repository_map.get_sdk_managed() + } } diff --git a/crates/bitwarden-state/Cargo.toml b/crates/bitwarden-state/Cargo.toml index 0593aad1f..c602313ed 100644 --- a/crates/bitwarden-state/Cargo.toml +++ b/crates/bitwarden-state/Cargo.toml @@ -15,7 +15,20 @@ wasm = [] [dependencies] async-trait = { workspace = true } +bitwarden-error = { workspace = true } +bitwarden-threading = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(target_arch="wasm32")'.dependencies] +indexed-db = ">=0.4.2, <0.5" +js-sys = { workspace = true } +tsify-next = { workspace = true } + +[target.'cfg(not(target_arch="wasm32"))'.dependencies] +rusqlite = { version = ">=0.36.0, <0.37", features = ["bundled"] } [dev-dependencies] tokio = { workspace = true, features = ["rt"] } diff --git a/crates/bitwarden-state/README.md b/crates/bitwarden-state/README.md index cc135e002..82f4358f7 100644 --- a/crates/bitwarden-state/README.md +++ b/crates/bitwarden-state/README.md @@ -14,7 +14,7 @@ struct Cipher { // Register `Cipher` for use with a `Repository`. // This should be done in the crate where `Cipher` is defined. -bitwarden_state::register_repository_item!(Cipher, "Cipher"); +bitwarden_state::register_repository_item!(Cipher, "Cipher", version: 1); ``` With the registration complete, the next important decision is to select where will the data be @@ -173,3 +173,64 @@ class CipherStoreImpl: CipherStore { getClient(userId = userId).platform().store().registerCipherStore(CipherStoreImpl()); ``` + +## SDK-Managed State + +With `SDK-Managed State`, the SDK will be exclusively responsible for the data storage. This means +that the clients don't need to make any changes themselves, as the implementation is internal to the +SDK. To add support for an SDK managed `Repository`, it needs to be added to the initialization code +for WASM and UniFFI. This example shows how to add support for `Cipher`s. + +### WASM + +Go to `crates/bitwarden-wasm-internal/src/platform/mod.rs` and add a line with your type, as shown: + +```rust,ignore + pub async fn initialize_state( + &self, + cipher_repository: CipherRepository, + ) -> Result<(), bitwarden_state::registry::StateRegistryError> { + let cipher = cipher_repository.into_channel_impl(); + self.0.platform().state().register_client_managed(cipher); + + let sdk_managed_repositories = vec![ + // This should list all the SDK-managed repositories + ::data(), + // Add your type here + ]; + + self.0 + .platform() + .state() + .initialize_database(sdk_managed_repositories) + .await + } +``` + +### UniFFI + +Go to `crates/bitwarden-uniffi/src/platform/mod.rs` and add a line with your type, as shown: + +```rust,ignore + pub async fn initialize_state( + &self, + cipher_repository: Arc, + ) -> Result<()> { + let cipher = UniffiRepositoryBridge::new(cipher_repository); + self.0.platform().state().register_client_managed(cipher); + + let sdk_managed_repositories = vec![ + // This should list all the SDK-managed repositories + ::data(), + // Add your type here + ]; + + self.0 + .platform() + .state() + .initialize_database(sdk_managed_repositories) + .await + .map_err(Error::StateRegistry)?; + Ok(()) + } +``` diff --git a/crates/bitwarden-state/src/lib.rs b/crates/bitwarden-state/src/lib.rs index 825a46e39..fe982ee46 100644 --- a/crates/bitwarden-state/src/lib.rs +++ b/crates/bitwarden-state/src/lib.rs @@ -5,3 +5,5 @@ pub mod repository; /// This module provides a registry for managing repositories of different types. pub mod registry; + +pub(crate) mod sdk_managed; diff --git a/crates/bitwarden-state/src/registry.rs b/crates/bitwarden-state/src/registry.rs index 47e9fb832..1645aaf0c 100644 --- a/crates/bitwarden-state/src/registry.rs +++ b/crates/bitwarden-state/src/registry.rs @@ -1,15 +1,25 @@ use std::{ any::{Any, TypeId}, collections::HashMap, - sync::{Arc, RwLock}, + sync::{Arc, OnceLock, RwLock}, }; -use crate::repository::{Repository, RepositoryItem}; +use bitwarden_error::bitwarden_error; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; + +use crate::{ + repository::{Repository, RepositoryItem, RepositoryItemData}, + sdk_managed::{Database, SystemDatabase}, +}; /// A registry that contains repositories for different types of items. /// These repositories can be either managed by the client or by the SDK itself. pub struct StateRegistry { + sdk_managed: RwLock>, client_managed: RwLock>>, + + database: OnceLock, } impl std::fmt::Debug for StateRegistry { @@ -18,13 +28,58 @@ impl std::fmt::Debug for StateRegistry { } } +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum StateRegistryError { + #[error("Database is already initialized")] + DatabaseAlreadyInitialized, + #[error("Database is not initialized")] + DatabaseNotInitialized, + + #[error(transparent)] + Database(#[from] crate::sdk_managed::DatabaseError), +} + impl StateRegistry { /// Creates a new empty `StateRegistry`. #[allow(clippy::new_without_default)] pub fn new() -> Self { StateRegistry { client_managed: RwLock::new(HashMap::new()), + database: OnceLock::new(), + sdk_managed: RwLock::new(Vec::new()), + } + } + + // TODO: Ideally we'd do this in new, but that would mean making the client initialization + // async. + // TODO: This function needs to be provided some configuration to know where to open the + // database. For Sqlite: + // - A folder path where the files will be stored. + // - A user ID to create a unique database file per user? + // + // For WASM indexedDB: + // - A database name to use for the indexedDB (Some prefix to avoid conflicts + user ID?) + + /// Initializes the database used for sdk-managed repositories. + pub async fn initialize_database( + &self, + repositories: Vec, + ) -> Result<(), StateRegistryError> { + if self.database.get().is_some() { + return Err(StateRegistryError::DatabaseAlreadyInitialized); } + let _ = self + .database + .set(SystemDatabase::initialize(&repositories).await?); + + *self + .sdk_managed + .write() + .expect("RwLock should not be poisoned") = repositories.clone(); + + Ok(()) } /// Registers a client-managed repository into the map, associating it with its type. @@ -44,6 +99,17 @@ impl StateRegistry { .and_then(|boxed| boxed.downcast_ref::>>()) .map(Arc::clone) } + + /// Retrieves a SDK-managed repository from the database. + pub fn get_sdk_managed( + &self, + ) -> Result, StateRegistryError> { + if let Some(db) = self.database.get() { + Ok(db.get_repository::()?) + } else { + Err(StateRegistryError::DatabaseNotInitialized) + } + } } #[cfg(test)] @@ -83,9 +149,9 @@ mod tests { #[derive(PartialEq, Eq, Debug)] struct TestItem(T); - register_repository_item!(TestItem, "TestItem"); - register_repository_item!(TestItem, "TestItem"); - register_repository_item!(TestItem>, "TestItem>"); + register_repository_item!(TestItem, "TestItem", version: 1); + register_repository_item!(TestItem, "TestItem", version: 1); + register_repository_item!(TestItem>, "TestItem>", version: 1); impl_repository!(TestA, TestItem); impl_repository!(TestB, TestItem); diff --git a/crates/bitwarden-state/src/repository.rs b/crates/bitwarden-state/src/repository.rs index bf19092b2..bd0006c61 100644 --- a/crates/bitwarden-state/src/repository.rs +++ b/crates/bitwarden-state/src/repository.rs @@ -6,6 +6,14 @@ pub enum RepositoryError { /// An internal unspecified error. #[error("Internal error: {0}")] Internal(String), + + /// A serialization or deserialization error. + #[error(transparent)] + Serde(#[from] serde_json::Error), + + /// An internal database error. + #[error(transparent)] + Database(#[from] crate::sdk_managed::DatabaseError), } /// This trait represents a generic repository interface, capable of storing and retrieving @@ -28,21 +36,51 @@ pub trait Repository: Send + Sync { pub trait RepositoryItem: Internal + Send + Sync + 'static { /// The name of the type implementing this trait. const NAME: &'static str; + + /// The version of the repository type implementing this trait. + const VERSION: u32; + /// Returns the `TypeId` of the type implementing this trait. fn type_id() -> TypeId { TypeId::of::() } + + /// Returns metadata about the repository item type. + fn data() -> RepositoryItemData { + RepositoryItemData::new::() + } +} + +/// This struct holds metadata about a registered repository item type. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +pub struct RepositoryItemData { + pub(crate) type_id: TypeId, + pub(crate) name: &'static str, + pub(crate) version: u32, +} + +impl RepositoryItemData { + /// Create a new `RepositoryItemData` from a type that implements `RepositoryItem`. + pub fn new() -> Self { + Self { + type_id: TypeId::of::(), + name: T::NAME, + version: T::VERSION, + } + } } /// Register a type for use in a repository. The type must only be registered once in the crate /// where it's defined. The provided name must be unique and not be changed. #[macro_export] macro_rules! register_repository_item { - ($ty:ty, $name:literal) => { + ($ty:ty, $name:literal, version: $version:literal) => { const _: () = { impl $crate::repository::___internal::Internal for $ty {} impl $crate::repository::RepositoryItem for $ty { const NAME: &'static str = $name; + const VERSION: u32 = $version; } }; }; diff --git a/crates/bitwarden-state/src/sdk_managed/indexed_db.rs b/crates/bitwarden-state/src/sdk_managed/indexed_db.rs new file mode 100644 index 000000000..6e734ef03 --- /dev/null +++ b/crates/bitwarden-state/src/sdk_managed/indexed_db.rs @@ -0,0 +1,159 @@ +use js_sys::JsString; +use serde::{de::DeserializeOwned, ser::Serialize}; + +use crate::{ + repository::{RepositoryItem, RepositoryItemData}, + sdk_managed::{Database, DatabaseError}, +}; + +#[derive(Debug, thiserror::Error)] +#[error("IndexedDB internal error: {0}")] +pub struct IndexedDbInternalError(String); +impl From for IndexedDbInternalError { + fn from(err: tsify_next::serde_wasm_bindgen::Error) -> Self { + IndexedDbInternalError(err.to_string()) + } +} + +#[derive(Clone)] +pub struct IndexedDbDatabase( + bitwarden_threading::ThreadBoundRunner>, +); +impl Database for IndexedDbDatabase { + async fn initialize(registrations: &[RepositoryItemData]) -> Result { + let factory = indexed_db::Factory::get()?; + + let registrations = registrations.to_vec(); + + // Sum all the versions of the registrations to determine the database version + // TODO: We should do a better versioning strategy, as this won't work if one repository is + // removed + let version: u32 = registrations.iter().map(|reg| reg.version).sum(); + + // Open the database, creating it if needed + let db = factory + .open("bitwarden-sdk-test-db", version, async move |evt| { + let db = evt.database(); + + for reg in registrations { + db.build_object_store(reg.name).create()?; + } + + Ok(()) + }) + .await?; + + let runner = bitwarden_threading::ThreadBoundRunner::new(db); + Ok(IndexedDbDatabase(runner)) + } + + async fn get( + &self, + namespace: &str, + key: &str, + ) -> Result, DatabaseError> { + let namespace = namespace.to_string(); + let key = key.to_string(); + + let result = self + .0 + .run_in_thread(move |db| async move { + db.transaction(&[&namespace]) + .run(|t| async move { + let store = t.object_store(&namespace)?; + let response = store.get(&JsString::from(key)).await?; + + if let Some(value) = response { + Ok(::tsify_next::serde_wasm_bindgen::from_value(value) + .map_err(IndexedDbInternalError::from)?) + } else { + Ok(None) + } + }) + .await + }) + .await??; + + Ok(result) + } + + async fn list( + &self, + namespace: &str, + ) -> Result, DatabaseError> { + let namespace = namespace.to_string(); + + let results = self + .0 + .run_in_thread(move |db| async move { + db.transaction(&[&namespace]) + .run(|t| async move { + let store = t.object_store(&namespace)?; + let results = store.get_all(None).await?; + + let mut items: Vec = Vec::new(); + + for value in results { + let item: T = ::tsify_next::serde_wasm_bindgen::from_value(value) + .map_err(IndexedDbInternalError::from)?; + items.push(item); + } + + Ok(items) + }) + .await + }) + .await??; + + Ok(results) + } + + async fn set( + &self, + namespace: &str, + key: &str, + value: T, + ) -> Result<(), DatabaseError> { + let namespace = namespace.to_string(); + let key = key.to_string(); + + self.0 + .run_in_thread(move |db| async move { + db.transaction(&[&namespace]) + .rw() + .run(|t| async move { + let store = t.object_store(&namespace)?; + + let value = ::tsify_next::serde_wasm_bindgen::to_value(&value) + .map_err(IndexedDbInternalError::from)?; + + store.put_kv(&JsString::from(key), &value).await?; + Ok(()) + }) + .await + }) + .await??; + + Ok(()) + } + + async fn remove(&self, namespace: &str, key: &str) -> Result<(), DatabaseError> { + let namespace = namespace.to_string(); + let key = key.to_string(); + + self.0 + .run_in_thread(move |db| async move { + db.transaction(&[&namespace]) + .rw() + .run(|t| async move { + let store = t.object_store(&namespace)?; + store.delete(&JsString::from(key)).await?; + Ok(()) + }) + .await + }) + .await??; + + Ok(()) + } +} diff --git a/crates/bitwarden-state/src/sdk_managed/mod.rs b/crates/bitwarden-state/src/sdk_managed/mod.rs new file mode 100644 index 000000000..cfd4417be --- /dev/null +++ b/crates/bitwarden-state/src/sdk_managed/mod.rs @@ -0,0 +1,95 @@ +use bitwarden_error::bitwarden_error; +use serde::{de::DeserializeOwned, ser::Serialize}; +use thiserror::Error; + +use crate::repository::{Repository, RepositoryError, RepositoryItem, RepositoryItemData}; + +#[cfg(target_arch = "wasm32")] +mod indexed_db; +#[cfg(target_arch = "wasm32")] +pub(super) type SystemDatabase = indexed_db::IndexedDbDatabase; +#[cfg(target_arch = "wasm32")] +type InternalError = ::indexed_db::Error; + +#[cfg(not(target_arch = "wasm32"))] +mod sqlite; +#[cfg(not(target_arch = "wasm32"))] +pub(super) type SystemDatabase = sqlite::SqliteDatabase; +#[cfg(not(target_arch = "wasm32"))] +type InternalError = ::rusqlite::Error; + +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum DatabaseError { + #[error(transparent)] + ThreadBoundRunner(#[from] bitwarden_threading::CallError), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("JS error: {0}")] + JSError(String), + + #[error(transparent)] + Internal(#[from] InternalError), +} + +pub trait Database { + async fn initialize(registrations: &[RepositoryItemData]) -> Result + where + Self: Sized; + + async fn get( + &self, + namespace: &str, + key: &str, + ) -> Result, DatabaseError>; + + async fn list( + &self, + namespace: &str, + ) -> Result, DatabaseError>; + + async fn set( + &self, + namespace: &str, + key: &str, + value: T, + ) -> Result<(), DatabaseError>; + + async fn remove(&self, namespace: &str, key: &str) -> Result<(), DatabaseError>; +} + +struct DBRepository { + database: SystemDatabase, + _marker: std::marker::PhantomData, +} + +#[async_trait::async_trait] +impl Repository for DBRepository { + async fn get(&self, key: String) -> Result, RepositoryError> { + let value = self.database.get(V::NAME, &key).await?; + Ok(value) + } + async fn list(&self) -> Result, RepositoryError> { + let values = self.database.list(V::NAME).await?; + Ok(values) + } + async fn set(&self, key: String, value: V) -> Result<(), RepositoryError> { + Ok(self.database.set(V::NAME, &key, value).await?) + } + async fn remove(&self, key: String) -> Result<(), RepositoryError> { + Ok(self.database.remove(V::NAME, &key).await?) + } +} + +impl SystemDatabase { + pub(super) fn get_repository( + &self, + ) -> Result, DatabaseError> { + Ok(DBRepository { + database: self.clone(), + _marker: std::marker::PhantomData, + }) + } +} diff --git a/crates/bitwarden-state/src/sdk_managed/sqlite.rs b/crates/bitwarden-state/src/sdk_managed/sqlite.rs new file mode 100644 index 000000000..32a62f41c --- /dev/null +++ b/crates/bitwarden-state/src/sdk_managed/sqlite.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use serde::{de::DeserializeOwned, ser::Serialize}; +use tokio::sync::Mutex; + +use crate::{ + repository::{RepositoryItem, RepositoryItemData}, + sdk_managed::{Database, DatabaseError}, +}; + +// TODO: Use connection pooling with r2d2 and r2d2_sqlite? +#[derive(Clone)] +pub struct SqliteDatabase(Arc>); +impl Database for SqliteDatabase { + async fn initialize(registrations: &[RepositoryItemData]) -> Result { + let mut db = rusqlite::Connection::open_in_memory()?; + + // Set WAL mode for better concurrency + db.execute("PRAGMA journal_mode = WAL;", [])?; + + let transaction = db.transaction()?; + + for reg in registrations { + transaction.execute( + "CREATE TABLE IF NOT EXISTS ?1 (key TEXT PRIMARY KEY, value TEXT NOT NULL);", + [reg.name], + )?; + } + + transaction.commit()?; + Ok(SqliteDatabase(Arc::new(Mutex::new(db)))) + } + + async fn get( + &self, + namespace: &str, + key: &str, + ) -> Result, DatabaseError> { + let conn = self.0.lock().await; + let mut stmt = conn.prepare("SELECT value FROM ?1 WHERE key = ?2")?; + let mut rows = stmt.query(rusqlite::params![namespace, key])?; + + if let Some(row) = rows.next()? { + let value = row.get::<_, String>(0)?; + + Ok(Some(serde_json::from_str(&value)?)) + } else { + Ok(None) + } + } + + async fn list( + &self, + namespace: &str, + ) -> Result, DatabaseError> { + let conn = self.0.lock().await; + let mut stmt = conn.prepare("SELECT key, value FROM ?1")?; + let rows = stmt.query_map(rusqlite::params![namespace], |row| row.get(1))?; + + let mut results = Vec::new(); + for row in rows { + let value: String = row?; + let value: T = serde_json::from_str(&value)?; + results.push(value); + } + + Ok(results) + } + + async fn set( + &self, + namespace: &str, + key: &str, + value: T, + ) -> Result<(), DatabaseError> { + let mut conn = self.0.lock().await; + let transaction = conn.transaction()?; + + let value = serde_json::to_string(&value)?; + + transaction.execute( + "INSERT OR REPLACE INTO ?1 (key, value) VALUES (?2, ?3)", + rusqlite::params![namespace, key, value], + )?; + + transaction.commit()?; + Ok(()) + } + + async fn remove(&self, namespace: &str, key: &str) -> Result<(), DatabaseError> { + let mut conn = self.0.lock().await; + let transaction = conn.transaction()?; + + transaction.execute( + "DELETE FROM ?1 WHERE key = ?2", + rusqlite::params![namespace, key], + )?; + + transaction.commit()?; + Ok(()) + } +} diff --git a/crates/bitwarden-threading/src/lib.rs b/crates/bitwarden-threading/src/lib.rs index b86b47214..fd0e8bc1a 100644 --- a/crates/bitwarden-threading/src/lib.rs +++ b/crates/bitwarden-threading/src/lib.rs @@ -11,4 +11,4 @@ mod thread_bound_runner; #[allow(missing_docs)] pub mod time; -pub use thread_bound_runner::ThreadBoundRunner; +pub use thread_bound_runner::{CallError, ThreadBoundRunner}; diff --git a/crates/bitwarden-threading/src/thread_bound_runner.rs b/crates/bitwarden-threading/src/thread_bound_runner.rs index ff3154f89..6366561cb 100644 --- a/crates/bitwarden-threading/src/thread_bound_runner.rs +++ b/crates/bitwarden-threading/src/thread_bound_runner.rs @@ -80,11 +80,19 @@ pub struct CallError(String); /// This pattern is useful for interacting with APIs or data structures that must remain /// on the same thread, such as GUI toolkits, WebAssembly contexts, or other thread-bound /// environments. -#[derive(Clone)] pub struct ThreadBoundRunner { call_channel_tx: tokio::sync::mpsc::Sender>, } +// This is not implemented using derive to remove the implicit bound on `ThreadState: Clone` +impl Clone for ThreadBoundRunner { + fn clone(&self) -> Self { + ThreadBoundRunner { + call_channel_tx: self.call_channel_tx.clone(), + } + } +} + impl ThreadBoundRunner where ThreadState: 'static, diff --git a/crates/bitwarden-uniffi/src/error.rs b/crates/bitwarden-uniffi/src/error.rs index 3e0ca8db3..7193a2e5a 100644 --- a/crates/bitwarden-uniffi/src/error.rs +++ b/crates/bitwarden-uniffi/src/error.rs @@ -66,6 +66,9 @@ pub enum Error { #[error(transparent)] Crypto(#[from] bitwarden_crypto::CryptoError), + #[error(transparent)] + StateRegistry(#[from] bitwarden_state::registry::StateRegistryError), + // Generators #[error(transparent)] Username(#[from] UsernameError), diff --git a/crates/bitwarden-uniffi/src/platform/mod.rs b/crates/bitwarden-uniffi/src/platform/mod.rs index 798307967..b0e4fc074 100644 --- a/crates/bitwarden-uniffi/src/platform/mod.rs +++ b/crates/bitwarden-uniffi/src/platform/mod.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use bitwarden_core::{platform::FingerprintRequest, Client}; use bitwarden_fido::ClientFido2Ext; +use bitwarden_state::repository::RepositoryItem; use bitwarden_vault::Cipher; use repository::UniffiRepositoryBridge; @@ -56,11 +57,24 @@ repository::create_uniffi_repository!(CipherRepository, Cipher); #[uniffi::export] impl StateClient { - pub fn register_cipher_repository(&self, store: Arc) { - let store_internal = UniffiRepositoryBridge::new(store); + pub async fn initialize_state( + &self, + cipher_repository: Arc, + ) -> Result<()> { + let cipher = UniffiRepositoryBridge::new(cipher_repository); + self.0.platform().state().register_client_managed(cipher); + + let sdk_managed_repositories = vec![ + // This should list all the SDK-managed repositories + ::data(), + ]; + self.0 .platform() .state() - .register_client_managed(store_internal) + .initialize_database(sdk_managed_repositories) + .await + .map_err(Error::StateRegistry)?; + Ok(()) } } diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 4c9c1bdb1..b43d2f0b8 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -135,7 +135,7 @@ pub struct Cipher { pub revision_date: DateTime, } -bitwarden_state::register_repository_item!(Cipher, "Cipher"); +bitwarden_state::register_repository_item!(Cipher, "Cipher", version: 1); #[allow(missing_docs)] #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/crates/bitwarden-wasm-internal/src/platform/mod.rs b/crates/bitwarden-wasm-internal/src/platform/mod.rs index b2b977659..29d4fc004 100644 --- a/crates/bitwarden-wasm-internal/src/platform/mod.rs +++ b/crates/bitwarden-wasm-internal/src/platform/mod.rs @@ -1,4 +1,5 @@ use bitwarden_core::Client; +use bitwarden_state::repository::RepositoryItem; use bitwarden_vault::Cipher; use wasm_bindgen::prelude::wasm_bindgen; @@ -33,8 +34,22 @@ repository::create_wasm_repository!(CipherRepository, Cipher, "Repository Result<(), bitwarden_state::registry::StateRegistryError> { + let cipher = cipher_repository.into_channel_impl(); + self.0.platform().state().register_client_managed(cipher); + + let sdk_managed_repositories = vec![ + // This should list all the SDK-managed repositories + ::data(), + ]; + + self.0 + .platform() + .state() + .initialize_database(sdk_managed_repositories) + .await } } From e5cab0d87d644da0e169348421ff0ac3757b3964 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 26 Jun 2025 13:21:00 +0200 Subject: [PATCH 2/2] [PM-22593] improve initialization process for database and repositories (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## đŸŽŸī¸ Tracking ## 📔 Objective ## ⏰ Reminders before review - Contributor guidelines followed - All formatters and local linters executed and passed - Written new unit and / or integration tests where applicable - Protected functional changes with optionality (feature flags) - Used internationalization (i18n) for all UI strings - CI builds passed - Communicated to DevOps any deployment requirements - Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team ## đŸĻŽ Reviewer guidelines - 👍 (`:+1:`) or similar for great changes - 📝 (`:memo:`) or â„šī¸ (`:information_source:`) for notes or general info - ❓ (`:question:`) for questions - 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion - 🎨 (`:art:`) for suggestions / improvements - ❌ (`:x:`) or âš ī¸ (`:warning:`) for more significant problems or concerns needing attention - 🌱 (`:seedling:`) or â™ģī¸ (`:recycle:`) for future improvements or indications of technical debt - ⛏ (`:pick:`) for minor or nitpick changes --- .../src/platform/state_client.rs | 4 +++- crates/bitwarden-state/src/lib.rs | 2 ++ crates/bitwarden-state/src/registry.rs | 5 +++-- .../src/sdk_managed/configuration.rs | 21 +++++++++++++++++++ .../src/sdk_managed/indexed_db.rs | 11 ++++++++-- crates/bitwarden-state/src/sdk_managed/mod.rs | 11 +++++++++- .../bitwarden-state/src/sdk_managed/sqlite.rs | 18 +++++++++++++--- crates/bitwarden-uniffi/src/platform/mod.rs | 20 ++++++++++++++++-- .../src/platform/mod.rs | 21 +++++++++++++++++-- 9 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 crates/bitwarden-state/src/sdk_managed/configuration.rs diff --git a/crates/bitwarden-core/src/platform/state_client.rs b/crates/bitwarden-core/src/platform/state_client.rs index 33a6807f5..a5a0daef2 100644 --- a/crates/bitwarden-core/src/platform/state_client.rs +++ b/crates/bitwarden-core/src/platform/state_client.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use bitwarden_state::{ registry::StateRegistryError, repository::{Repository, RepositoryItem, RepositoryItemData}, + DatabaseConfiguration, }; use crate::Client; @@ -32,12 +33,13 @@ impl StateClient { /// Initialize the database for SDK managed repositories. pub async fn initialize_database( &self, + configuration: DatabaseConfiguration, repositories: Vec, ) -> Result<(), StateRegistryError> { self.client .internal .repository_map - .initialize_database(repositories) + .initialize_database(configuration, repositories) .await } diff --git a/crates/bitwarden-state/src/lib.rs b/crates/bitwarden-state/src/lib.rs index fe982ee46..ec172cdd1 100644 --- a/crates/bitwarden-state/src/lib.rs +++ b/crates/bitwarden-state/src/lib.rs @@ -7,3 +7,5 @@ pub mod repository; pub mod registry; pub(crate) mod sdk_managed; + +pub use sdk_managed::DatabaseConfiguration; diff --git a/crates/bitwarden-state/src/registry.rs b/crates/bitwarden-state/src/registry.rs index 1645aaf0c..407f9c758 100644 --- a/crates/bitwarden-state/src/registry.rs +++ b/crates/bitwarden-state/src/registry.rs @@ -10,7 +10,7 @@ use thiserror::Error; use crate::{ repository::{Repository, RepositoryItem, RepositoryItemData}, - sdk_managed::{Database, SystemDatabase}, + sdk_managed::{Database, DatabaseConfiguration, SystemDatabase}, }; /// A registry that contains repositories for different types of items. @@ -65,6 +65,7 @@ impl StateRegistry { /// Initializes the database used for sdk-managed repositories. pub async fn initialize_database( &self, + configuration: DatabaseConfiguration, repositories: Vec, ) -> Result<(), StateRegistryError> { if self.database.get().is_some() { @@ -72,7 +73,7 @@ impl StateRegistry { } let _ = self .database - .set(SystemDatabase::initialize(&repositories).await?); + .set(SystemDatabase::initialize(configuration, &repositories).await?); *self .sdk_managed diff --git a/crates/bitwarden-state/src/sdk_managed/configuration.rs b/crates/bitwarden-state/src/sdk_managed/configuration.rs new file mode 100644 index 000000000..622e476af --- /dev/null +++ b/crates/bitwarden-state/src/sdk_managed/configuration.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +#[derive(Debug)] +/// Configuration for the database used by the SDK. +pub enum DatabaseConfiguration { + /// SQLite configuration, used on native platforms + Sqlite { + /// The name of the SQLite database. Different users should have different database + /// names to avoid conflicts. + db_name: String, + /// The path to the folder in which the SQLite database should be stored. + folder_path: PathBuf, + }, + + /// IndexedDB configuration, used on WebAssembly platforms + IndexedDb { + /// The name of the IndexedDB database. Different users should have different database + /// names to avoid conflicts. + db_name: String, + }, +} diff --git a/crates/bitwarden-state/src/sdk_managed/indexed_db.rs b/crates/bitwarden-state/src/sdk_managed/indexed_db.rs index 6e734ef03..ec5c127a8 100644 --- a/crates/bitwarden-state/src/sdk_managed/indexed_db.rs +++ b/crates/bitwarden-state/src/sdk_managed/indexed_db.rs @@ -3,7 +3,7 @@ use serde::{de::DeserializeOwned, ser::Serialize}; use crate::{ repository::{RepositoryItem, RepositoryItemData}, - sdk_managed::{Database, DatabaseError}, + sdk_managed::{Database, DatabaseConfiguration, DatabaseError}, }; #[derive(Debug, thiserror::Error)] @@ -20,7 +20,14 @@ pub struct IndexedDbDatabase( bitwarden_threading::ThreadBoundRunner>, ); impl Database for IndexedDbDatabase { - async fn initialize(registrations: &[RepositoryItemData]) -> Result { + async fn initialize( + configuration: DatabaseConfiguration, + registrations: &[RepositoryItemData], + ) -> Result { + let DatabaseConfiguration::IndexedDb { db_name: _db_name } = configuration else { + return Err(DatabaseError::UnsupportedConfiguration(configuration)); + }; + let factory = indexed_db::Factory::get()?; let registrations = registrations.to_vec(); diff --git a/crates/bitwarden-state/src/sdk_managed/mod.rs b/crates/bitwarden-state/src/sdk_managed/mod.rs index cfd4417be..637230848 100644 --- a/crates/bitwarden-state/src/sdk_managed/mod.rs +++ b/crates/bitwarden-state/src/sdk_managed/mod.rs @@ -4,6 +4,9 @@ use thiserror::Error; use crate::repository::{Repository, RepositoryError, RepositoryItem, RepositoryItemData}; +mod configuration; +pub use configuration::DatabaseConfiguration; + #[cfg(target_arch = "wasm32")] mod indexed_db; #[cfg(target_arch = "wasm32")] @@ -21,6 +24,9 @@ type InternalError = ::rusqlite::Error; #[bitwarden_error(flat)] #[derive(Debug, Error)] pub enum DatabaseError { + #[error("Database not supported on this platform: {0:?}")] + UnsupportedConfiguration(DatabaseConfiguration), + #[error(transparent)] ThreadBoundRunner(#[from] bitwarden_threading::CallError), @@ -35,7 +41,10 @@ pub enum DatabaseError { } pub trait Database { - async fn initialize(registrations: &[RepositoryItemData]) -> Result + async fn initialize( + configuration: DatabaseConfiguration, + registrations: &[RepositoryItemData], + ) -> Result where Self: Sized; diff --git a/crates/bitwarden-state/src/sdk_managed/sqlite.rs b/crates/bitwarden-state/src/sdk_managed/sqlite.rs index 32a62f41c..2ead3fd9b 100644 --- a/crates/bitwarden-state/src/sdk_managed/sqlite.rs +++ b/crates/bitwarden-state/src/sdk_managed/sqlite.rs @@ -5,15 +5,27 @@ use tokio::sync::Mutex; use crate::{ repository::{RepositoryItem, RepositoryItemData}, - sdk_managed::{Database, DatabaseError}, + sdk_managed::{Database, DatabaseConfiguration, DatabaseError}, }; // TODO: Use connection pooling with r2d2 and r2d2_sqlite? #[derive(Clone)] pub struct SqliteDatabase(Arc>); impl Database for SqliteDatabase { - async fn initialize(registrations: &[RepositoryItemData]) -> Result { - let mut db = rusqlite::Connection::open_in_memory()?; + async fn initialize( + configuration: DatabaseConfiguration, + registrations: &[RepositoryItemData], + ) -> Result { + let DatabaseConfiguration::Sqlite { + db_name, + folder_path: mut path, + } = configuration + else { + return Err(DatabaseError::UnsupportedConfiguration(configuration)); + }; + path.set_file_name(format!("{}.sqlite", db_name)); + + let mut db = rusqlite::Connection::open(path)?; // Set WAL mode for better concurrency db.execute("PRAGMA journal_mode = WAL;", [])?; diff --git a/crates/bitwarden-uniffi/src/platform/mod.rs b/crates/bitwarden-uniffi/src/platform/mod.rs index b0e4fc074..6050733a4 100644 --- a/crates/bitwarden-uniffi/src/platform/mod.rs +++ b/crates/bitwarden-uniffi/src/platform/mod.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use bitwarden_core::{platform::FingerprintRequest, Client}; use bitwarden_fido::ClientFido2Ext; -use bitwarden_state::repository::RepositoryItem; +use bitwarden_state::{repository::RepositoryItem, DatabaseConfiguration}; use bitwarden_vault::Cipher; use repository::UniffiRepositoryBridge; @@ -55,10 +55,17 @@ pub struct StateClient(Client); repository::create_uniffi_repository!(CipherRepository, Cipher); +#[derive(uniffi::Record)] +pub struct SqliteConfiguration { + db_name: String, + folder_path: String, +} + #[uniffi::export] impl StateClient { pub async fn initialize_state( &self, + configuration: SqliteConfiguration, cipher_repository: Arc, ) -> Result<()> { let cipher = UniffiRepositoryBridge::new(cipher_repository); @@ -72,9 +79,18 @@ impl StateClient { self.0 .platform() .state() - .initialize_database(sdk_managed_repositories) + .initialize_database(configuration.into(), sdk_managed_repositories) .await .map_err(Error::StateRegistry)?; Ok(()) } } + +impl From for DatabaseConfiguration { + fn from(config: SqliteConfiguration) -> Self { + DatabaseConfiguration::Sqlite { + db_name: config.db_name, + folder_path: config.folder_path.into(), + } + } +} diff --git a/crates/bitwarden-wasm-internal/src/platform/mod.rs b/crates/bitwarden-wasm-internal/src/platform/mod.rs index 29d4fc004..6a5c26d29 100644 --- a/crates/bitwarden-wasm-internal/src/platform/mod.rs +++ b/crates/bitwarden-wasm-internal/src/platform/mod.rs @@ -1,6 +1,8 @@ use bitwarden_core::Client; -use bitwarden_state::repository::RepositoryItem; +use bitwarden_state::{repository::RepositoryItem, DatabaseConfiguration}; use bitwarden_vault::Cipher; +use serde::{Deserialize, Serialize}; +use tsify_next::Tsify; use wasm_bindgen::prelude::wasm_bindgen; mod repository; @@ -32,10 +34,17 @@ impl StateClient { repository::create_wasm_repository!(CipherRepository, Cipher, "Repository"); +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct IndexedDbConfiguration { + pub db_name: String, +} + #[wasm_bindgen] impl StateClient { pub async fn initialize_state( &self, + configuration: IndexedDbConfiguration, cipher_repository: CipherRepository, ) -> Result<(), bitwarden_state::registry::StateRegistryError> { let cipher = cipher_repository.into_channel_impl(); @@ -49,7 +58,15 @@ impl StateClient { self.0 .platform() .state() - .initialize_database(sdk_managed_repositories) + .initialize_database(configuration.into(), sdk_managed_repositories) .await } } + +impl From for DatabaseConfiguration { + fn from(config: IndexedDbConfiguration) -> Self { + bitwarden_state::DatabaseConfiguration::IndexedDb { + db_name: config.db_name, + } + } +}