Skip to main content

bitwarden_collections/
collection_client.rs

1use std::collections::HashMap;
2
3use bitwarden_core::{Client, FromClient};
4#[cfg(feature = "wasm")]
5use serde::{Deserialize, Serialize};
6#[cfg(feature = "wasm")]
7use tsify::Tsify;
8#[cfg(feature = "wasm")]
9use wasm_bindgen::prelude::wasm_bindgen;
10
11use crate::{
12    collection::{Collection, CollectionId, CollectionView},
13    error::{CollectionDecryptError, CollectionEncryptError},
14    tree::{NodeItem, Tree},
15};
16
17/// Represents the result of decrypting a list of collections.
18///
19/// This struct contains two vectors: `successes` and `failures`.
20/// `successes` contains the decrypted `CollectionView` objects,
21/// while `failures` contains the original `Collection` objects that failed to decrypt.
22#[cfg_attr(
23    feature = "wasm",
24    derive(Tsify, Serialize, Deserialize),
25    tsify(into_wasm_abi, from_wasm_abi)
26)]
27#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
28pub struct DecryptCollectionListResult {
29    /// The decrypted `CollectionView` objects.
30    pub successes: Vec<CollectionView>,
31    /// The original `Collection` objects that failed to decrypt.
32    pub failures: Vec<Collection>,
33}
34
35#[allow(missing_docs)]
36#[cfg_attr(feature = "wasm", wasm_bindgen)]
37#[derive(Clone)]
38pub struct CollectionsClient {
39    pub(crate) client: Client,
40}
41
42impl FromClient for CollectionsClient {
43    fn from_client(client: &Client) -> Self {
44        Self {
45            client: client.clone(),
46        }
47    }
48}
49
50#[cfg_attr(feature = "wasm", wasm_bindgen)]
51impl CollectionsClient {
52    /// Encrypts a [CollectionView] into an encrypted [Collection] using the organization key.
53    pub fn encrypt(
54        &self,
55        collection_view: CollectionView,
56    ) -> Result<Collection, CollectionEncryptError> {
57        let key_store = self.client.internal.get_key_store();
58        let collection = key_store.encrypt(collection_view)?;
59        Ok(collection)
60    }
61
62    /// Encrypts a list of [CollectionView]s into encrypted [Collection]s using the organization
63    /// key.
64    pub fn encrypt_list(
65        &self,
66        collection_views: Vec<CollectionView>,
67    ) -> Result<Vec<Collection>, CollectionEncryptError> {
68        let key_store = self.client.internal.get_key_store();
69        let collections = key_store.encrypt_list(&collection_views)?;
70        Ok(collections)
71    }
72
73    #[allow(missing_docs)]
74    pub fn decrypt(
75        &self,
76        collection: Collection,
77    ) -> Result<CollectionView, CollectionDecryptError> {
78        let key_store = self.client.internal.get_key_store();
79        let view = key_store.decrypt(&collection)?;
80        Ok(view)
81    }
82
83    #[allow(missing_docs)]
84    pub fn decrypt_list(
85        &self,
86        collections: Vec<Collection>,
87    ) -> Result<Vec<CollectionView>, CollectionDecryptError> {
88        let key_store = self.client.internal.get_key_store();
89        let views = key_store.decrypt_list(&collections)?;
90        Ok(views)
91    }
92
93    /// Decrypts a list of collections, returning successes and failures separately.
94    ///
95    /// Unlike `decrypt_list`, a single collection that fails to decrypt (e.g. due to a missing
96    /// organization key) does not abort the entire batch — it is returned in `failures` instead.
97    pub fn decrypt_list_with_failures(
98        &self,
99        collections: Vec<Collection>,
100    ) -> DecryptCollectionListResult {
101        let key_store = self.client.internal.get_key_store();
102        let (successes, failures) = key_store.decrypt_list_with_failures(&collections);
103        DecryptCollectionListResult {
104            successes,
105            failures: failures.into_iter().cloned().collect(),
106        }
107    }
108
109    ///
110    /// Returns the vector of CollectionView objects in a tree structure based on its implemented
111    /// path().
112    pub fn get_collection_tree(&self, collections: Vec<CollectionView>) -> CollectionViewTree {
113        CollectionViewTree {
114            tree: Tree::from_items(collections),
115        }
116    }
117}
118
119#[cfg_attr(feature = "wasm", wasm_bindgen)]
120pub struct CollectionViewTree {
121    tree: Tree<CollectionView>,
122}
123
124#[cfg_attr(feature = "wasm", wasm_bindgen)]
125pub struct CollectionViewNodeItem {
126    node_item: NodeItem<CollectionView>,
127}
128
129#[cfg_attr(
130    feature = "wasm",
131    derive(Tsify, Serialize, Deserialize),
132    tsify(into_wasm_abi, from_wasm_abi)
133)]
134#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
135pub struct AncestorMap {
136    pub ancestors: HashMap<CollectionId, String>,
137}
138
139#[cfg_attr(feature = "wasm", wasm_bindgen)]
140impl CollectionViewNodeItem {
141    pub fn get_item(&self) -> CollectionView {
142        self.node_item.item.clone()
143    }
144
145    pub fn get_parent(&self) -> Option<CollectionView> {
146        self.node_item.parent.clone()
147    }
148
149    pub fn get_children(&self) -> Vec<CollectionView> {
150        self.node_item.children.clone()
151    }
152
153    pub fn get_ancestors(&self) -> AncestorMap {
154        AncestorMap {
155            ancestors: self
156                .node_item
157                .ancestors
158                .iter()
159                .map(|(&uuid, name)| (CollectionId::new(uuid), name.clone()))
160                .collect(),
161        }
162    }
163}
164
165#[cfg_attr(feature = "wasm", wasm_bindgen)]
166impl CollectionViewTree {
167    pub fn get_item_for_view(
168        &self,
169        collection_view: CollectionView,
170    ) -> Option<CollectionViewNodeItem> {
171        self.tree
172            .get_item_by_id(collection_view.id.unwrap_or_default().into())
173            .map(|n| CollectionViewNodeItem { node_item: n })
174    }
175
176    pub fn get_root_items(&self) -> Vec<CollectionViewNodeItem> {
177        self.tree
178            .get_root_items()
179            .into_iter()
180            .map(|n| CollectionViewNodeItem { node_item: n })
181            .collect()
182    }
183
184    pub fn get_flat_items(&self) -> Vec<CollectionViewNodeItem> {
185        self.tree
186            .get_flat_items()
187            .into_iter()
188            .map(|n| CollectionViewNodeItem { node_item: n })
189            .collect()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use bitwarden_core::{OrganizationId, client::test_accounts::test_bitwarden_com_account};
196
197    use super::*;
198    use crate::collection::CollectionType;
199
200    fn test_collection() -> Collection {
201        Collection {
202            id: Some("66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()),
203            organization_id: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
204            name: "2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap(),
205            external_id: None,
206            hide_passwords: false,
207            read_only: false,
208            manage: false,
209            default_user_collection_email: None,
210            r#type: CollectionType::SharedCollection,
211        }
212    }
213
214    async fn test_collections_client() -> CollectionsClient {
215        let client = Client::init_test_account(test_bitwarden_com_account()).await;
216        CollectionsClient::from_client(&client)
217    }
218
219    #[tokio::test]
220    async fn test_decrypt_list() {
221        let collections = test_collections_client().await;
222
223        let dec = collections.decrypt_list(vec![test_collection()]).unwrap();
224
225        assert_eq!(dec[0].name, "Default collection");
226    }
227
228    #[tokio::test]
229    async fn test_decrypt() {
230        let collections = test_collections_client().await;
231
232        let dec = collections.decrypt(test_collection()).unwrap();
233
234        assert_eq!(dec.name, "Default collection");
235    }
236
237    #[tokio::test]
238    async fn test_decrypt_list_with_failures_all_success() {
239        let collections = test_collections_client().await;
240
241        let result = collections.decrypt_list_with_failures(vec![test_collection()]);
242
243        assert_eq!(result.successes.len(), 1);
244        assert!(result.failures.is_empty());
245        assert_eq!(result.successes[0].name, "Default collection");
246    }
247
248    #[tokio::test]
249    async fn test_decrypt_list_with_failures_mixed_results() {
250        let client = test_collections_client().await;
251
252        let valid_collection = test_collection();
253        let mut invalid_collection = test_collection();
254        // No organization key exists in the test account's key store for this id, so
255        // decryption of this single item must fail without affecting the others.
256        invalid_collection.organization_id = OrganizationId::new_v4();
257
258        let collections = vec![valid_collection, invalid_collection.clone()];
259
260        let result = client.decrypt_list_with_failures(collections);
261
262        assert_eq!(result.successes.len(), 1);
263        assert_eq!(result.successes[0].name, "Default collection");
264
265        assert_eq!(result.failures.len(), 1);
266        // The failed item must be returned unchanged (still ciphertext) — decryption
267        // failures must never leak partially-decrypted or plaintext data.
268        assert_eq!(result.failures[0].id, invalid_collection.id);
269        assert_eq!(result.failures[0].name, invalid_collection.name);
270    }
271
272    #[tokio::test]
273    async fn test_decrypt_list_with_failures_empty_list() {
274        let collections = test_collections_client().await;
275
276        let result = collections.decrypt_list_with_failures(vec![]);
277
278        assert!(result.successes.is_empty());
279        assert!(result.failures.is_empty());
280    }
281
282    #[tokio::test]
283    async fn test_encrypt_decrypt_roundtrip() {
284        let collections = test_collections_client().await;
285
286        let view = collections.decrypt(test_collection()).unwrap();
287
288        assert_eq!(view.name, "Default collection");
289
290        // Re-encrypt the decrypted view, then decrypt again
291        let expected_id = view.id;
292        let expected_org_id = view.organization_id;
293        let re_encrypted = collections.encrypt(view).unwrap();
294        let re_decrypted = collections.decrypt(re_encrypted).unwrap();
295
296        assert_eq!(re_decrypted.name, "Default collection");
297        assert_eq!(re_decrypted.id, expected_id);
298        assert_eq!(re_decrypted.organization_id, expected_org_id);
299    }
300
301    #[tokio::test]
302    async fn test_encrypt_list_decrypt_list_roundtrip() {
303        let collections = test_collections_client().await;
304
305        let views = collections.decrypt_list(vec![test_collection()]).unwrap();
306
307        assert_eq!(views.len(), 1);
308        assert_eq!(views[0].name, "Default collection");
309
310        let expected_id = views[0].id;
311        let expected_org_id = views[0].organization_id;
312
313        let re_encrypted = collections.encrypt_list(views).unwrap();
314
315        assert_eq!(re_encrypted.len(), 1);
316
317        let re_decrypted = collections.decrypt_list(re_encrypted).unwrap();
318
319        assert_eq!(re_decrypted.len(), 1);
320        assert_eq!(re_decrypted[0].name, "Default collection");
321        assert_eq!(re_decrypted[0].id, expected_id);
322        assert_eq!(re_decrypted[0].organization_id, expected_org_id);
323    }
324}