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.
22// `pub` only so the `test-utils` re-export can reach it for the out-of-tree CLI.
23// TODO: Back to `pub(crate)` once the re-export goes.
24pub struct ParsedImport {
25    /// The ciphers to submit, index-aligned with [`Self::folder_relationships`].
26    pub ciphers: Vec<ImportingCipher>,
27    /// Folder paths (e.g. `"Parent/Child"`), index-aligned with [`Self::folder_relationships`].
28    pub folders: Vec<String>,
29    /// `(cipher_index, folder_index)` pairs.
30    pub folder_relationships: Vec<(usize, usize)>,
31}
32
33/// The encrypted request model and counts for an import, ready to submit.
34enum ImportPayload {
35    Individual(ImportCiphersRequestModel),
36    Organization(String, ImportOrganizationCiphersRequestModel),
37}
38
39/// Encrypts a parsed import for the destination (personal vault or organization), submits it to the
40/// import endpoint, and returns the per-type counts.
41pub(crate) async fn submit_import(
42    client: &Client,
43    parsed: ParsedImport,
44    options: ImportOptions,
45) -> Result<ImportSummary, ImportError> {
46    let user_id = client.internal.get_user_id().ok_or(NotAuthenticatedError)?;
47
48    // Encrypt everything in one scope so the KeyStoreContext is dropped before the await.
49    let (payload, summary) = {
50        let key_store = client.internal.get_key_store();
51        let mut ctx = key_store.context();
52
53        let (ciphers, folder_relationships) = filter_restricted(
54            parsed.ciphers,
55            parsed.folder_relationships,
56            &options.restricted_types,
57        );
58        let cipher_count = ciphers.len();
59        let cipher_type_counts = count_by_type(&ciphers);
60
61        let cipher_models = ciphers
62            .into_iter()
63            .map(|c| {
64                let encrypted = encrypt_import(&mut ctx, c, options.organization_id, user_id)?;
65                Ok::<_, ImportError>(CipherRequestModel::from(encrypted))
66            })
67            .collect::<Result<Vec<_>, _>>()?;
68
69        match options.organization_id {
70            // Personal vault: groups become folders, optionally nested under the target folder.
71            None => {
72                let target_folder = options
73                    .target_folder
74                    .as_ref()
75                    .map(|t| (t.id, t.name.as_str()));
76                let (folder_views, folder_count) =
77                    build_personal_folders(parsed.folders, target_folder);
78                let folder_models = folder_views
79                    .into_iter()
80                    .map(|v| -> Result<FolderWithIdRequestModel, ImportError> {
81                        let folder: Folder = v.encrypt_composite(&mut ctx, v.key_identifier())?;
82                        Ok((&folder).into())
83                    })
84                    .collect::<Result<Vec<_>, _>>()?;
85
86                let relationships = if target_folder.is_some() {
87                    nest_relationships_under_target(folder_relationships, cipher_count)
88                } else {
89                    folder_relationships
90                };
91
92                let model = ImportCiphersRequestModel {
93                    folders: Some(folder_models),
94                    ciphers: Some(cipher_models),
95                    folder_relationships: Some(to_kvp(&relationships)),
96                };
97                (
98                    ImportPayload::Individual(model),
99                    ImportSummary {
100                        ciphers: cipher_type_counts,
101                        folders: folder_count as u32,
102                        collections: 0,
103                    },
104                )
105            }
106            // Organization vault: groups become collections if "My Items" not enabled
107            Some(organization_id) => {
108                let is_my_items = targets_my_items(options.target_collection.as_ref());
109
110                let (
111                    folder_views,
112                    folder_count,
113                    final_folder_relationships,
114                    collection_views,
115                    source_collection_count,
116                    final_collection_relationships,
117                ) = if is_my_items {
118                    let (folder_views, folder_count) = build_personal_folders(parsed.folders, None);
119                    let target = options
120                        .target_collection
121                        .as_ref()
122                        .expect("is_my_items implies target_collection is Some");
123                    let my_items = CollectionView {
124                        id: Some(target.id),
125                        organization_id,
126                        name: target.name.clone(),
127                        external_id: None,
128                        hide_passwords: false,
129                        read_only: false,
130                        manage: true,
131                        r#type: CollectionType::DefaultUserCollection,
132                    };
133                    let collection_relationships =
134                        (0..cipher_count).map(|c| (c, 0)).collect::<Vec<_>>();
135                    (
136                        folder_views,
137                        folder_count,
138                        folder_relationships,
139                        vec![my_items],
140                        0,
141                        collection_relationships,
142                    )
143                } else {
144                    let target = options
145                        .target_collection
146                        .as_ref()
147                        .map(|t| (t.id, t.name.as_str()));
148                    let (collection_views, source_collection_count) =
149                        build_org_collections(parsed.folders, organization_id, target);
150                    let collection_relationships = if target.is_some() {
151                        nest_relationships_under_target(folder_relationships, cipher_count)
152                    } else {
153                        // No target: brand-new top-level collections,
154                        folder_relationships
155                    };
156                    (
157                        Vec::new(),
158                        0,
159                        Vec::new(),
160                        collection_views,
161                        source_collection_count,
162                        collection_relationships,
163                    )
164                };
165
166                let folder_models = folder_views
167                    .into_iter()
168                    .map(|v| -> Result<FolderWithIdRequestModel, ImportError> {
169                        let folder: Folder = v.encrypt_composite(&mut ctx, v.key_identifier())?;
170                        Ok((&folder).into())
171                    })
172                    .collect::<Result<Vec<_>, _>>()?;
173
174                let collection_models = collection_views
175                    .into_iter()
176                    .map(|v| -> Result<CollectionWithIdRequestModel, ImportError> {
177                        let collection: Collection =
178                            v.encrypt_composite(&mut ctx, v.key_identifier())?;
179                        Ok(CollectionWithIdRequestModel {
180                            name: collection.name.to_string(),
181                            external_id: collection.external_id.clone(),
182                            groups: None,
183                            users: None,
184                            id: collection.id.map(Into::into),
185                        })
186                    })
187                    .collect::<Result<Vec<_>, _>>()?;
188
189                let model = ImportOrganizationCiphersRequestModel {
190                    collections: Some(collection_models),
191                    ciphers: Some(cipher_models),
192                    collection_relationships: Some(to_kvp(&final_collection_relationships)),
193                    folders: Some(folder_models),
194                    folder_relationships: Some(to_kvp(&final_folder_relationships)),
195                };
196                (
197                    ImportPayload::Organization(organization_id.to_string(), model),
198                    ImportSummary {
199                        ciphers: cipher_type_counts,
200                        folders: folder_count as u32,
201                        collections: source_collection_count as u32,
202                    },
203                )
204            }
205        }
206    };
207
208    let api_client = &client.internal.get_api_configurations().api_client;
209    match payload {
210        ImportPayload::Individual(model) => {
211            api_client
212                .import_ciphers_api()
213                .post_import(Some(model))
214                .await?;
215        }
216        ImportPayload::Organization(organization_id, model) => {
217            api_client
218                .import_ciphers_api()
219                .post_import_organization(Some(&organization_id), Some(model))
220                .await?;
221        }
222    }
223
224    Ok(summary)
225}
226
227/// Maps an exporter [`CipherType`] to the vault [`bitwarden_vault::CipherType`] discriminant.
228fn vault_cipher_type(t: &CipherType) -> bitwarden_vault::CipherType {
229    use bitwarden_vault::CipherType as V;
230    match t {
231        CipherType::Login(_) => V::Login,
232        CipherType::SecureNote(_) => V::SecureNote,
233        CipherType::Card(_) => V::Card,
234        CipherType::Identity(_) => V::Identity,
235        CipherType::SshKey(_) => V::SshKey,
236        CipherType::BankAccount => V::BankAccount,
237        CipherType::Passport => V::Passport,
238        CipherType::DriversLicense => V::DriversLicense,
239    }
240}
241
242/// Counts ciphers by vault type, in a stable display order, omitting types with no entries.
243fn count_by_type(ciphers: &[ImportingCipher]) -> Vec<CipherTypeCount> {
244    use bitwarden_vault::CipherType as V;
245    const ORDER: [V; 8] = [
246        V::Login,
247        V::Card,
248        V::Identity,
249        V::SecureNote,
250        V::SshKey,
251        V::BankAccount,
252        V::Passport,
253        V::DriversLicense,
254    ];
255    ORDER
256        .into_iter()
257        .filter_map(|t| {
258            let count = ciphers
259                .iter()
260                .filter(|c| vault_cipher_type(&c.r#type) == t)
261                .count() as u32;
262            (count > 0).then_some(CipherTypeCount { r#type: t, count })
263        })
264        .collect()
265}
266
267/// Drops ciphers whose type is restricted and re-indexes the folder relationships.
268fn filter_restricted(
269    ciphers: Vec<ImportingCipher>,
270    folder_relationships: Vec<(usize, usize)>,
271    restricted: &[bitwarden_vault::CipherType],
272) -> (Vec<ImportingCipher>, Vec<(usize, usize)>) {
273    if restricted.is_empty() {
274        return (ciphers, folder_relationships);
275    }
276
277    let mut old_to_new = vec![None; ciphers.len()];
278    let mut kept = Vec::with_capacity(ciphers.len());
279    for (old_index, cipher) in ciphers.into_iter().enumerate() {
280        if restricted.contains(&vault_cipher_type(&cipher.r#type)) {
281            continue;
282        }
283        old_to_new[old_index] = Some(kept.len());
284        kept.push(cipher);
285    }
286
287    let relationships = folder_relationships
288        .into_iter()
289        .filter_map(|(cipher, folder)| old_to_new[cipher].map(|new| (new, folder)))
290        .collect();
291
292    (kept, relationships)
293}
294
295/// Builds the folder views to import, plus the count of folders actually parsed from the source
296/// (excluding the injected target). When a target folder is given it becomes folder 0 and the
297/// imported groups are nested beneath it as `"{target}/{group}"` — the returned count is always
298/// `names.len()`, regardless of whether a target was given, so callers can't accidentally report
299/// the merged list's length (which includes the pre-existing target) as an import count.
300fn build_personal_folders(
301    names: Vec<String>,
302    target: Option<(bitwarden_vault::FolderId, &str)>,
303) -> (Vec<FolderView>, usize) {
304    let count = names.len();
305    let revision_date = Utc::now();
306    let folders = match target {
307        Some((id, target)) => {
308            let mut folders = Vec::with_capacity(names.len() + 1);
309            folders.push(FolderView {
310                id: Some(id),
311                name: target.to_string(),
312                revision_date,
313            });
314            folders.extend(names.into_iter().map(|name| FolderView {
315                id: None,
316                name: format!("{target}/{name}"),
317                revision_date,
318            }));
319            folders
320        }
321        None => names
322            .into_iter()
323            .map(|name| FolderView {
324                id: None,
325                name,
326                revision_date,
327            })
328            .collect(),
329    };
330    (folders, count)
331}
332
333/// True when the org-import target is the user's own "My items" collection
334fn targets_my_items(target_collection: Option<&ImportTargetCollection>) -> bool {
335    matches!(
336        target_collection.map(|t| &t.r#type),
337        Some(CollectionType::DefaultUserCollection)
338    )
339}
340
341/// Builds the collection views for an org import, plus the count of collections actually parsed
342/// from the source (excluding the injected target) — mirrors `build_personal_folders`, but for
343/// collections. When a target is given it becomes collection 0 and parsed groups are nested
344/// beneath it as `"{target}/{group}"`; with no target, every group becomes a brand-new
345/// top-level collection (submitted with no id, same as `build_personal_folders` submits new
346/// folders with no id — the server creates on an unmatched/empty id).
347fn build_org_collections(
348    names: Vec<String>,
349    organization_id: OrganizationId,
350    target: Option<(CollectionId, &str)>,
351) -> (Vec<CollectionView>, usize) {
352    let count = names.len();
353    let new_view = |id: Option<CollectionId>, name: String| CollectionView {
354        id,
355        organization_id,
356        name,
357        external_id: None,
358        hide_passwords: false,
359        read_only: false,
360        manage: true,
361        r#type: CollectionType::SharedCollection,
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        assert_ne!(encrypted.cipher.name.unwrap().to_string(), "GitHub");
621    }
622}