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(crate) struct ParsedImport {
23 pub ciphers: Vec<ImportingCipher>,
25 pub folders: Vec<String>,
27 pub folder_relationships: Vec<(usize, usize)>,
29}
30
31enum ImportPayload {
33 Individual(ImportCiphersRequestModel),
34 Organization(String, ImportOrganizationCiphersRequestModel),
35}
36
37pub(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 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 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 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 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
226fn 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
241fn 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
266fn 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
294fn 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
332fn 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
340fn 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
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!(encrypted.cipher.key.is_some());
622 assert_ne!(encrypted.cipher.name.unwrap().to_string(), "GitHub");
623 }
624}