1use bitwarden_api_api::models::{
8 CipherRequestModel, CollectionWithIdRequestModel, FolderWithIdRequestModel,
9 ImportCiphersRequestModel, ImportOrganizationCiphersRequestModel, Int32Int32KeyValuePair,
10};
11use bitwarden_collections::collection::{Collection, CollectionType, CollectionView};
12use bitwarden_core::{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 encrypted = encrypt_import(&mut ctx, c, options.organization_id, user_id)?;
62 Ok::<_, ImportError>(CipherRequestModel::from(encrypted))
63 })
64 .collect::<Result<Vec<_>, _>>()?;
65
66 match options.organization_id {
67 None => {
69 let target_folder = options
70 .target_folder
71 .as_ref()
72 .map(|t| (t.id, t.name.as_str()));
73 let folder_views = build_personal_folders(parsed.folders, target_folder);
74 let folder_models = folder_views
75 .into_iter()
76 .map(|v| -> Result<FolderWithIdRequestModel, ImportError> {
77 let folder: Folder = v.encrypt_composite(&mut ctx, v.key_identifier())?;
78 Ok((&folder).into())
79 })
80 .collect::<Result<Vec<_>, _>>()?;
81 let folder_count = folder_models.len();
82
83 let relationships = if target_folder.is_some() {
84 nest_relationships_under_target(folder_relationships, cipher_count)
85 } else {
86 folder_relationships
87 };
88
89 let model = ImportCiphersRequestModel {
90 folders: Some(folder_models),
91 ciphers: Some(cipher_models),
92 folder_relationships: Some(to_kvp(&relationships)),
93 };
94 (
95 ImportPayload::Individual(model),
96 ImportSummary {
97 ciphers: cipher_type_counts,
98 folders: folder_count as u32,
99 collections: 0,
100 },
101 )
102 }
103 Some(organization_id) => {
106 let folder_views = build_personal_folders(parsed.folders, None);
107 let folder_models = folder_views
108 .into_iter()
109 .map(|v| -> Result<FolderWithIdRequestModel, ImportError> {
110 let folder: Folder = v.encrypt_composite(&mut ctx, v.key_identifier())?;
111 Ok((&folder).into())
112 })
113 .collect::<Result<Vec<_>, _>>()?;
114 let folder_count = folder_models.len();
115
116 let (collection_models, collection_relationships) = match options.target_collection
117 {
118 Some(target) => {
119 let view = CollectionView {
123 id: Some(target.id),
124 organization_id,
125 name: target.name,
126 external_id: None,
127 hide_passwords: false,
128 read_only: false,
129 manage: true,
130 r#type: CollectionType::SharedCollection,
131 };
132 let collection: Collection =
133 view.encrypt_composite(&mut ctx, view.key_identifier())?;
134 let relationships = (0..cipher_count).map(|c| (c, 0)).collect::<Vec<_>>();
135 let model = CollectionWithIdRequestModel {
137 name: collection.name.to_string(),
138 external_id: collection.external_id.clone(),
139 groups: None,
140 users: None,
141 id: collection.id.map(Into::into),
142 };
143 (vec![model], relationships)
144 }
145 None => (Vec::new(), Vec::new()),
148 };
149 let collection_count = collection_models.len();
150
151 let model = ImportOrganizationCiphersRequestModel {
152 collections: Some(collection_models),
153 ciphers: Some(cipher_models),
154 collection_relationships: Some(to_kvp(&collection_relationships)),
155 folders: Some(folder_models),
156 folder_relationships: Some(to_kvp(&folder_relationships)),
157 };
158 (
159 ImportPayload::Organization(organization_id.to_string(), model),
160 ImportSummary {
161 ciphers: cipher_type_counts,
162 folders: folder_count as u32,
163 collections: collection_count as u32,
164 },
165 )
166 }
167 }
168 };
169
170 let api_client = &client.internal.get_api_configurations().api_client;
171 match payload {
172 ImportPayload::Individual(model) => {
173 api_client
174 .import_ciphers_api()
175 .post_import(Some(model))
176 .await?;
177 }
178 ImportPayload::Organization(organization_id, model) => {
179 api_client
180 .import_ciphers_api()
181 .post_import_organization(Some(&organization_id), Some(model))
182 .await?;
183 }
184 }
185
186 Ok(summary)
187}
188
189fn vault_cipher_type(t: &CipherType) -> bitwarden_vault::CipherType {
191 use bitwarden_vault::CipherType as V;
192 match t {
193 CipherType::Login(_) => V::Login,
194 CipherType::SecureNote(_) => V::SecureNote,
195 CipherType::Card(_) => V::Card,
196 CipherType::Identity(_) => V::Identity,
197 CipherType::SshKey(_) => V::SshKey,
198 CipherType::BankAccount => V::BankAccount,
199 CipherType::Passport => V::Passport,
200 CipherType::DriversLicense => V::DriversLicense,
201 }
202}
203
204fn count_by_type(ciphers: &[ImportingCipher]) -> Vec<CipherTypeCount> {
206 use bitwarden_vault::CipherType as V;
207 const ORDER: [V; 8] = [
208 V::Login,
209 V::Card,
210 V::Identity,
211 V::SecureNote,
212 V::SshKey,
213 V::BankAccount,
214 V::Passport,
215 V::DriversLicense,
216 ];
217 ORDER
218 .into_iter()
219 .filter_map(|t| {
220 let count = ciphers
221 .iter()
222 .filter(|c| vault_cipher_type(&c.r#type) == t)
223 .count() as u32;
224 (count > 0).then_some(CipherTypeCount { r#type: t, count })
225 })
226 .collect()
227}
228
229fn filter_restricted(
231 ciphers: Vec<ImportingCipher>,
232 folder_relationships: Vec<(usize, usize)>,
233 restricted: &[bitwarden_vault::CipherType],
234) -> (Vec<ImportingCipher>, Vec<(usize, usize)>) {
235 if restricted.is_empty() {
236 return (ciphers, folder_relationships);
237 }
238
239 let mut old_to_new = vec![None; ciphers.len()];
240 let mut kept = Vec::with_capacity(ciphers.len());
241 for (old_index, cipher) in ciphers.into_iter().enumerate() {
242 if restricted.contains(&vault_cipher_type(&cipher.r#type)) {
243 continue;
244 }
245 old_to_new[old_index] = Some(kept.len());
246 kept.push(cipher);
247 }
248
249 let relationships = folder_relationships
250 .into_iter()
251 .filter_map(|(cipher, folder)| old_to_new[cipher].map(|new| (new, folder)))
252 .collect();
253
254 (kept, relationships)
255}
256
257fn build_personal_folders(
260 names: Vec<String>,
261 target: Option<(bitwarden_vault::FolderId, &str)>,
262) -> Vec<FolderView> {
263 let revision_date = Utc::now();
264 match target {
265 Some((id, target)) => {
266 let mut folders = Vec::with_capacity(names.len() + 1);
267 folders.push(FolderView {
268 id: Some(id),
269 name: target.to_string(),
270 revision_date,
271 });
272 folders.extend(names.into_iter().map(|name| FolderView {
273 id: None,
274 name: format!("{target}/{name}"),
275 revision_date,
276 }));
277 folders
278 }
279 None => names
280 .into_iter()
281 .map(|name| FolderView {
282 id: None,
283 name,
284 revision_date,
285 })
286 .collect(),
287 }
288}
289
290fn nest_relationships_under_target(
293 relationships: Vec<(usize, usize)>,
294 cipher_count: usize,
295) -> Vec<(usize, usize)> {
296 let assigned: std::collections::HashSet<usize> =
297 relationships.iter().map(|(cipher, _)| *cipher).collect();
298 let mut out: Vec<(usize, usize)> = relationships
299 .iter()
300 .map(|(cipher, folder)| (*cipher, folder + 1))
301 .collect();
302 for cipher in 0..cipher_count {
303 if !assigned.contains(&cipher) {
304 out.push((cipher, 0));
305 }
306 }
307 out
308}
309
310fn to_kvp(relationships: &[(usize, usize)]) -> Vec<Int32Int32KeyValuePair> {
311 relationships
312 .iter()
313 .map(|(cipher, folder)| Int32Int32KeyValuePair {
314 key: Some(*cipher as i32),
315 value: Some(*folder as i32),
316 })
317 .collect()
318}
319
320#[cfg(test)]
321mod tests {
322 use bitwarden_exporters::{CipherType, ImportingCipher, Login};
323 use bitwarden_vault::{CipherType as VaultCipherType, FolderId};
324 use chrono::{DateTime, Utc};
325
326 use super::*;
327
328 fn importing(name: &str, r#type: CipherType) -> ImportingCipher {
329 let date: DateTime<Utc> = "2024-01-01T00:00:00Z".parse().unwrap();
330 ImportingCipher {
331 folder_id: None,
332 name: name.to_string(),
333 notes: None,
334 r#type,
335 favorite: false,
336 reprompt: 0,
337 fields: vec![],
338 revision_date: date,
339 creation_date: date,
340 deleted_date: None,
341 }
342 }
343
344 #[test]
345 fn filter_restricted_drops_matching_and_reindexes_relationships() {
346 let ciphers = vec![
347 importing("a", CipherType::Passport),
348 importing("b", CipherType::BankAccount),
349 importing("c", CipherType::Passport),
350 ];
351 let relationships = vec![(0, 0), (1, 1), (2, 0)];
353
354 let (kept, relationships) =
355 filter_restricted(ciphers, relationships, &[VaultCipherType::BankAccount]);
356
357 assert_eq!(kept.len(), 2);
358 assert_eq!(kept[0].name, "a");
359 assert_eq!(kept[1].name, "c");
360 assert_eq!(relationships, vec![(0, 0), (1, 0)]);
362 }
363
364 #[test]
365 fn filter_restricted_empty_list_is_noop() {
366 let ciphers = vec![importing("a", CipherType::Passport)];
367 let relationships = vec![(0, 0)];
368 let (kept, out) = filter_restricted(ciphers, relationships.clone(), &[]);
369 assert_eq!(kept.len(), 1);
370 assert_eq!(out, relationships);
371 }
372
373 #[test]
374 fn build_personal_folders_without_target_preserves_names() {
375 let folders = build_personal_folders(vec!["A".into(), "A/B".into()], None);
376 assert_eq!(folders.len(), 2);
377 assert!(folders.iter().all(|f| f.id.is_none()));
378 assert_eq!(folders[0].name, "A");
379 assert_eq!(folders[1].name, "A/B");
380 }
381
382 #[test]
383 fn build_personal_folders_with_target_nests_under_it() {
384 let target = FolderId::new(uuid::Uuid::new_v4());
385 let folders = build_personal_folders(vec!["A".into()], Some((target, "Target")));
386 assert_eq!(folders.len(), 2);
387 assert_eq!(folders[0].id, Some(target));
388 assert_eq!(folders[0].name, "Target");
389 assert_eq!(folders[1].id, None);
390 assert_eq!(folders[1].name, "Target/A");
391 }
392
393 #[test]
394 fn nest_relationships_shifts_existing_and_assigns_folderless() {
395 let out = nest_relationships_under_target(vec![(0, 0)], 2);
397 assert!(out.contains(&(0, 1)));
398 assert!(out.contains(&(1, 0)));
399 assert_eq!(out.len(), 2);
400 }
401
402 #[test]
403 fn count_by_type_groups_in_stable_order_and_omits_zero() {
404 let login = CipherType::Login(Box::new(Login {
405 username: None,
406 password: None,
407 login_uris: vec![],
408 totp: None,
409 fido2_credentials: None,
410 }));
411 let ciphers = vec![
412 importing("a", CipherType::Passport),
413 importing("b", login),
414 importing("c", CipherType::Passport),
415 ];
416 let counts = count_by_type(&ciphers);
417 assert_eq!(counts.len(), 2);
419 assert_eq!(counts[0].r#type, VaultCipherType::Login);
420 assert_eq!(counts[0].count, 1);
421 assert_eq!(counts[1].r#type, VaultCipherType::Passport);
422 assert_eq!(counts[1].count, 2);
423 }
424
425 #[test]
426 fn to_kvp_maps_indices() {
427 let kvp = to_kvp(&[(0, 2), (3, 1)]);
428 assert_eq!(kvp[0].key, Some(0));
429 assert_eq!(kvp[0].value, Some(2));
430 assert_eq!(kvp[1].key, Some(3));
431 assert_eq!(kvp[1].value, Some(1));
432 }
433
434 #[tokio::test]
437 async fn encrypt_import_encrypts_the_cipher_name() {
438 use bitwarden_core::{Client, client::test_accounts::test_bitwarden_com_account};
439 use bitwarden_exporters::encrypt_import;
440
441 let client = Client::init_test_account(test_bitwarden_com_account()).await;
442 let key_store = client.internal.get_key_store();
443 let mut ctx = key_store.context();
444
445 let login = CipherType::Login(Box::new(Login {
446 username: None,
447 password: None,
448 login_uris: vec![],
449 totp: None,
450 fido2_credentials: None,
451 }));
452 let user_id = client.internal.get_user_id().unwrap();
453 let encrypted =
454 encrypt_import(&mut ctx, importing("GitHub", login), None, user_id).unwrap();
455
456 assert_eq!(encrypted.encrypted_for, user_id);
457 assert_ne!(encrypted.cipher.name.unwrap().to_string(), "GitHub");
458 }
459}