Skip to main content

bitwarden_state/
debug.rs

1//! Dev-only generic debug browse over the registry's repositories.
2//!
3//! [`StateDebug`](crate::debug::StateDebug) is the exposed handle: a typed
4//! browse over every registered repository, addressed by string type name and
5//! key, for automated tooling that needs to reach state the type-safe public API
6//! does not expose generically. It reads through a parallel index of per-type
7//! get/set/list shims, captured where each type is still known (client-managed
8//! registration and the SDK-managed migration path) and dispatched by the
9//! repository's string name. Compiled only under the `debug-capabilities`
10//! feature; never ship in production.
11//!
12//! The registration logic is covered by the unit test below (run under
13//! `--all-features`). CI never builds the feature into a wasm/mobile artifact,
14//! so the generated binding surface is verified by hand:
15//! `crates/bitwarden-wasm-internal/build.sh -d`, then the integration-tests
16//! `debug().state()` browse.
17
18use std::{
19    any::TypeId,
20    collections::HashMap,
21    future::Future,
22    pin::Pin,
23    str::FromStr,
24    sync::{Arc, RwLock},
25};
26
27use serde_json::Value;
28
29use crate::{
30    registry::StateRegistry,
31    repository::{RepositoryItem, RepositoryMigrations},
32};
33
34/// Typed debug handle over the SDK's state registry: a generic browse across
35/// every registered repository, both client-managed and SDK-managed.
36///
37/// Holds a cheap, `Arc`-backed handle to the registry, so nothing borrows across
38/// the FFI boundary. Reached through the client's debug tree; bypasses the
39/// type-safe public API.
40#[bitwarden_ffi::wasm_object]
41pub struct StateDebug {
42    registry: Arc<StateRegistry>,
43}
44
45impl StateDebug {
46    /// Wrap the owning registry handle. Reached through [`StateRegistryDebugExt`].
47    pub(crate) fn new(registry: Arc<StateRegistry>) -> Self {
48        Self { registry }
49    }
50}
51
52/// Yields the debug handle for a state registry.
53///
54/// Implemented on `Arc<StateRegistry>` (not `StateRegistry`) because the handle
55/// owns its registry, so nothing borrows across the FFI boundary. The debug tree
56/// gets the shared registry from the client and calls this.
57pub trait StateRegistryDebugExt {
58    /// Debug browse over this registry.
59    fn debug(&self) -> StateDebug;
60}
61
62impl StateRegistryDebugExt for Arc<StateRegistry> {
63    fn debug(&self) -> StateDebug {
64        StateDebug::new(self.clone())
65    }
66}
67
68#[bitwarden_ffi::wasm_export]
69impl StateDebug {
70    /// Names of every registered repository (client- and SDK-managed).
71    pub fn types(&self) -> Vec<String> {
72        self.registry.debug_types()
73    }
74
75    /// List a repository's values as a JSON-array string (`JSON.parse` it),
76    /// addressed by type name. Values only (no keys). Raw stored JSON.
77    pub async fn list(&self, type_name: String) -> String {
78        Value::Array(self.registry.debug_list(&type_name).await).to_string()
79    }
80
81    /// Read one item by type name and string key, as a JSON string (`"null"` if
82    /// absent).
83    pub async fn get(&self, type_name: String, key: String) -> String {
84        self.registry
85            .debug_get(&type_name, &key)
86            .await
87            .unwrap_or(Value::Null)
88            .to_string()
89    }
90
91    /// Write one item by type name and string key. `value` is the item as a JSON
92    /// string. Returns `true` if the write landed; `false` on a bad JSON string,
93    /// an unknown type, a bad key, a value that does not deserialize, or a failed
94    /// write, so a caller (e.g. a seeding harness) can tell a no-op from a write.
95    pub async fn set(&self, type_name: String, key: String, value: String) -> bool {
96        let Ok(value) = serde_json::from_str::<Value>(&value) else {
97            return false;
98        };
99        self.registry.debug_set(&type_name, &key, value).await
100    }
101}
102
103/// A future borrowing the registry, boxed so it can cross a fn pointer. `Send`
104/// so the handle's async methods can run on a multi-threaded runtime (uniffi).
105type DebugFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
106/// Shim: list a repository's values as JSON.
107type DebugListFn = for<'a> fn(&'a StateRegistry) -> DebugFuture<'a, Vec<Value>>;
108/// Shim: read one item by string key as JSON.
109type DebugGetFn = for<'a> fn(&'a StateRegistry, &'a str) -> DebugFuture<'a, Option<Value>>;
110/// Shim: write one item by string key. Reports whether the write landed.
111type DebugSetFn = for<'a> fn(&'a StateRegistry, &'a str, Value) -> DebugFuture<'a, bool>;
112
113/// Monomorphized get/set/list shims for one repository type, so the registry can
114/// offer generic string-addressed access without naming the type. Captured at
115/// the point where the concrete type is still known.
116#[derive(Debug, Clone, Copy)]
117pub(crate) struct DebugRepo {
118    name: &'static str,
119    list: DebugListFn,
120    get: DebugGetFn,
121    set: DebugSetFn,
122}
123
124impl DebugRepo {
125    /// Capture the shims for a concrete repository type.
126    pub(crate) fn for_type<T: RepositoryItem>() -> Self {
127        Self {
128            name: T::NAME,
129            list: |registry| Box::pin(list_repo::<T>(registry)),
130            get: |registry, key| Box::pin(get_repo::<T>(registry, key)),
131            set: |registry, key, value| Box::pin(set_repo::<T>(registry, key, value)),
132        }
133    }
134}
135
136/// Index of per-type shims, keyed by `TypeId`. Held by [`StateRegistry`] so the
137/// generic debug surface can address any registered repository by its string
138/// name and key.
139#[derive(Default)]
140pub(crate) struct DebugRegistry {
141    repos: RwLock<HashMap<TypeId, DebugRepo>>,
142}
143
144impl DebugRegistry {
145    /// Create an empty index.
146    pub(crate) fn new() -> Self {
147        Self::default()
148    }
149
150    /// Capture shims for a client-managed type at its registration point.
151    pub(crate) fn register<T: RepositoryItem>(&self) {
152        self.insert(TypeId::of::<T>(), DebugRepo::for_type::<T>());
153    }
154
155    /// Capture shims for every SDK-managed type declared in the migrations, each
156    /// carried on its [`RepositoryItemData`](crate::repository::RepositoryItemData)
157    /// from the point where the type was still known.
158    pub(crate) fn register_migrations(&self, migrations: &RepositoryMigrations) {
159        for item in migrations.clone().into_repository_items() {
160            self.insert(item.type_id(), item.debug);
161        }
162    }
163
164    fn insert(&self, type_id: TypeId, repo: DebugRepo) {
165        self.repos
166            .write()
167            .expect("RwLock should not be poisoned")
168            .insert(type_id, repo);
169    }
170
171    /// Look up the (copyable) shim set for a repository by its type name.
172    fn named(&self, type_name: &str) -> Option<DebugRepo> {
173        self.repos
174            .read()
175            .expect("RwLock should not be poisoned")
176            .values()
177            .find(|repo| repo.name == type_name)
178            .copied()
179    }
180
181    /// Names of every registered repository.
182    fn names(&self) -> Vec<String> {
183        self.repos
184            .read()
185            .expect("RwLock should not be poisoned")
186            .values()
187            .map(|repo| repo.name.to_string())
188            .collect()
189    }
190}
191
192/// Generic browse over registered repositories, addressed by type name. Backs
193/// [`StateDebug`]; bypasses the type-safe public API.
194impl StateRegistry {
195    /// Register debug shims for the SDK-managed types declared in `migrations`.
196    ///
197    /// [`new_with_db`](Self::new_with_db) already does this, but the memory-backed
198    /// registry the wasm and mobile bindings build does not go through it, so
199    /// without this call the browse would only see client-managed types. Idempotent
200    /// (shims are keyed by type).
201    pub fn debug_register_migrations(&self, migrations: &RepositoryMigrations) {
202        self.debug.register_migrations(migrations);
203    }
204
205    /// Names of every registered repository (client- and SDK-managed).
206    pub(crate) fn debug_types(&self) -> Vec<String> {
207        self.debug.names()
208    }
209
210    /// List a repository's values as JSON, addressed by type name. Values only;
211    /// the repository API lists values without their keys.
212    pub(crate) async fn debug_list(&self, type_name: &str) -> Vec<Value> {
213        match self.debug.named(type_name) {
214            Some(repo) => (repo.list)(self).await,
215            None => Vec::new(),
216        }
217    }
218
219    /// Read one item by type name and string key, as JSON.
220    pub(crate) async fn debug_get(&self, type_name: &str, key: &str) -> Option<Value> {
221        let repo = self.debug.named(type_name)?;
222        (repo.get)(self, key).await
223    }
224
225    /// Write one item by type name and string key. Returns `false` on an unknown
226    /// type, a bad key, a value that does not deserialize, or a failed write.
227    pub(crate) async fn debug_set(&self, type_name: &str, key: &str, value: Value) -> bool {
228        match self.debug.named(type_name) {
229            Some(repo) => (repo.set)(self, key, value).await,
230            None => false,
231        }
232    }
233}
234
235/// List every item in the `T` repository (client- or SDK-managed) as JSON.
236async fn list_repo<T: RepositoryItem>(registry: &StateRegistry) -> Vec<Value> {
237    let Ok(repository) = registry.get::<T>() else {
238        return Vec::new();
239    };
240    match repository.list().await {
241        Ok(items) => items
242            .iter()
243            .filter_map(|item| serde_json::to_value(item).ok())
244            .collect(),
245        Err(_) => Vec::new(),
246    }
247}
248
249/// Read one item from the `T` repository by its string key, as JSON.
250async fn get_repo<T: RepositoryItem>(registry: &StateRegistry, key: &str) -> Option<Value> {
251    let key = <T::Key as FromStr>::from_str(key).ok()?;
252    let value = registry.get::<T>().ok()?.get(key).await.ok().flatten()?;
253    serde_json::to_value(&value).ok()
254}
255
256/// Write one item to the `T` repository at its string key. Returns `false` on a
257/// bad key, a value that does not deserialize to `T`, or a failed write.
258async fn set_repo<T: RepositoryItem>(registry: &StateRegistry, key: &str, value: Value) -> bool {
259    let Ok(key) = <T::Key as FromStr>::from_str(key) else {
260        return false;
261    };
262    let Ok(value) = serde_json::from_value::<T>(value) else {
263        return false;
264    };
265    match registry.get::<T>() {
266        Ok(repository) => repository.set(key, value).await.is_ok(),
267        Err(_) => false,
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use serde::{Deserialize, Serialize};
274    use serde_json::json;
275
276    use super::*;
277    use crate::{
278        register_repository_item,
279        repository::{RepositoryMigrationStep, RepositoryMigrations},
280    };
281
282    #[derive(Serialize, Deserialize, PartialEq, Debug)]
283    struct SdkOnlyItem {
284        value: String,
285    }
286    register_repository_item!(String => SdkOnlyItem, "SdkOnlyItem");
287
288    /// A type that is only ever SDK-managed (declared in migrations, never
289    /// registered client-managed) is invisible to the browse until its migration
290    /// shims are registered, and then reads and writes through the memory-backed
291    /// registry the wasm and mobile bindings use.
292    #[tokio::test]
293    async fn debug_register_migrations_exposes_sdk_managed_types() {
294        let registry = StateRegistry::new_with_memory_db();
295        assert!(!registry.debug_types().contains(&"SdkOnlyItem".to_string()));
296
297        let migrations =
298            RepositoryMigrations::new(vec![RepositoryMigrationStep::Add(SdkOnlyItem::data())]);
299        registry.debug_register_migrations(&migrations);
300        assert!(registry.debug_types().contains(&"SdkOnlyItem".to_string()));
301
302        let value = json!({ "value": "hello" });
303        assert!(registry.debug_set("SdkOnlyItem", "k1", value.clone()).await);
304        assert_eq!(registry.debug_get("SdkOnlyItem", "k1").await, Some(value));
305        assert_eq!(registry.debug_list("SdkOnlyItem").await.len(), 1);
306    }
307}