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