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, CollectionId, CollectionType, CollectionView};
12use bitwarden_core::{Client, NotAuthenticatedError, OrganizationId};
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, ImportTargetCollection};
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    /// The ciphers to submit, index-aligned with [`Self::folder_relationships`].
24    pub ciphers: Vec<ImportingCipher>,
25    /// Folder paths (e.g. `"Parent/Child"`), index-aligned with [`Self::folder_relationships`].
26    pub folders: Vec<String>,
27    /// `(cipher_index, folder_index)` pairs.
28    pub folder_relationships: Vec<(usize, usize)>,
29}
30
31/// The encrypted request model and counts for an import, ready to submit.
32enum ImportPayload {
33    Individual(ImportCiphersRequestModel),
34    Organization(String, ImportOrganizationCiphersRequestModel),
35}
36
37/// Encrypts a parsed import for the destination (personal vault or organization), submits it to the
38/// import endpoint, and returns the per-type counts.
39pub(crate) async fn submit_import(
40    client: &Client,
41    parsed: ParsedImport,
42    options: ImportOptions,
43) -> Result<ImportSummary, ImportError> {
44    let user_id = client.internal.get_user_id().ok_or(NotAuthenticatedError)?;
45
46    // Encrypt everything in one scope so the KeyStoreContext is dropped before the await.
47    let (payload, summary) = {
48        let key_store = client.internal.get_key_store();
49        let mut ctx = key_store.context();
50
51        let (ciphers, folder_relationships) = filter_restricted(
52            parsed.ciphers,
53            parsed.folder_relationships,
54            &options.restricted_types,
55        );
56        let cipher_count = ciphers.len();
57        let cipher_type_counts = count_by_type(&ciphers);
58
59        let cipher_models = ciphers
60            .into_iter()
61            .map(|c| {
62                let encrypted = encrypt_import(&mut ctx, c, options.organization_id, user_id)?;
63                Ok::<_, ImportError>(CipherRequestModel::from(encrypted))
64            })
65            .collect::<Result<Vec<_>, _>>()?;
66
67        match options.organization_id {
68            // Personal vault: groups become folders, optionally nested under the target folder.
69            None => {
70                let target_folder = options
71                    .target_folder
72                    .as_ref()
73                    .map(|t| (t.id, t.name.as_str()));
74                let (folder_views, folder_count) =
75                    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
84                let relationships = if target_folder.is_some() {
85                    nest_relationships_under_target(folder_relationships, cipher_count)
86                } else {
87                    folder_relationships
88                };
89
90                let model = ImportCiphersRequestModel {
91                    folders: Some(folder_models),
92                    ciphers: Some(cipher_models),
93                    folder_relationships: Some(to_kvp(&relationships)),
94                };
95                (
96                    ImportPayload::Individual(model),
97                    ImportSummary {
98                        ciphers: cipher_type_counts,
99                        folders: folder_count as u32,
100                        collections: 0,
101                    },
102                )
103            }
104            // Organization vault: groups become collections if "My Items" not enabled
105            Some(organization_id) => {
106                let is_my_items = targets_my_items(options.target_collection.as_ref());
107
108                let (
109                    folder_views,
110                    folder_count,
111                    final_folder_relationships,
112                    collection_views,
113                    source_collection_count,
114                    final_collection_relationships,
115                ) = if is_my_items {
116                    let (folder_views, folder_count) = build_personal_folders(parsed.folders, None);
117                    let target = options
118                        .target_collection
119                        .as_ref()
120                        .expect("is_my_items implies target_collection is Some");
121                    let my_items = CollectionView {
122                        id: Some(target.id),
123                        organization_id,
124                        name: target.name.clone(),
125                        external_id: None,
126                        hide_passwords: false,
127                        read_only: false,
128                        manage: true,
129                        r#type: CollectionType::DefaultUserCollection,
130                        has_enabled_access_rule: None,
131                    };
132                    let collection_relationships =
133                        (0..cipher_count).map(|c| (c, 0)).collect::<Vec<_>>();
134                    (
135                        folder_views,
136                        folder_count,
137                        folder_relationships,
138                        vec![my_items],
139                        0,
140                        collection_relationships,
141                    )
142                } else {
143                    let target = options
144                        .target_collection
145                        .as_ref()
146                        .map(|t| (t.id, t.name.as_str()));
147                    let (collection_views, source_collection_count) =
148                        build_org_collections(parsed.folders, organization_id, target);
149                    let collection_relationships = if target.is_some() {
150                        nest_relationships_under_target(folder_relationships, cipher_count)
151                    } else {
152                        // No target: brand-new top-level collections,
153                        folder_relationships
154                    };
155                    (
156                        Vec::new(),
157                        0,
158                        Vec::new(),
159                        collection_views,
160                        source_collection_count,
161                        collection_relationships,
162                    )
163                };
164
165                let folder_models = folder_views
166                    .into_iter()
167                    .map(|v| -> Result<FolderWithIdRequestModel, ImportError> {
168                        let folder: Folder = v.encrypt_composite(&mut ctx, v.key_identifier())?;
169                        Ok((&folder).into())
170                    })
171                    .collect::<Result<Vec<_>, _>>()?;
172
173                let collection_models = collection_views
174                    .into_iter()
175                    .map(|v| -> Result<CollectionWithIdRequestModel, ImportError> {
176                        let collection: Collection =
177                            v.encrypt_composite(&mut ctx, v.key_identifier())?;
178                        Ok(CollectionWithIdRequestModel {
179                            name: collection.name.to_string(),
180                            external_id: collection.external_id.clone(),
181                            groups: None,
182                            users: None,
183                            id: collection.id.map(Into::into),
184                        })
185                    })
186                    .collect::<Result<Vec<_>, _>>()?;
187
188                let model = ImportOrganizationCiphersRequestModel {
189                    collections: Some(collection_models),
190                    ciphers: Some(cipher_models),
191                    collection_relationships: Some(to_kvp(&final_collection_relationships)),
192                    folders: Some(folder_models),
193                    folder_relationships: Some(to_kvp(&final_folder_relationships)),
194                };
195                (
196                    ImportPayload::Organization(organization_id.to_string(), model),
197                    ImportSummary {
198                        ciphers: cipher_type_counts,
199                        folders: folder_count as u32,
200                        collections: source_collection_count as u32,
201                    },
202                )
203            }
204        }
205    };
206
207    let api_client = &client.internal.get_api_configurations().api_client;
208    match payload {
209        ImportPayload::Individual(model) => {
210            api_client
211                .import_ciphers_api()
212                .post_import(Some(model))
213                .await?;
214        }
215        ImportPayload::Organization(organization_id, model) => {
216            api_client
217                .import_ciphers_api()
218                .post_import_organization(Some(&organization_id), Some(model))
219                .await?;
220        }
221    }
222
223    Ok(summary)
224}
225
226/// Maps an exporter [`CipherType`] to the vault [`bitwarden_vault::CipherType`] discriminant.
227fn vault_cipher_type(t: &CipherType) -> bitwarden_vault::CipherType {
228    use bitwarden_vault::CipherType as V;
229    match t {
230        CipherType::Login(_) => V::Login,
231        CipherType::SecureNote(_) => V::SecureNote,
232        CipherType::Card(_) => V::Card,
233        CipherType::Identity(_) => V::Identity,
234        CipherType::SshKey(_) => V::SshKey,
235        CipherType::BankAccount => V::BankAccount,
236        CipherType::Passport => V::Passport,
237        CipherType::DriversLicense => V::DriversLicense,
238    }
239}
240
241/// Counts ciphers by vault type, in a stable display order, omitting types with no entries.
242fn count_by_type(ciphers: &[ImportingCipher]) -> Vec<CipherTypeCount> {
243    use bitwarden_vault::CipherType as V;
244    const ORDER: [V; 8] = [
245        V::Login,
246        V::Card,
247        V::Identity,
248        V::SecureNote,
249        V::SshKey,
250        V::BankAccount,
251        V::Passport,
252        V::DriversLicense,
253    ];
254    ORDER
255        .into_iter()
256        .filter_map(|t| {
257            let count = ciphers
258                .iter()
259                .filter(|c| vault_cipher_type(&c.r#type) == t)
260                .count() as u32;
261            (count > 0).then_some(CipherTypeCount { r#type: t, count })
262        })
263        .collect()
264}
265
266/// Drops ciphers whose type is restricted and re-indexes the folder relationships.
267fn filter_restricted(
268    ciphers: Vec<ImportingCipher>,
269    folder_relationships: Vec<(usize, usize)>,
270    restricted: &[bitwarden_vault::CipherType],
271) -> (Vec<ImportingCipher>, Vec<(usize, usize)>) {
272    if restricted.is_empty() {
273        return (ciphers, folder_relationships);
274    }
275
276    let mut old_to_new = vec![None; ciphers.len()];
277    let mut kept = Vec::with_capacity(ciphers.len());
278    for (old_index, cipher) in ciphers.into_iter().enumerate() {
279        if restricted.contains(&vault_cipher_type(&cipher.r#type)) {
280            continue;
281        }
282        old_to_new[old_index] = Some(kept.len());
283        kept.push(cipher);
284    }
285
286    let relationships = folder_relationships
287        .into_iter()
288        .filter_map(|(cipher, folder)| old_to_new[cipher].map(|new| (new, folder)))
289        .collect();
290
291    (kept, relationships)
292}
293
294/// Builds the folder views to import, plus the count of folders actually parsed from the source
295/// (excluding the injected target). When a target folder is given it becomes folder 0 and the
296/// imported groups are nested beneath it as `"{target}/{group}"` — the returned count is always
297/// `names.len()`, regardless of whether a target was given, so callers can't accidentally report
298/// the merged list's length (which includes the pre-existing target) as an import count.
299fn build_personal_folders(
300    names: Vec<String>,
301    target: Option<(bitwarden_vault::FolderId, &str)>,
302) -> (Vec<FolderView>, usize) {
303    let count = names.len();
304    let revision_date = Utc::now();
305    let folders = match target {
306        Some((id, target)) => {
307            let mut folders = Vec::with_capacity(names.len() + 1);
308            folders.push(FolderView {
309                id: Some(id),
310                name: target.to_string(),
311                revision_date,
312            });
313            folders.extend(names.into_iter().map(|name| FolderView {
314                id: None,
315                name: format!("{target}/{name}"),
316                revision_date,
317            }));
318            folders
319        }
320        None => names
321            .into_iter()
322            .map(|name| FolderView {
323                id: None,
324                name,
325                revision_date,
326            })
327            .collect(),
328    };
329    (folders, count)
330}
331
332/// True when the org-import target is the user's own "My items" collection
333fn targets_my_items(target_collection: Option<&ImportTargetCollection>) -> bool {
334    matches!(
335        target_collection.map(|t| &t.r#type),
336        Some(CollectionType::DefaultUserCollection)
337    )
338}
339
340/// Builds the collection views for an org import, plus the count of collections actually parsed
341/// from the source (excluding the injected target) — mirrors `build_personal_folders`, but for
342/// collections. When a target is given it becomes collection 0 and parsed groups are nested
343/// beneath it as `"{target}/{group}"`; with no target, every group becomes a brand-new
344/// top-level collection (submitted with no id, same as `build_personal_folders` submits new
345/// folders with no id — the server creates on an unmatched/empty id).
346fn build_org_collections(
347    names: Vec<String>,
348    organization_id: OrganizationId,
349    target: Option<(CollectionId, &str)>,
350) -> (Vec<CollectionView>, usize) {
351    let count = names.len();
352    let new_view = |id: Option<CollectionId>, name: String| CollectionView {
353        id,
354        organization_id,
355        name,
356        external_id: None,
357        hide_passwords: false,
358        read_only: false,
359        manage: true,
360        r#type: CollectionType::SharedCollection,
361        has_enabled_access_rule: None,
362    };
363    let collections = match target {
364        Some((id, target_name)) => {
365            let mut collections = Vec::with_capacity(names.len() + 1);
366            collections.push(new_view(Some(id), target_name.to_string()));
367            collections.extend(
368                names
369                    .into_iter()
370                    .map(|name| new_view(None, format!("{target_name}/{name}"))),
371            );
372            collections
373        }
374        None => names.into_iter().map(|name| new_view(None, name)).collect(),
375    };
376    (collections, count)
377}
378
379/// Shifts existing relationships to account for the target folder at index 0 and assigns any
380/// folder-less cipher to it.
381fn nest_relationships_under_target(
382    relationships: Vec<(usize, usize)>,
383    cipher_count: usize,
384) -> Vec<(usize, usize)> {
385    let assigned: std::collections::HashSet<usize> =
386        relationships.iter().map(|(cipher, _)| *cipher).collect();
387    let mut out: Vec<(usize, usize)> = relationships
388        .iter()
389        .map(|(cipher, folder)| (*cipher, folder + 1))
390        .collect();
391    for cipher in 0..cipher_count {
392        if !assigned.contains(&cipher) {
393            out.push((cipher, 0));
394        }
395    }
396    out
397}
398
399fn to_kvp(relationships: &[(usize, usize)]) -> Vec<Int32Int32KeyValuePair> {
400    relationships
401        .iter()
402        .map(|(cipher, folder)| Int32Int32KeyValuePair {
403            key: Some(*cipher as i32),
404            value: Some(*folder as i32),
405        })
406        .collect()
407}
408
409#[cfg(test)]
410mod tests {
411    use bitwarden_exporters::{CipherType, ImportingCipher, Login};
412    use bitwarden_vault::{CipherType as VaultCipherType, FolderId};
413    use chrono::{DateTime, Utc};
414
415    use super::*;
416
417    fn importing(name: &str, r#type: CipherType) -> ImportingCipher {
418        let date: DateTime<Utc> = "2024-01-01T00:00:00Z".parse().unwrap();
419        ImportingCipher {
420            folder_id: None,
421            name: name.to_string(),
422            notes: None,
423            r#type,
424            favorite: false,
425            reprompt: 0,
426            fields: vec![],
427            revision_date: date,
428            creation_date: date,
429            deleted_date: None,
430        }
431    }
432
433    #[test]
434    fn filter_restricted_drops_matching_and_reindexes_relationships() {
435        let ciphers = vec![
436            importing("a", CipherType::Passport),
437            importing("b", CipherType::BankAccount),
438            importing("c", CipherType::Passport),
439        ];
440        // a->folder0, b->folder1, c->folder0
441        let relationships = vec![(0, 0), (1, 1), (2, 0)];
442
443        let (kept, relationships) =
444            filter_restricted(ciphers, relationships, &[VaultCipherType::BankAccount]);
445
446        assert_eq!(kept.len(), 2);
447        assert_eq!(kept[0].name, "a");
448        assert_eq!(kept[1].name, "c");
449        // b's relationship is dropped; c is reindexed from cipher 2 to cipher 1.
450        assert_eq!(relationships, vec![(0, 0), (1, 0)]);
451    }
452
453    #[test]
454    fn filter_restricted_empty_list_is_noop() {
455        let ciphers = vec![importing("a", CipherType::Passport)];
456        let relationships = vec![(0, 0)];
457        let (kept, out) = filter_restricted(ciphers, relationships.clone(), &[]);
458        assert_eq!(kept.len(), 1);
459        assert_eq!(out, relationships);
460    }
461
462    #[test]
463    fn build_personal_folders_without_target_preserves_names() {
464        let (folders, count) = build_personal_folders(vec!["A".into(), "A/B".into()], None);
465        assert_eq!(count, 2);
466        assert_eq!(folders.len(), 2);
467        assert!(folders.iter().all(|f| f.id.is_none()));
468        assert_eq!(folders[0].name, "A");
469        assert_eq!(folders[1].name, "A/B");
470    }
471
472    #[test]
473    fn build_personal_folders_with_target_nests_under_it() {
474        let target = FolderId::new(uuid::Uuid::new_v4());
475        let (folders, count) = build_personal_folders(vec!["A".into()], Some((target, "Target")));
476        assert_eq!(count, 1);
477        assert_eq!(folders.len(), 2);
478        assert_eq!(folders[0].id, Some(target));
479        assert_eq!(folders[0].name, "Target");
480        assert_eq!(folders[1].id, None);
481        assert_eq!(folders[1].name, "Target/A");
482    }
483
484    /// Guards the `submit_import` fix directly: this is the exact call `submit_import` makes to
485    /// get `ImportSummary.folders`, so this pins the real count, not a proxy for it. Reverting the
486    /// fix — reporting `folders.len()` instead of the returned count — would fail this test, since
487    /// the merged list still has the target at index 0 even when nothing was parsed.
488    #[test]
489    fn build_personal_folders_count_excludes_injected_target_when_nothing_was_parsed() {
490        let target = FolderId::new(uuid::Uuid::new_v4());
491        let (folders, count) = build_personal_folders(Vec::new(), Some((target, "Target")));
492
493        assert_eq!(folders.len(), 1);
494        assert_eq!(count, 0);
495    }
496
497    #[test]
498    fn targets_my_items_true_only_for_default_user_collection() {
499        assert!(!targets_my_items(None));
500
501        let shared = ImportTargetCollection {
502            id: CollectionId::new(uuid::Uuid::new_v4()),
503            name: "Engineering".into(),
504            r#type: CollectionType::SharedCollection,
505        };
506        assert!(!targets_my_items(Some(&shared)));
507
508        let my_items = ImportTargetCollection {
509            id: CollectionId::new(uuid::Uuid::new_v4()),
510            name: "My items".into(),
511            r#type: CollectionType::DefaultUserCollection,
512        };
513        assert!(targets_my_items(Some(&my_items)));
514    }
515
516    #[test]
517    fn build_org_collections_without_target_creates_new_top_level_collections() {
518        let org_id = OrganizationId::new(uuid::Uuid::new_v4());
519        let (collections, count) =
520            build_org_collections(vec!["A".into(), "A/B".into()], org_id, None);
521
522        assert_eq!(count, 2);
523        assert_eq!(collections.len(), 2);
524        assert!(collections.iter().all(|c| c.id.is_none()));
525        assert!(collections.iter().all(|c| c.organization_id == org_id));
526        assert_eq!(collections[0].name, "A");
527        assert_eq!(collections[1].name, "A/B");
528    }
529
530    #[test]
531    fn build_org_collections_with_target_nests_under_it() {
532        let org_id = OrganizationId::new(uuid::Uuid::new_v4());
533        let target = CollectionId::new(uuid::Uuid::new_v4());
534        let (collections, count) =
535            build_org_collections(vec!["A".into()], org_id, Some((target, "Target")));
536
537        assert_eq!(count, 1);
538        assert_eq!(collections.len(), 2);
539        assert_eq!(collections[0].id, Some(target));
540        assert_eq!(collections[0].name, "Target");
541        assert_eq!(collections[1].id, None);
542        assert_eq!(collections[1].name, "Target/A");
543    }
544
545    #[test]
546    fn build_org_collections_count_excludes_injected_target_when_nothing_was_parsed() {
547        let org_id = OrganizationId::new(uuid::Uuid::new_v4());
548        let target = CollectionId::new(uuid::Uuid::new_v4());
549        let (collections, count) =
550            build_org_collections(Vec::new(), org_id, Some((target, "Target")));
551
552        assert_eq!(collections.len(), 1);
553        assert_eq!(count, 0);
554    }
555
556    #[test]
557    fn nest_relationships_shifts_existing_and_assigns_folderless() {
558        // cipher 0 is in a group; cipher 1 has no folder.
559        let out = nest_relationships_under_target(vec![(0, 0)], 2);
560        assert!(out.contains(&(0, 1)));
561        assert!(out.contains(&(1, 0)));
562        assert_eq!(out.len(), 2);
563    }
564
565    #[test]
566    fn count_by_type_groups_in_stable_order_and_omits_zero() {
567        let login = CipherType::Login(Box::new(Login {
568            username: None,
569            password: None,
570            login_uris: vec![],
571            totp: None,
572            fido2_credentials: None,
573        }));
574        let ciphers = vec![
575            importing("a", CipherType::Passport),
576            importing("b", login),
577            importing("c", CipherType::Passport),
578        ];
579        let counts = count_by_type(&ciphers);
580        // Login is ordered before Passport; Card/etc. with zero entries are omitted.
581        assert_eq!(counts.len(), 2);
582        assert_eq!(counts[0].r#type, VaultCipherType::Login);
583        assert_eq!(counts[0].count, 1);
584        assert_eq!(counts[1].r#type, VaultCipherType::Passport);
585        assert_eq!(counts[1].count, 2);
586    }
587
588    #[test]
589    fn to_kvp_maps_indices() {
590        let kvp = to_kvp(&[(0, 2), (3, 1)]);
591        assert_eq!(kvp[0].key, Some(0));
592        assert_eq!(kvp[0].value, Some(2));
593        assert_eq!(kvp[1].key, Some(3));
594        assert_eq!(kvp[1].value, Some(1));
595    }
596
597    /// Covers the encrypt boundary: a parsed cipher's name comes out encrypted (not the plaintext
598    /// title) when run through a real key store.
599    #[tokio::test]
600    async fn encrypt_import_encrypts_the_cipher_name() {
601        use bitwarden_core::{Client, client::test_accounts::test_bitwarden_com_account};
602        use bitwarden_exporters::encrypt_import;
603
604        let client = Client::init_test_account(test_bitwarden_com_account()).await;
605        let key_store = client.internal.get_key_store();
606        let mut ctx = key_store.context();
607
608        let login = CipherType::Login(Box::new(Login {
609            username: None,
610            password: None,
611            login_uris: vec![],
612            totp: None,
613            fido2_credentials: None,
614        }));
615        let user_id = client.internal.get_user_id().unwrap();
616        let encrypted =
617            encrypt_import(&mut ctx, importing("GitHub", login), None, user_id).unwrap();
618
619        assert_eq!(encrypted.encrypted_for, user_id);
620        // Imported ciphers carry their own cipher key
621        assert!(encrypted.cipher.key.is_some());
622        assert_ne!(encrypted.cipher.name.unwrap().to_string(), "GitHub");
623    }
624}