1use 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
20pub(crate) struct ParsedImport {
23 pub ciphers: Vec<ImportingCipher>,
24 pub folders: Vec<String>,
26 pub folder_relationships: Vec<(usize, usize)>,
28}
29
30enum ImportPayload {
32 Individual(ImportCiphersRequestModel),
33 Organization(String, ImportOrganizationCiphersRequestModel),
34}
35
36pub(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 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 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 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 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 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 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
193fn 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
208fn 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
233fn 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
261fn 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
294fn 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 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 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 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 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 #[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}