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::{ApiError, 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                .map_err(ApiError::from)?;
180        }
181        ImportPayload::Organization(organization_id, model) => {
182            api_client
183                .import_ciphers_api()
184                .post_import_organization(Some(&organization_id), Some(model))
185                .await
186                .map_err(ApiError::from)?;
187        }
188    }
189
190    Ok(summary)
191}
192
193/// Maps an exporter [`CipherType`] to the vault [`bitwarden_vault::CipherType`] discriminant.
194fn vault_cipher_type(t: &CipherType) -> bitwarden_vault::CipherType {
195    use bitwarden_vault::CipherType as V;
196    match t {
197        CipherType::Login(_) => V::Login,
198        CipherType::SecureNote(_) => V::SecureNote,
199        CipherType::Card(_) => V::Card,
200        CipherType::Identity(_) => V::Identity,
201        CipherType::SshKey(_) => V::SshKey,
202        CipherType::BankAccount => V::BankAccount,
203        CipherType::Passport => V::Passport,
204        CipherType::DriversLicense => V::DriversLicense,
205    }
206}
207
208/// Counts ciphers by vault type, in a stable display order, omitting types with no entries.
209fn count_by_type(ciphers: &[ImportingCipher]) -> Vec<CipherTypeCount> {
210    use bitwarden_vault::CipherType as V;
211    const ORDER: [V; 8] = [
212        V::Login,
213        V::Card,
214        V::Identity,
215        V::SecureNote,
216        V::SshKey,
217        V::BankAccount,
218        V::Passport,
219        V::DriversLicense,
220    ];
221    ORDER
222        .into_iter()
223        .filter_map(|t| {
224            let count = ciphers
225                .iter()
226                .filter(|c| vault_cipher_type(&c.r#type) == t)
227                .count() as u32;
228            (count > 0).then_some(CipherTypeCount { r#type: t, count })
229        })
230        .collect()
231}
232
233/// Drops ciphers whose type is restricted and re-indexes the folder relationships.
234fn filter_restricted(
235    ciphers: Vec<ImportingCipher>,
236    folder_relationships: Vec<(usize, usize)>,
237    restricted: &[bitwarden_vault::CipherType],
238) -> (Vec<ImportingCipher>, Vec<(usize, usize)>) {
239    if restricted.is_empty() {
240        return (ciphers, folder_relationships);
241    }
242
243    let mut old_to_new = vec![None; ciphers.len()];
244    let mut kept = Vec::with_capacity(ciphers.len());
245    for (old_index, cipher) in ciphers.into_iter().enumerate() {
246        if restricted.contains(&vault_cipher_type(&cipher.r#type)) {
247            continue;
248        }
249        old_to_new[old_index] = Some(kept.len());
250        kept.push(cipher);
251    }
252
253    let relationships = folder_relationships
254        .into_iter()
255        .filter_map(|(cipher, folder)| old_to_new[cipher].map(|new| (new, folder)))
256        .collect();
257
258    (kept, relationships)
259}
260
261/// Builds the folder views to import. When a target folder is given it becomes folder 0 and the
262/// imported groups are nested beneath it as `"{target}/{group}"`.
263fn build_personal_folders(
264    names: Vec<String>,
265    target: Option<(bitwarden_vault::FolderId, &str)>,
266) -> Vec<FolderView> {
267    let revision_date = Utc::now();
268    match target {
269        Some((id, target)) => {
270            let mut folders = Vec::with_capacity(names.len() + 1);
271            folders.push(FolderView {
272                id: Some(id),
273                name: target.to_string(),
274                revision_date,
275            });
276            folders.extend(names.into_iter().map(|name| FolderView {
277                id: None,
278                name: format!("{target}/{name}"),
279                revision_date,
280            }));
281            folders
282        }
283        None => names
284            .into_iter()
285            .map(|name| FolderView {
286                id: None,
287                name,
288                revision_date,
289            })
290            .collect(),
291    }
292}
293
294/// Shifts existing relationships to account for the target folder at index 0 and assigns any
295/// folder-less cipher to it.
296fn nest_relationships_under_target(
297    relationships: Vec<(usize, usize)>,
298    cipher_count: usize,
299) -> Vec<(usize, usize)> {
300    let assigned: std::collections::HashSet<usize> =
301        relationships.iter().map(|(cipher, _)| *cipher).collect();
302    let mut out: Vec<(usize, usize)> = relationships
303        .iter()
304        .map(|(cipher, folder)| (*cipher, folder + 1))
305        .collect();
306    for cipher in 0..cipher_count {
307        if !assigned.contains(&cipher) {
308            out.push((cipher, 0));
309        }
310    }
311    out
312}
313
314fn to_kvp(relationships: &[(usize, usize)]) -> Vec<Int32Int32KeyValuePair> {
315    relationships
316        .iter()
317        .map(|(cipher, folder)| Int32Int32KeyValuePair {
318            key: Some(*cipher as i32),
319            value: Some(*folder as i32),
320        })
321        .collect()
322}
323
324#[cfg(test)]
325mod tests {
326    use bitwarden_exporters::{CipherType, ImportingCipher, Login};
327    use bitwarden_vault::{CipherType as VaultCipherType, FolderId};
328    use chrono::{DateTime, Utc};
329
330    use super::*;
331
332    fn importing(name: &str, r#type: CipherType) -> ImportingCipher {
333        let date: DateTime<Utc> = "2024-01-01T00:00:00Z".parse().unwrap();
334        ImportingCipher {
335            folder_id: None,
336            name: name.to_string(),
337            notes: None,
338            r#type,
339            favorite: false,
340            reprompt: 0,
341            fields: vec![],
342            revision_date: date,
343            creation_date: date,
344            deleted_date: None,
345        }
346    }
347
348    #[test]
349    fn filter_restricted_drops_matching_and_reindexes_relationships() {
350        let ciphers = vec![
351            importing("a", CipherType::Passport),
352            importing("b", CipherType::BankAccount),
353            importing("c", CipherType::Passport),
354        ];
355        // a->folder0, b->folder1, c->folder0
356        let relationships = vec![(0, 0), (1, 1), (2, 0)];
357
358        let (kept, relationships) =
359            filter_restricted(ciphers, relationships, &[VaultCipherType::BankAccount]);
360
361        assert_eq!(kept.len(), 2);
362        assert_eq!(kept[0].name, "a");
363        assert_eq!(kept[1].name, "c");
364        // b's relationship is dropped; c is reindexed from cipher 2 to cipher 1.
365        assert_eq!(relationships, vec![(0, 0), (1, 0)]);
366    }
367
368    #[test]
369    fn filter_restricted_empty_list_is_noop() {
370        let ciphers = vec![importing("a", CipherType::Passport)];
371        let relationships = vec![(0, 0)];
372        let (kept, out) = filter_restricted(ciphers, relationships.clone(), &[]);
373        assert_eq!(kept.len(), 1);
374        assert_eq!(out, relationships);
375    }
376
377    #[test]
378    fn build_personal_folders_without_target_preserves_names() {
379        let folders = build_personal_folders(vec!["A".into(), "A/B".into()], None);
380        assert_eq!(folders.len(), 2);
381        assert!(folders.iter().all(|f| f.id.is_none()));
382        assert_eq!(folders[0].name, "A");
383        assert_eq!(folders[1].name, "A/B");
384    }
385
386    #[test]
387    fn build_personal_folders_with_target_nests_under_it() {
388        let target = FolderId::new(uuid::Uuid::new_v4());
389        let folders = build_personal_folders(vec!["A".into()], Some((target, "Target")));
390        assert_eq!(folders.len(), 2);
391        assert_eq!(folders[0].id, Some(target));
392        assert_eq!(folders[0].name, "Target");
393        assert_eq!(folders[1].id, None);
394        assert_eq!(folders[1].name, "Target/A");
395    }
396
397    #[test]
398    fn nest_relationships_shifts_existing_and_assigns_folderless() {
399        // cipher 0 is in a group; cipher 1 has no folder.
400        let out = nest_relationships_under_target(vec![(0, 0)], 2);
401        assert!(out.contains(&(0, 1)));
402        assert!(out.contains(&(1, 0)));
403        assert_eq!(out.len(), 2);
404    }
405
406    #[test]
407    fn count_by_type_groups_in_stable_order_and_omits_zero() {
408        let login = CipherType::Login(Box::new(Login {
409            username: None,
410            password: None,
411            login_uris: vec![],
412            totp: None,
413            fido2_credentials: None,
414        }));
415        let ciphers = vec![
416            importing("a", CipherType::Passport),
417            importing("b", login),
418            importing("c", CipherType::Passport),
419        ];
420        let counts = count_by_type(&ciphers);
421        // Login is ordered before Passport; Card/etc. with zero entries are omitted.
422        assert_eq!(counts.len(), 2);
423        assert_eq!(counts[0].r#type, VaultCipherType::Login);
424        assert_eq!(counts[0].count, 1);
425        assert_eq!(counts[1].r#type, VaultCipherType::Passport);
426        assert_eq!(counts[1].count, 2);
427    }
428
429    #[test]
430    fn to_kvp_maps_indices() {
431        let kvp = to_kvp(&[(0, 2), (3, 1)]);
432        assert_eq!(kvp[0].key, Some(0));
433        assert_eq!(kvp[0].value, Some(2));
434        assert_eq!(kvp[1].key, Some(3));
435        assert_eq!(kvp[1].value, Some(1));
436    }
437
438    /// Covers the encrypt boundary: a parsed cipher's name comes out encrypted (not the plaintext
439    /// title) when run through a real key store.
440    #[tokio::test]
441    async fn encrypt_import_encrypts_the_cipher_name() {
442        use bitwarden_core::{Client, client::test_accounts::test_bitwarden_com_account};
443        use bitwarden_exporters::encrypt_import;
444
445        let client = Client::init_test_account(test_bitwarden_com_account()).await;
446        let key_store = client.internal.get_key_store();
447        let mut ctx = key_store.context();
448
449        let login = CipherType::Login(Box::new(Login {
450            username: None,
451            password: None,
452            login_uris: vec![],
453            totp: None,
454            fido2_credentials: None,
455        }));
456        let cipher = encrypt_import(&mut ctx, importing("GitHub", login), None).unwrap();
457
458        assert_ne!(cipher.name.to_string(), "GitHub");
459    }
460}