1use 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
20pub struct ParsedImport {
25 pub ciphers: Vec<ImportingCipher>,
27 pub folders: Vec<String>,
29 pub folder_relationships: Vec<(usize, usize)>,
31}
32
33enum ImportPayload {
35 Individual(ImportCiphersRequestModel),
36 Organization(String, ImportOrganizationCiphersRequestModel),
37}
38
39pub(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 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 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 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 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
227fn 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
242fn 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
267fn 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
295fn 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
333fn 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
341fn 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
379fn 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 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 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 #[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 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 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 #[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}