diff --git a/Cargo.lock b/Cargo.lock index d42405358..e5d837e4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,8 +608,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]] @@ -1585,6 +1593,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" @@ -1618,6 +1638,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" @@ -1883,6 +1909,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" @@ -2186,6 +2224,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" @@ -2383,6 +2435,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" @@ -2837,6 +2900,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" @@ -3309,6 +3378,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" @@ -3494,6 +3577,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" @@ -4647,6 +4736,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..a5a0daef2 100644 --- a/crates/bitwarden-core/src/platform/state_client.rs +++ b/crates/bitwarden-core/src/platform/state_client.rs @@ -1,6 +1,10 @@ use std::sync::Arc; -use bitwarden_state::repository::{Repository, RepositoryItem}; +use bitwarden_state::{ + registry::StateRegistryError, + repository::{Repository, RepositoryItem, RepositoryItemData}, + DatabaseConfiguration, +}; use crate::Client; @@ -25,4 +29,26 @@ 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, + configuration: DatabaseConfiguration, + repositories: Vec, + ) -> Result<(), StateRegistryError> { + self.client + .internal + .repository_map + .initialize_database(configuration, 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..ec172cdd1 100644 --- a/crates/bitwarden-state/src/lib.rs +++ b/crates/bitwarden-state/src/lib.rs @@ -5,3 +5,7 @@ pub mod repository; /// This module provides a registry for managing repositories of different types. 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 47e9fb832..407f9c758 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, DatabaseConfiguration, 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,59 @@ 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, + configuration: DatabaseConfiguration, + repositories: Vec, + ) -> Result<(), StateRegistryError> { + if self.database.get().is_some() { + return Err(StateRegistryError::DatabaseAlreadyInitialized); } + let _ = self + .database + .set(SystemDatabase::initialize(configuration, &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 +100,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 +150,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/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 new file mode 100644 index 000000000..ec5c127a8 --- /dev/null +++ b/crates/bitwarden-state/src/sdk_managed/indexed_db.rs @@ -0,0 +1,166 @@ +use js_sys::JsString; +use serde::{de::DeserializeOwned, ser::Serialize}; + +use crate::{ + repository::{RepositoryItem, RepositoryItemData}, + sdk_managed::{Database, DatabaseConfiguration, 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( + 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(); + + // 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..637230848 --- /dev/null +++ b/crates/bitwarden-state/src/sdk_managed/mod.rs @@ -0,0 +1,104 @@ +use bitwarden_error::bitwarden_error; +use serde::{de::DeserializeOwned, ser::Serialize}; +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")] +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("Database not supported on this platform: {0:?}")] + UnsupportedConfiguration(DatabaseConfiguration), + + #[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( + configuration: DatabaseConfiguration, + 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..2ead3fd9b --- /dev/null +++ b/crates/bitwarden-state/src/sdk_managed/sqlite.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use serde::{de::DeserializeOwned, ser::Serialize}; +use tokio::sync::Mutex; + +use crate::{ + repository::{RepositoryItem, RepositoryItemData}, + 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( + 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;", [])?; + + 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..6050733a4 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, DatabaseConfiguration}; use bitwarden_vault::Cipher; use repository::UniffiRepositoryBridge; @@ -54,13 +55,42 @@ 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 fn register_cipher_repository(&self, store: Arc) { - let store_internal = UniffiRepositoryBridge::new(store); + pub async fn initialize_state( + &self, + configuration: SqliteConfiguration, + 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(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-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..6a5c26d29 100644 --- a/crates/bitwarden-wasm-internal/src/platform/mod.rs +++ b/crates/bitwarden-wasm-internal/src/platform/mod.rs @@ -1,5 +1,8 @@ use bitwarden_core::Client; +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; @@ -31,10 +34,39 @@ 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 fn register_cipher_repository(&self, store: CipherRepository) { - let store = store.into_channel_impl(); - self.0.platform().state().register_client_managed(store) + pub async fn initialize_state( + &self, + configuration: IndexedDbConfiguration, + 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(), + ]; + + self.0 + .platform() + .state() + .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, + } } }