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