Skip to main content

bitwarden_importers/
pipeline.rs

1//! Generic submit pipeline shared by all SDK importers.
2//!
3//! A format-specific parser (see `crate::importers`) produces a [`ParsedImport`]; this module
4//! encrypts it for the destination, builds the API request, submits it, and reports the counts.
5//! Nothing here is format-specific.
6
7use bitwarden_api_api::models::{
8    CipherRequestModel, CollectionWithIdRequestModel, FolderWithIdRequestModel,
9    ImportCiphersRequestModel, ImportOrganizationCiphersRequestModel, Int32Int32KeyValuePair,
10};
11use bitwarden_collections::collection::{Collection, CollectionType, CollectionView};
12use bitwarden_core::{Client, NotAuthenticatedError};
13use bitwarden_crypto::{CompositeEncryptable, IdentifyKey};
14use bitwarden_exporters::{CipherType, ImportingCipher, encrypt_import};
15use bitwarden_vault::{Folder, FolderView};
16use chrono::Utc;
17
18use crate::{CipherTypeCount, ImportError, ImportOptions, ImportSummary};
19
20/// Format-agnostic parse result: the ciphers, the folder paths, and which cipher belongs to which
21/// folder (by index). Every importer parser produces this for the pipeline to submit.
22pub(crate) struct ParsedImport {
23    pub ciphers: Vec<ImportingCipher>,
24    /// Folder paths (e.g. `"Parent/Child"`), index-aligned with [`Self::folder_relationships`].
25    pub folders: Vec<String>,
26    /// `(cipher_index, folder_index)` pairs.
27    pub folder_relationships: Vec<(usize, usize)>,
28}
29
30/// The encrypted request model and counts for an import, ready to submit.
31enum ImportPayload {
32    Individual(ImportCiphersRequestModel),
33    Organization(String, ImportOrganizationCiphersRequestModel),
34}
35
36/// Encrypts a parsed import for the destination (personal vault or organization), submits it to the
37/// import endpoint, and returns the per-type counts.
38pub(crate) async fn submit_import(
39    client: &Client,
40    parsed: ParsedImport,
41    options: ImportOptions,
42) -> Result<ImportSummary, ImportError> {
43    let user_id = client.internal.get_user_id().ok_or(NotAuthenticatedError)?;
44
45    // Encrypt everything in one scope so the KeyStoreContext is dropped before the await.
46    let (payload, summary) = {
47        let key_store = client.internal.get_key_store();
48        let mut ctx = key_store.context();
49
50        let (ciphers, folder_relationships) = filter_restricted(
51            parsed.ciphers,
52            parsed.folder_relationships,
53            &options.restricted_types,
54        );
55        let cipher_count = ciphers.len();
56        let cipher_type_counts = count_by_type(&ciphers);
57
58        let cipher_models = ciphers
59            .into_iter()
60            .map(|c| {
61                let encrypted = encrypt_import(&mut ctx, c, options.organization_id, user_id)?;
62                Ok::<_, ImportError>(CipherRequestModel::from(encrypted))
63            })
64            .collect::<Result<Vec<_>, _>>()?;
65
66        match options.organization_id {
67            // Personal vault: groups become folders, optionally nested under the target folder.
68            None => {
69                let target_folder = options
70                    .target_folder
71                    .as_ref()
72                    .map(|t| (t.id, t.name.as_str()));
73                let folder_views = build_personal_folders(parsed.folders, target_folder);
74                let folder_models = folder_views
75                    .into_iter()
76                    .map(|v| -> Result<FolderWithIdRequestModel, ImportError> {
77                        let folder: Folder = v.encrypt_composite(&mut ctx, v.key_identifier())?;
78                        Ok((&folder).into())
79                    })
80                    .collect::<Result<Vec<_>, _>>()?;
81                let folder_count = folder_models.len();
82
83                let relationships = if target_folder.is_some() {
84                    nest_relationships_under_target(folder_relationships, cipher_count)
85                } else {
86                    folder_relationships
87                };
88
89                let model = ImportCiphersRequestModel {
90                    folders: Some(folder_models),
91                    ciphers: Some(cipher_models),
92                    folder_relationships: Some(to_kvp(&relationships)),
93                };
94                (
95                    ImportPayload::Individual(model),
96                    ImportSummary {
97                        ciphers: cipher_type_counts,
98                        folders: folder_count as u32,
99                        collections: 0,
100                    },
101                )
102            }
103            // Organization vault: groups stay personal folders; ciphers go to the target
104            // collection.
105            Some(organization_id) => {
106                let folder_views = build_personal_folders(parsed.folders, None);
107                let folder_models = folder_views
108                    .into_iter()
109                    .map(|v| -> Result<FolderWithIdRequestModel, ImportError> {
110                        let folder: Folder = v.encrypt_composite(&mut ctx, v.key_identifier())?;
111                        Ok((&folder).into())
112                    })
113                    .collect::<Result<Vec<_>, _>>()?;
114                let folder_count = folder_models.len();
115
116                let (collection_models, collection_relationships) = match options.target_collection
117                {
118                    Some(target) => {
119                        // `hide_passwords`/`read_only`/`manage` are required to build the view
120                        // but aren't carried by `CollectionWithIdRequestModel` — they're not a
121                        // permission decision, just construction placeholders.
122                        let view = CollectionView {
123                            id: Some(target.id),
124                            organization_id,
125                            name: target.name,
126                            external_id: None,
127                            hide_passwords: false,
128                            read_only: false,
129                            manage: true,
130                            r#type: CollectionType::SharedCollection,
131                        };
132                        let collection: Collection =
133                            view.encrypt_composite(&mut ctx, view.key_identifier())?;
134                        let relationships = (0..cipher_count).map(|c| (c, 0)).collect::<Vec<_>>();
135                        // The name is already encrypted; this is just the wire shape.
136                        let model = CollectionWithIdRequestModel {
137                            name: collection.name.to_string(),
138                            external_id: collection.external_id.clone(),
139                            groups: None,
140                            users: None,
141                            id: collection.id.map(Into::into),
142                        };
143                        (vec![model], relationships)
144                    }
145                    // No target: ciphers are submitted unassigned (the server enforces
146                    // permissions).
147                    None => (Vec::new(), Vec::new()),
148                };
149                let collection_count = collection_models.len();
150
151                let model = ImportOrganizationCiphersRequestModel {
152                    collections: Some(collection_models),
153                    ciphers: Some(cipher_models),
154                    collection_relationships: Some(to_kvp(&collection_relationships)),
155                    folders: Some(folder_models),
156                    folder_relationships: Some(to_kvp(&folder_relationships)),
157                };
158                (
159                    ImportPayload::Organization(organization_id.to_string(), model),
160                    ImportSummary {
161                        ciphers: cipher_type_counts,
162                        folders: folder_count as u32,
163                        collections: collection_count as u32,
164                    },
165                )
166            }
167        }
168    };
169
170    let api_client = &client.internal.get_api_configurations().api_client;
171    match payload {
172        ImportPayload::Individual(model) => {
173            api_client
174                .import_ciphers_api()
175                .post_import(Some(model))
176                .await?;
177        }
178        ImportPayload::Organization(organization_id, model) => {
179            api_client
180                .import_ciphers_api()
181                .post_import_organization(Some(&organization_id), Some(model))
182                .await?;
183        }
184    }
185
186    Ok(summary)
187}
188
189/// Maps an exporter [`CipherType`] to the vault [`bitwarden_vault::CipherType`] discriminant.
190fn vault_cipher_type(t: &CipherType) -> bitwarden_vault::CipherType {
191    use bitwarden_vault::CipherType as V;
192    match t {
193        CipherType::Login(_) => V::Login,
194        CipherType::SecureNote(_) => V::SecureNote,
195        CipherType::Card(_) => V::Card,
196        CipherType::Identity(_) => V::Identity,
197        CipherType::SshKey(_) => V::SshKey,
198        CipherType::BankAccount => V::BankAccount,
199        CipherType::Passport => V::Passport,
200        CipherType::DriversLicense => V::DriversLicense,
201    }
202}
203
204/// Counts ciphers by vault type, in a stable display order, omitting types with no entries.
205fn count_by_type(ciphers: &[ImportingCipher]) -> Vec<CipherTypeCount> {
206    use bitwarden_vault::CipherType as V;
207    const ORDER: [V; 8] = [
208        V::Login,
209        V::Card,
210        V::Identity,
211        V::SecureNote,
212        V::SshKey,
213        V::BankAccount,
214        V::Passport,
215        V::DriversLicense,
216    ];
217    ORDER
218        .into_iter()
219        .filter_map(|t| {
220            let count = ciphers
221                .iter()
222                .filter(|c| vault_cipher_type(&c.r#type) == t)
223                .count() as u32;
224            (count > 0).then_some(CipherTypeCount { r#type: t, count })
225        })
226        .collect()
227}
228
229/// Drops ciphers whose type is restricted and re-indexes the folder relationships.
230fn filter_restricted(
231    ciphers: Vec<ImportingCipher>,
232    folder_relationships: Vec<(usize, usize)>,
233    restricted: &[bitwarden_vault::CipherType],
234) -> (Vec<ImportingCipher>, Vec<(usize, usize)>) {
235    if restricted.is_empty() {
236        return (ciphers, folder_relationships);
237    }
238
239    let mut old_to_new = vec![None; ciphers.len()];
240    let mut kept = Vec::with_capacity(ciphers.len());
241    for (old_index, cipher) in ciphers.into_iter().enumerate() {
242        if restricted.contains(&vault_cipher_type(&cipher.r#type)) {
243            continue;
244        }
245        old_to_new[old_index] = Some(kept.len());
246        kept.push(cipher);
247    }
248
249    let relationships = folder_relationships
250        .into_iter()
251        .filter_map(|(cipher, folder)| old_to_new[cipher].map(|new| (new, folder)))
252        .collect();
253
254    (kept, relationships)
255}
256
257/// Builds the folder views to import. When a target folder is given it becomes folder 0 and the
258/// imported groups are nested beneath it as `"{target}/{group}"`.
259fn build_personal_folders(
260    names: Vec<String>,
261    target: Option<(bitwarden_vault::FolderId, &str)>,
262) -> Vec<FolderView> {
263    let revision_date = Utc::now();
264    match target {
265        Some((id, target)) => {
266            let mut folders = Vec::with_capacity(names.len() + 1);
267            folders.push(FolderView {
268                id: Some(id),
269                name: target.to_string(),
270                revision_date,
271            });
272            folders.extend(names.into_iter().map(|name| FolderView {
273                id: None,
274                name: format!("{target}/{name}"),
275                revision_date,
276            }));
277            folders
278        }
279        None => names
280            .into_iter()
281            .map(|name| FolderView {
282                id: None,
283                name,
284                revision_date,
285            })
286            .collect(),
287    }
288}
289
290/// Shifts existing relationships to account for the target folder at index 0 and assigns any
291/// folder-less cipher to it.
292fn nest_relationships_under_target(
293    relationships: Vec<(usize, usize)>,
294    cipher_count: usize,
295) -> Vec<(usize, usize)> {
296    let assigned: std::collections::HashSet<usize> =
297        relationships.iter().map(|(cipher, _)| *cipher).collect();
298    let mut out: Vec<(usize, usize)> = relationships
299        .iter()
300        .map(|(cipher, folder)| (*cipher, folder + 1))
301        .collect();
302    for cipher in 0..cipher_count {
303        if !assigned.contains(&cipher) {
304            out.push((cipher, 0));
305        }
306    }
307    out
308}
309
310fn to_kvp(relationships: &[(usize, usize)]) -> Vec<Int32Int32KeyValuePair> {
311    relationships
312        .iter()
313        .map(|(cipher, folder)| Int32Int32KeyValuePair {
314            key: Some(*cipher as i32),
315            value: Some(*folder as i32),
316        })
317        .collect()
318}
319
320#[cfg(test)]
321mod tests {
322    use bitwarden_exporters::{CipherType, ImportingCipher, Login};
323    use bitwarden_vault::{CipherType as VaultCipherType, FolderId};
324    use chrono::{DateTime, Utc};
325
326    use super::*;
327
328    fn importing(name: &str, r#type: CipherType) -> ImportingCipher {
329        let date: DateTime<Utc> = "2024-01-01T00:00:00Z".parse().unwrap();
330        ImportingCipher {
331            folder_id: None,
332            name: name.to_string(),
333            notes: None,
334            r#type,
335            favorite: false,
336            reprompt: 0,
337            fields: vec![],
338            revision_date: date,
339            creation_date: date,
340            deleted_date: None,
341        }
342    }
343
344    #[test]
345    fn filter_restricted_drops_matching_and_reindexes_relationships() {
346        let ciphers = vec![
347            importing("a", CipherType::Passport),
348            importing("b", CipherType::BankAccount),
349            importing("c", CipherType::Passport),
350        ];
351        // a->folder0, b->folder1, c->folder0
352        let relationships = vec![(0, 0), (1, 1), (2, 0)];
353
354        let (kept, relationships) =
355            filter_restricted(ciphers, relationships, &[VaultCipherType::BankAccount]);
356
357        assert_eq!(kept.len(), 2);
358        assert_eq!(kept[0].name, "a");
359        assert_eq!(kept[1].name, "c");
360        // b's relationship is dropped; c is reindexed from cipher 2 to cipher 1.
361        assert_eq!(relationships, vec![(0, 0), (1, 0)]);
362    }
363
364    #[test]
365    fn filter_restricted_empty_list_is_noop() {
366        let ciphers = vec![importing("a", CipherType::Passport)];
367        let relationships = vec![(0, 0)];
368        let (kept, out) = filter_restricted(ciphers, relationships.clone(), &[]);
369        assert_eq!(kept.len(), 1);
370        assert_eq!(out, relationships);
371    }
372
373    #[test]
374    fn build_personal_folders_without_target_preserves_names() {
375        let folders = build_personal_folders(vec!["A".into(), "A/B".into()], None);
376        assert_eq!(folders.len(), 2);
377        assert!(folders.iter().all(|f| f.id.is_none()));
378        assert_eq!(folders[0].name, "A");
379        assert_eq!(folders[1].name, "A/B");
380    }
381
382    #[test]
383    fn build_personal_folders_with_target_nests_under_it() {
384        let target = FolderId::new(uuid::Uuid::new_v4());
385        let folders = build_personal_folders(vec!["A".into()], Some((target, "Target")));
386        assert_eq!(folders.len(), 2);
387        assert_eq!(folders[0].id, Some(target));
388        assert_eq!(folders[0].name, "Target");
389        assert_eq!(folders[1].id, None);
390        assert_eq!(folders[1].name, "Target/A");
391    }
392
393    #[test]
394    fn nest_relationships_shifts_existing_and_assigns_folderless() {
395        // cipher 0 is in a group; cipher 1 has no folder.
396        let out = nest_relationships_under_target(vec![(0, 0)], 2);
397        assert!(out.contains(&(0, 1)));
398        assert!(out.contains(&(1, 0)));
399        assert_eq!(out.len(), 2);
400    }
401
402    #[test]
403    fn count_by_type_groups_in_stable_order_and_omits_zero() {
404        let login = CipherType::Login(Box::new(Login {
405            username: None,
406            password: None,
407            login_uris: vec![],
408            totp: None,
409            fido2_credentials: None,
410        }));
411        let ciphers = vec![
412            importing("a", CipherType::Passport),
413            importing("b", login),
414            importing("c", CipherType::Passport),
415        ];
416        let counts = count_by_type(&ciphers);
417        // Login is ordered before Passport; Card/etc. with zero entries are omitted.
418        assert_eq!(counts.len(), 2);
419        assert_eq!(counts[0].r#type, VaultCipherType::Login);
420        assert_eq!(counts[0].count, 1);
421        assert_eq!(counts[1].r#type, VaultCipherType::Passport);
422        assert_eq!(counts[1].count, 2);
423    }
424
425    #[test]
426    fn to_kvp_maps_indices() {
427        let kvp = to_kvp(&[(0, 2), (3, 1)]);
428        assert_eq!(kvp[0].key, Some(0));
429        assert_eq!(kvp[0].value, Some(2));
430        assert_eq!(kvp[1].key, Some(3));
431        assert_eq!(kvp[1].value, Some(1));
432    }
433
434    /// Covers the encrypt boundary: a parsed cipher's name comes out encrypted (not the plaintext
435    /// title) when run through a real key store.
436    #[tokio::test]
437    async fn encrypt_import_encrypts_the_cipher_name() {
438        use bitwarden_core::{Client, client::test_accounts::test_bitwarden_com_account};
439        use bitwarden_exporters::encrypt_import;
440
441        let client = Client::init_test_account(test_bitwarden_com_account()).await;
442        let key_store = client.internal.get_key_store();
443        let mut ctx = key_store.context();
444
445        let login = CipherType::Login(Box::new(Login {
446            username: None,
447            password: None,
448            login_uris: vec![],
449            totp: None,
450            fido2_credentials: None,
451        }));
452        let user_id = client.internal.get_user_id().unwrap();
453        let encrypted =
454            encrypt_import(&mut ctx, importing("GitHub", login), None, user_id).unwrap();
455
456        assert_eq!(encrypted.encrypted_for, user_id);
457        assert_ne!(encrypted.cipher.name.unwrap().to_string(), "GitHub");
458    }
459}