1use chrono::{DateTime, Utc};
2use credential_exchange_format::{
3 Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential,
4 CreditCardCredential, CustomFieldsCredential, DriversLicenseCredential, EditableField,
5 EditableFieldString, EditableFieldValue, IdentityDocumentCredential, Item, NoteCredential,
6 PasskeyCredential, PassportCredential, PersonNameCredential, SshKeyCredential, TotpCredential,
7 WifiCredential,
8};
9
10use crate::{
11 CipherType, Field, ImportingCipher, SecureNote, SecureNoteType,
12 cxf::{
13 CxfError,
14 api_key::api_key_to_fields,
15 card::to_card,
16 editable_field::create_field,
17 identity::{
18 address_to_identity, drivers_license_to_identity, identity_document_to_identity,
19 passport_to_identity, person_name_to_identity,
20 },
21 login::to_login,
22 note::extract_note_content,
23 ssh::to_ssh,
24 wifi::wifi_to_fields,
25 },
26};
27
28pub(crate) fn parse_cxf(payload: String) -> Result<Vec<ImportingCipher>, CxfError> {
32 let account: CxfAccount = serde_json::from_str(&payload)?;
33
34 let items: Vec<ImportingCipher> = account.items.into_iter().flat_map(parse_item).collect();
35
36 Ok(items)
37}
38
39fn convert_date(ts: Option<u64>) -> DateTime<Utc> {
43 ts.and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
44 .unwrap_or(Utc::now())
45}
46
47fn custom_fields_to_fields(custom_fields: &CustomFieldsCredential) -> Vec<Field> {
50 custom_fields
51 .fields
52 .iter()
53 .map(|field_value| match field_value {
54 EditableFieldValue::String(field) => create_field(field, None::<String>),
55 EditableFieldValue::ConcealedString(field) => create_field(field, None::<String>),
56 EditableFieldValue::Boolean(field) => create_field(field, None::<String>),
57 EditableFieldValue::Date(field) => create_field(field, None::<String>),
58 EditableFieldValue::YearMonth(field) => create_field(field, None::<String>),
59 EditableFieldValue::SubdivisionCode(field) => create_field(field, None::<String>),
60 EditableFieldValue::CountryCode(field) => create_field(field, None::<String>),
61 EditableFieldValue::WifiNetworkSecurityType(field) => {
62 create_field(field, None::<String>)
63 }
64 _ => create_field(
65 &EditableField {
66 id: None,
67 label: Some("Unknown Field".to_string()),
68 value: EditableFieldString("".to_string()),
69 extensions: None,
70 },
71 None::<String>,
72 ),
73 })
74 .collect()
75}
76
77pub(super) fn parse_item(value: Item) -> Vec<ImportingCipher> {
78 let grouped = group_credentials_by_type(value.credentials);
79
80 let creation_date = convert_date(value.creation_at);
81 let revision_date = convert_date(value.modified_at);
82
83 let mut output = vec![];
84
85 let scope = value.scope.as_ref();
86
87 let note_content = grouped.note.first().map(extract_note_content);
89
90 let mut add_item = |t: CipherType, fields: Vec<Field>, fallback_name: Option<String>| {
92 let name = match fallback_name {
93 Some(fallback) if value.title.trim().is_empty() => fallback,
94 _ => value.title.clone(),
95 };
96 output.push(ImportingCipher {
97 folder_id: None, name,
99 notes: note_content.clone(),
100 r#type: t,
101 favorite: false,
102 reprompt: 0,
103 fields,
104 revision_date,
105 creation_date,
106 deleted_date: None,
107 })
108 };
109
110 if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() || !grouped.totp.is_empty() {
112 let basic_auth = grouped.basic_auth.first();
113 let passkey = grouped.passkey.first();
114 let totp = grouped.totp.first();
115
116 let login = to_login(creation_date, basic_auth, passkey, totp, scope);
117 add_item(CipherType::Login(Box::new(login)), vec![], None);
118 }
119
120 if let Some(credit_card) = grouped.credit_card.first() {
122 let (card, fields) = to_card(credit_card);
123
124 let fallback_name = card
126 .cardholder_name
127 .clone()
128 .unwrap_or_else(|| "Untitled Card".to_string());
129
130 add_item(
131 CipherType::Card(Box::new(card)),
132 fields,
133 Some(fallback_name),
134 );
135 }
136
137 let secure_note_type = || {
139 CipherType::SecureNote(Box::new(SecureNote {
140 r#type: SecureNoteType::Generic,
141 }))
142 };
143
144 if let Some(api_key) = grouped.api_key.first() {
146 let fields = api_key_to_fields(api_key);
147 add_item(secure_note_type(), fields, None);
148 }
149
150 if let Some(wifi) = grouped.wifi.first() {
152 let fields = wifi_to_fields(wifi);
153 add_item(secure_note_type(), fields, None);
154 }
155
156 [
158 grouped
159 .address
160 .first()
161 .map(|a| address_to_identity(a.clone())),
162 grouped
163 .passport
164 .first()
165 .map(|p| passport_to_identity(p.clone())),
166 grouped
167 .person_name
168 .first()
169 .map(|p| person_name_to_identity(p.clone())),
170 grouped
171 .drivers_license
172 .first()
173 .map(|d| drivers_license_to_identity(d.clone())),
174 grouped
175 .identity_document
176 .first()
177 .map(|i| identity_document_to_identity(i.clone())),
178 ]
179 .into_iter()
180 .flatten()
181 .for_each(|(identity, custom_fields)| {
182 add_item(
183 CipherType::Identity(Box::new(identity)),
184 custom_fields,
185 None,
186 );
187 });
188
189 if let Some(ssh) = grouped.ssh.first() {
191 match to_ssh(ssh) {
192 Ok((ssh_key, fields)) => add_item(CipherType::SshKey(Box::new(ssh_key)), fields, None),
193 Err(_) => {
194 }
196 }
197 }
198
199 if let Some(custom_fields) = grouped.custom_fields.first() {
202 if let Some(first_cipher) = output.first_mut() {
203 first_cipher
205 .fields
206 .extend(custom_fields_to_fields(custom_fields));
207 } else {
208 let fields = custom_fields_to_fields(custom_fields);
210 output.push(ImportingCipher {
211 folder_id: None,
212 name: value.title.clone(),
213 notes: note_content.clone(),
214 r#type: secure_note_type(),
215 favorite: false,
216 reprompt: 0,
217 fields,
218 revision_date,
219 creation_date,
220 deleted_date: None,
221 });
222 }
223 }
224
225 if !grouped.note.is_empty() && output.is_empty() {
227 let standalone_note_content = grouped.note.first().map(extract_note_content);
228 output.push(ImportingCipher {
229 folder_id: None, name: value.title.clone(),
231 notes: standalone_note_content,
232 r#type: secure_note_type(),
233 favorite: false,
234 reprompt: 0,
235 fields: vec![],
236 revision_date,
237 creation_date,
238 deleted_date: None,
239 });
240 }
241
242 output
243}
244
245fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials {
252 fn filter_credentials<T>(
253 credentials: &[Credential],
254 f: impl Fn(&Credential) -> Option<&T>,
255 ) -> Vec<T>
256 where
257 T: Clone,
258 {
259 credentials.iter().filter_map(f).cloned().collect()
260 }
261
262 macro_rules! extract_credential {
263 ($field:ident, $variant:path, $type:ty) => {
264 filter_credentials(&credentials, |c| match c {
265 &$variant(ref inner) => Some(inner.as_ref()),
266 _ => None,
267 })
268 };
269 }
270
271 GroupedCredentials {
272 api_key: extract_credential!(api_key, Credential::ApiKey, ApiKeyCredential),
273 basic_auth: extract_credential!(basic_auth, Credential::BasicAuth, BasicAuthCredential),
274 credit_card: extract_credential!(credit_card, Credential::CreditCard, CreditCardCredential),
275 custom_fields: extract_credential!(custom_fields, Credential::CustomFields, CustomFields),
276 passkey: extract_credential!(passkey, Credential::Passkey, PasskeyCredential),
277 ssh: extract_credential!(ssh, Credential::SshKey, SshKeyCredential),
278 totp: extract_credential!(totp, Credential::Totp, TotpCredential),
279 wifi: extract_credential!(wifi, Credential::Wifi, WifiCredential),
280 address: extract_credential!(address, Credential::Address, AddressCredential),
281 passport: extract_credential!(passport, Credential::Passport, PassportCredential),
282 person_name: extract_credential!(person_name, Credential::PersonName, PersonNameCredential),
283 drivers_license: extract_credential!(
284 drivers_license,
285 Credential::DriversLicense,
286 DriversLicenseCredential
287 ),
288 identity_document: extract_credential!(
289 identity_document,
290 Credential::IdentityDocument,
291 IdentityDocumentCredential
292 ),
293 note: extract_credential!(note, Credential::Note, NoteCredential),
294 }
295}
296
297struct GroupedCredentials {
298 address: Vec<AddressCredential>,
299 api_key: Vec<ApiKeyCredential>,
300 basic_auth: Vec<BasicAuthCredential>,
301 credit_card: Vec<CreditCardCredential>,
302 custom_fields: Vec<CustomFieldsCredential>,
303 drivers_license: Vec<DriversLicenseCredential>,
304 identity_document: Vec<IdentityDocumentCredential>,
305 note: Vec<NoteCredential>,
306 passkey: Vec<PasskeyCredential>,
307 passport: Vec<PassportCredential>,
308 person_name: Vec<PersonNameCredential>,
309 ssh: Vec<SshKeyCredential>,
310 totp: Vec<TotpCredential>,
311 wifi: Vec<WifiCredential>,
312}
313
314#[cfg(test)]
315mod tests {
316 use chrono::{Duration, Month};
317 use credential_exchange_format::{B64Url, CreditCardCredential, EditableFieldYearMonth};
318
319 use super::*;
320
321 #[test]
322 fn test_convert_date() {
323 let timestamp: u64 = 1706613834;
324 let datetime = convert_date(Some(timestamp));
325 assert_eq!(
326 datetime,
327 "2024-01-30T11:23:54Z".parse::<DateTime<Utc>>().unwrap()
328 );
329 }
330
331 #[test]
332 fn test_convert_date_none() {
333 let datetime = convert_date(None);
334 assert!(datetime > Utc::now() - Duration::seconds(1));
335 assert!(datetime <= Utc::now());
336 }
337
338 #[test]
339 fn test_parse_empty_item() {
340 let item = Item {
341 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
342 creation_at: Some(1706613834),
343 modified_at: Some(1706623773),
344 title: "Bitwarden".to_string(),
345 subtitle: None,
346 favorite: None,
347 credentials: vec![],
348 tags: None,
349 extensions: None,
350 scope: None,
351 };
352
353 let ciphers: Vec<ImportingCipher> = parse_item(item);
354 assert_eq!(ciphers.len(), 0);
355 }
356
357 #[test]
358 fn test_parse_passkey() {
359 let item = Item {
360 id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
361 .unwrap(),
362 creation_at: Some(1732181986),
363 modified_at: Some(1732182026),
364 title: "example.com".to_string(),
365 subtitle: None,
366 favorite: None,
367 credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
368 credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
369 .unwrap(),
370 rp_id: "example.com".to_string(),
371 username: "pj-fry".to_string(),
372 user_display_name: "Philip J. Fry".to_string(),
373 user_handle: B64Url::try_from("YWxleCBtdWxsZXI").unwrap(),
374 key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
375 .unwrap(),
376 fido2_extensions: None,
377 }))],
378 tags: None,
379 extensions: None,
380 scope: None,
381 };
382
383 let ciphers: Vec<ImportingCipher> = parse_item(item);
384 assert_eq!(ciphers.len(), 1);
385 let cipher = ciphers.first().unwrap();
386
387 assert_eq!(cipher.folder_id, None);
388 assert_eq!(cipher.name, "example.com");
389
390 let login = match &cipher.r#type {
391 CipherType::Login(login) => login,
392 _ => panic!("Expected login"),
393 };
394
395 assert_eq!(login.username, Some("pj-fry".to_string()));
396 assert_eq!(login.password, None);
397 assert_eq!(login.login_uris.len(), 1);
398 assert_eq!(
399 login.login_uris[0].uri,
400 Some("https://example.com".to_string())
401 );
402 assert_eq!(login.totp, None);
403
404 let passkey = login.fido2_credentials.as_ref().unwrap().first().unwrap();
405 assert_eq!(passkey.credential_id, "b64.6NiHiekW4ZY8vYHa-ucbvA");
406 assert_eq!(passkey.key_type, "public-key");
407 assert_eq!(passkey.key_algorithm, "ECDSA");
408 assert_eq!(passkey.key_curve, "P-256");
409 assert_eq!(
410 passkey.key_value,
411 "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl"
412 );
413 assert_eq!(passkey.rp_id, "example.com");
414 assert_eq!(
415 passkey.user_handle.as_ref().map(|h| h.to_string()).unwrap(),
416 "YWxleCBtdWxsZXI"
417 );
418 assert_eq!(passkey.user_name, Some("pj-fry".to_string()));
419 assert_eq!(passkey.counter, 0);
420 assert_eq!(passkey.rp_name, Some("example.com".to_string()));
421 assert_eq!(passkey.user_display_name, Some("Philip J. Fry".to_string()));
422 assert_eq!(passkey.discoverable, "true");
423 assert_eq!(
424 passkey.creation_date,
425 "2024-11-21T09:39:46Z".parse::<DateTime<Utc>>().unwrap()
426 );
427 }
428
429 #[test]
430 fn test_passkey_with_basic_auth_and_scope() {
431 use credential_exchange_format::{BasicAuthCredential, CredentialScope};
432
433 let item = Item {
434 id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
435 .unwrap(),
436 creation_at: Some(1732181986),
437 modified_at: Some(1732182026),
438 title: "Combined Login".to_string(),
439 subtitle: None,
440 favorite: None,
441 credentials: vec![
442 Credential::BasicAuth(Box::new(BasicAuthCredential {
443 username: Some("basic_username".to_string().into()),
444 password: Some("basic_password".to_string().into()),
445 })),
446 Credential::Passkey(Box::new(PasskeyCredential {
447 credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
448 .unwrap(),
449 rp_id: "passkey-domain.com".to_string(),
450 username: "passkey_username".to_string(),
451 user_display_name: "Passkey User".to_string(),
452 user_handle: B64Url::try_from("YWxleCBtdWxsZXI")
453 .unwrap(),
454 key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
455 .unwrap(),
456 fido2_extensions: None,
457 }))
458 ],
459 tags: None,
460 extensions: None,
461 scope: Some(CredentialScope {
462 urls: vec!["https://example.com".to_string()],
463 android_apps: vec![],
464 }),
465 };
466
467 let ciphers: Vec<ImportingCipher> = parse_item(item);
468 assert_eq!(ciphers.len(), 1);
469 let cipher = ciphers.first().unwrap();
470
471 let login = match &cipher.r#type {
472 CipherType::Login(login) => login,
473 _ => panic!("Expected login"),
474 };
475
476 assert_eq!(login.username, Some("basic_username".to_string()));
478 assert_eq!(login.password, Some("basic_password".to_string()));
479
480 assert_eq!(login.login_uris.len(), 1);
482 assert_eq!(
483 login.login_uris[0].uri,
484 Some("https://example.com".to_string())
485 );
486
487 assert!(login.fido2_credentials.is_some());
489 }
490
491 #[test]
492 fn test_passkey_with_empty_username() {
493 let item = Item {
494 id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF").unwrap(),
495 creation_at: Some(1732181986),
496 modified_at: Some(1732182026),
497 title: "Empty Username Passkey".to_string(),
498 subtitle: None,
499 favorite: None,
500 credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
501 credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
502 .unwrap(),
503 rp_id: "example.com".to_string(),
504 username: "".to_string(), user_display_name: "User Display".to_string(),
506 user_handle: B64Url::try_from("YWxleCBtdWxsZXI")
507 .unwrap(),
508 key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
509 .unwrap(),
510 fido2_extensions: None,
511 }))],
512 tags: None,
513 extensions: None,
514 scope: None,
515 };
516
517 let ciphers: Vec<ImportingCipher> = parse_item(item);
518 assert_eq!(ciphers.len(), 1);
519 let cipher = ciphers.first().unwrap();
520
521 let login = match &cipher.r#type {
522 CipherType::Login(login) => login,
523 _ => panic!("Expected login"),
524 };
525
526 assert_eq!(login.username, None);
528 assert_eq!(login.password, None);
529
530 assert_eq!(login.login_uris.len(), 1);
532 assert_eq!(
533 login.login_uris[0].uri,
534 Some("https://example.com".to_string())
535 );
536 }
537
538 #[test]
539 fn test_credit_card() {
540 let item = Item {
541 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
542 creation_at: Some(1706613834),
543 modified_at: Some(1706623773),
544 title: "My MasterCard".to_string(),
545 subtitle: None,
546 favorite: None,
547 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
548 number: Some("1234 5678 9012 3456".to_string().into()),
549 full_name: Some("John Doe".to_string().into()),
550 card_type: Some("MasterCard".to_string().into()),
551 verification_number: Some("123".to_string().into()),
552 pin: None,
553 expiry_date: Some(
554 EditableFieldYearMonth {
555 year: 2026,
556 month: Month::January,
557 }
558 .into(),
559 ),
560 valid_from: None,
561 }))],
562 tags: None,
563 extensions: None,
564 scope: None,
565 };
566
567 let ciphers: Vec<ImportingCipher> = parse_item(item);
568 assert_eq!(ciphers.len(), 1);
569 let cipher = ciphers.first().unwrap();
570
571 assert_eq!(cipher.folder_id, None);
572 assert_eq!(cipher.name, "My MasterCard");
573
574 let card = match &cipher.r#type {
575 CipherType::Card(card) => card,
576 _ => panic!("Expected card"),
577 };
578
579 assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
580 assert_eq!(card.exp_month, Some("1".to_string()));
581 assert_eq!(card.exp_year, Some("2026".to_string()));
582 assert_eq!(card.code, Some("123".to_string()));
583 assert_eq!(card.brand, Some("Mastercard".to_string()));
584 assert_eq!(card.number, Some("1234 5678 9012 3456".to_string()));
585 }
586
587 #[test]
588 fn test_totp() {
589 use credential_exchange_format::{OTPHashAlgorithm, TotpCredential};
590
591 let item = Item {
592 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
593 creation_at: Some(1706613834),
594 modified_at: Some(1706623773),
595 title: "My TOTP".to_string(),
596 subtitle: None,
597 favorite: None,
598 credentials: vec![Credential::Totp(Box::new(TotpCredential {
599 secret: "Hello World!".as_bytes().to_vec().into(),
600 period: 30,
601 digits: 6,
602 username: Some("[email protected]".to_string()),
603 algorithm: OTPHashAlgorithm::Sha1,
604 issuer: Some("Example Service".to_string()),
605 }))],
606 tags: None,
607 extensions: None,
608 scope: None,
609 };
610
611 let ciphers: Vec<ImportingCipher> = parse_item(item);
612 assert_eq!(ciphers.len(), 1);
613 let cipher = ciphers.first().unwrap();
614
615 assert_eq!(cipher.folder_id, None);
616 assert_eq!(cipher.name, "My TOTP");
617 assert_eq!(cipher.notes, None);
618 assert!(!cipher.favorite);
619 assert_eq!(cipher.reprompt, 0);
620 assert_eq!(cipher.fields, vec![]);
621
622 let login = match &cipher.r#type {
623 CipherType::Login(login) => login,
624 _ => panic!("Expected login cipher for TOTP"),
625 };
626
627 assert!(login.totp.is_some());
629 let otpauth = login.totp.as_ref().unwrap();
630
631 assert!(
633 otpauth.starts_with("otpauth://totp/Example%20Service:test%40example%2Ecom?secret=")
634 );
635 assert!(otpauth.contains("&issuer=Example%20Service"));
636
637 assert!(!otpauth.contains("&period=30"));
639 assert!(!otpauth.contains("&digits=6"));
640 assert!(!otpauth.contains("&algorithm=SHA1"));
641
642 assert_eq!(login.username, None);
644 assert_eq!(login.password, None);
645 assert_eq!(login.login_uris, vec![]);
646 }
647
648 #[test]
649 fn test_totp_combined_with_basic_auth() {
650 use credential_exchange_format::{BasicAuthCredential, OTPHashAlgorithm, TotpCredential};
651
652 let item = Item {
653 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
654 creation_at: Some(1706613834),
655 modified_at: Some(1706623773),
656 title: "Login with TOTP".to_string(),
657 subtitle: None,
658 favorite: None,
659 credentials: vec![
660 Credential::BasicAuth(Box::new(BasicAuthCredential {
661 username: Some("myuser".to_string().into()),
662 password: Some("mypass".to_string().into()),
663 })),
664 Credential::Totp(Box::new(TotpCredential {
665 secret: "totpkey".as_bytes().to_vec().into(),
666 period: 30,
667 digits: 6,
668 username: Some("totpuser".to_string()),
669 algorithm: OTPHashAlgorithm::Sha1,
670 issuer: Some("Service".to_string()),
671 })),
672 ],
673 tags: None,
674 extensions: None,
675 scope: None,
676 };
677
678 let ciphers: Vec<ImportingCipher> = parse_item(item);
679 assert_eq!(ciphers.len(), 1);
680 let cipher = ciphers.first().unwrap();
681
682 let login = match &cipher.r#type {
683 CipherType::Login(login) => login,
684 _ => panic!("Expected login cipher"),
685 };
686
687 assert_eq!(login.username, Some("myuser".to_string()));
689 assert_eq!(login.password, Some("mypass".to_string()));
690 assert!(login.totp.is_some());
691
692 let otpauth = login.totp.as_ref().unwrap();
693 assert!(otpauth.starts_with("otpauth://totp/Service:totpuser?secret="));
694 assert!(otpauth.contains("&issuer=Service"));
695 }
696
697 #[test]
700 fn test_note_as_part_of_login() {
701 use credential_exchange_format::{BasicAuthCredential, Credential, Item, NoteCredential};
702
703 let item = Item {
704 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
705 creation_at: Some(1706613834),
706 modified_at: Some(1706623773),
707 title: "Login with Note".to_string(),
708 subtitle: None,
709 favorite: None,
710 credentials: vec![
711 Credential::BasicAuth(Box::new(BasicAuthCredential {
712 username: Some("testuser".to_string().into()),
713 password: Some("testpass".to_string().into()),
714 })),
715 Credential::Note(Box::new(NoteCredential {
716 content: "This note should be added to the login cipher."
717 .to_string()
718 .into(),
719 })),
720 ],
721 tags: None,
722 extensions: None,
723 scope: None,
724 };
725
726 let ciphers: Vec<ImportingCipher> = parse_item(item);
727 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
729
730 assert_eq!(cipher.name, "Login with Note");
731 assert_eq!(
732 cipher.notes,
733 Some("This note should be added to the login cipher.".to_string())
734 );
735
736 match &cipher.r#type {
737 CipherType::Login(_) => (), _ => panic!("Expected Login cipher with note content"),
739 };
740 }
741
742 #[test]
743 fn test_note_as_part_of_api_key() {
744 use credential_exchange_format::{ApiKeyCredential, Credential, Item, NoteCredential};
745
746 let item = Item {
747 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
748 creation_at: Some(1706613834),
749 modified_at: Some(1706623773),
750 title: "API Key with Note".to_string(),
751 subtitle: None,
752 favorite: None,
753 credentials: vec![
754 Credential::ApiKey(Box::new(ApiKeyCredential {
755 key: Some("api-key-12345".to_string().into()),
756 username: Some("api-user".to_string().into()),
757 key_type: Some("Bearer".to_string().into()),
758 url: None,
759 valid_from: None,
760 expiry_date: None,
761 })),
762 Credential::Note(Box::new(NoteCredential {
763 content: "This note should be added to the API key cipher."
764 .to_string()
765 .into(),
766 })),
767 ],
768 tags: None,
769 extensions: None,
770 scope: None,
771 };
772
773 let ciphers: Vec<ImportingCipher> = parse_item(item);
774 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
776
777 assert_eq!(cipher.name, "API Key with Note");
778 assert_eq!(
779 cipher.notes,
780 Some("This note should be added to the API key cipher.".to_string())
781 );
782
783 match &cipher.r#type {
784 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher with note content"),
786 };
787
788 assert!(!cipher.fields.is_empty());
790 }
791
792 #[test]
793 fn test_note_as_part_of_credit_card() {
794 use chrono::Month;
795 use credential_exchange_format::{Credential, CreditCardCredential, Item, NoteCredential};
796
797 let item = Item {
798 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
799 creation_at: Some(1706613834),
800 modified_at: Some(1706623773),
801 title: "Credit Card with Note".to_string(),
802 subtitle: None,
803 favorite: None,
804 credentials: vec![
805 Credential::CreditCard(Box::new(CreditCardCredential {
806 number: Some("1234 5678 9012 3456".to_string().into()),
807 full_name: Some("John Doe".to_string().into()),
808 card_type: Some("Visa".to_string().into()),
809 verification_number: Some("123".to_string().into()),
810 pin: None,
811 expiry_date: Some(
812 credential_exchange_format::EditableFieldYearMonth {
813 year: 2026,
814 month: Month::December,
815 }
816 .into(),
817 ),
818 valid_from: None,
819 })),
820 Credential::Note(Box::new(NoteCredential {
821 content: "This note should be added to the credit card cipher."
822 .to_string()
823 .into(),
824 })),
825 ],
826 tags: None,
827 extensions: None,
828 scope: None,
829 };
830
831 let ciphers: Vec<ImportingCipher> = parse_item(item);
832 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
834
835 assert_eq!(cipher.name, "Credit Card with Note");
836 assert_eq!(
837 cipher.notes,
838 Some("This note should be added to the credit card cipher.".to_string())
839 );
840
841 match &cipher.r#type {
842 CipherType::Card(_) => (), _ => panic!("Expected Card cipher with note content"),
844 };
845 }
846
847 #[test]
848 fn test_note_as_part_of_wifi() {
849 use credential_exchange_format::{
850 Credential, EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential,
851 };
852
853 let item = Item {
854 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
855 creation_at: Some(1706613834),
856 modified_at: Some(1706623773),
857 title: "WiFi with Note".to_string(),
858 subtitle: None,
859 favorite: None,
860 credentials: vec![
861 Credential::Wifi(Box::new(WifiCredential {
862 ssid: Some("MyNetwork".to_string().into()),
863 passphrase: Some("password123".to_string().into()),
864 network_security_type: Some(
865 EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
866 ),
867 hidden: Some(false.into()),
868 })),
869 Credential::Note(Box::new(NoteCredential {
870 content: "This note should be added to the WiFi cipher."
871 .to_string()
872 .into(),
873 })),
874 ],
875 tags: None,
876 extensions: None,
877 scope: None,
878 };
879
880 let ciphers: Vec<ImportingCipher> = parse_item(item);
881 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
883
884 assert_eq!(cipher.name, "WiFi with Note");
885 assert_eq!(
886 cipher.notes,
887 Some("This note should be added to the WiFi cipher.".to_string())
888 );
889
890 match &cipher.r#type {
891 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher with note content"),
893 };
894
895 assert!(!cipher.fields.is_empty());
897 }
898
899 #[test]
900 fn test_credit_card_empty_title_uses_cardholder_name() {
901 let item = Item {
902 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
903 creation_at: Some(1706613834),
904 modified_at: Some(1706623773),
905 title: "".to_string(), subtitle: None,
907 favorite: None,
908 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
909 number: Some("1234 5678 9012 3456".to_string().into()),
910 full_name: Some("Jane Smith".to_string().into()), card_type: Some("Visa".to_string().into()),
912 verification_number: Some("456".to_string().into()),
913 pin: None,
914 expiry_date: Some(
915 EditableFieldYearMonth {
916 year: 2027,
917 month: Month::March,
918 }
919 .into(),
920 ),
921 valid_from: None,
922 }))],
923 tags: None,
924 extensions: None,
925 scope: None,
926 };
927
928 let ciphers: Vec<ImportingCipher> = parse_item(item);
929 assert_eq!(ciphers.len(), 1);
930 let cipher = ciphers.first().unwrap();
931
932 assert_eq!(cipher.name, "Jane Smith");
934
935 let card = match &cipher.r#type {
936 CipherType::Card(card) => card,
937 _ => panic!("Expected card"),
938 };
939
940 assert_eq!(card.cardholder_name, Some("Jane Smith".to_string()));
941 }
942
943 #[test]
944 fn test_credit_card_blank_title_uses_cardholder_name() {
945 let item = Item {
946 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
947 creation_at: Some(1706613834),
948 modified_at: Some(1706623773),
949 title: " ".to_string(), subtitle: None,
951 favorite: None,
952 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
953 number: Some("1234 5678 9012 3456".to_string().into()),
954 full_name: Some("John Doe".to_string().into()),
955 card_type: Some("Mastercard".to_string().into()),
956 verification_number: Some("789".to_string().into()),
957 pin: None,
958 expiry_date: None,
959 valid_from: None,
960 }))],
961 tags: None,
962 extensions: None,
963 scope: None,
964 };
965
966 let ciphers: Vec<ImportingCipher> = parse_item(item);
967 assert_eq!(ciphers.len(), 1);
968 let cipher = ciphers.first().unwrap();
969
970 assert_eq!(cipher.name, "John Doe");
972 }
973
974 #[test]
975 fn test_credit_card_empty_title_no_cardholder_uses_fallback() {
976 let item = Item {
977 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
978 creation_at: Some(1706613834),
979 modified_at: Some(1706623773),
980 title: "".to_string(), subtitle: None,
982 favorite: None,
983 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
984 number: Some("1234 5678 9012 3456".to_string().into()),
985 full_name: None, card_type: Some("Visa".to_string().into()),
987 verification_number: Some("123".to_string().into()),
988 pin: None,
989 expiry_date: None,
990 valid_from: None,
991 }))],
992 tags: None,
993 extensions: None,
994 scope: None,
995 };
996
997 let ciphers: Vec<ImportingCipher> = parse_item(item);
998 assert_eq!(ciphers.len(), 1);
999 let cipher = ciphers.first().unwrap();
1000
1001 assert_eq!(cipher.name, "Untitled Card");
1003 }
1004
1005 #[test]
1006 fn test_credit_card_with_title_ignores_cardholder_name() {
1007 let item = Item {
1008 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
1009 creation_at: Some(1706613834),
1010 modified_at: Some(1706623773),
1011 title: "My Business Card".to_string(), subtitle: None,
1013 favorite: None,
1014 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
1015 number: Some("1234 5678 9012 3456".to_string().into()),
1016 full_name: Some("Jane Smith".to_string().into()),
1017 card_type: Some("Visa".to_string().into()),
1018 verification_number: Some("456".to_string().into()),
1019 pin: None,
1020 expiry_date: None,
1021 valid_from: None,
1022 }))],
1023 tags: None,
1024 extensions: None,
1025 scope: None,
1026 };
1027
1028 let ciphers: Vec<ImportingCipher> = parse_item(item);
1029 assert_eq!(ciphers.len(), 1);
1030 let cipher = ciphers.first().unwrap();
1031
1032 assert_eq!(cipher.name, "My Business Card");
1034 }
1035
1036 #[test]
1037 fn test_note_as_part_of_identity() {
1038 use credential_exchange_format::{AddressCredential, Credential, Item, NoteCredential};
1039
1040 let item = Item {
1041 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
1042 creation_at: Some(1706613834),
1043 modified_at: Some(1706623773),
1044 title: "Address with Note".to_string(),
1045 subtitle: None,
1046 favorite: None,
1047 credentials: vec![
1048 Credential::Address(Box::new(AddressCredential {
1049 street_address: Some("123 Main St".to_string().into()),
1050 city: Some("Springfield".to_string().into()),
1051 territory: Some("CA".to_string().into()),
1052 postal_code: Some("12345".to_string().into()),
1053 country: Some("US".to_string().into()),
1054 tel: Some("+1-555-123-4567".to_string().into()),
1055 })),
1056 Credential::Note(Box::new(NoteCredential {
1057 content: "This note should be added to the address identity cipher."
1058 .to_string()
1059 .into(),
1060 })),
1061 ],
1062 tags: None,
1063 extensions: None,
1064 scope: None,
1065 };
1066
1067 let ciphers: Vec<ImportingCipher> = parse_item(item);
1068 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
1070
1071 assert_eq!(cipher.name, "Address with Note");
1072 assert_eq!(
1073 cipher.notes,
1074 Some("This note should be added to the address identity cipher.".to_string())
1075 );
1076
1077 match &cipher.r#type {
1078 CipherType::Identity(_) => (), _ => panic!("Expected Identity cipher"),
1080 };
1081 }
1082
1083 #[test]
1084 fn test_wifi_with_note_and_custom_fields() {
1085 use bitwarden_vault::FieldType;
1086 use credential_exchange_format::{
1087 Credential, CustomFieldsCredential, EditableFieldValue,
1088 EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential,
1089 };
1090
1091 let item = Item {
1092 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
1093 creation_at: Some(1706613834),
1094 modified_at: Some(1706623773),
1095 title: "Wireless Router".to_string(),
1096 subtitle: None,
1097 favorite: None,
1098 credentials: vec![
1099 Credential::Wifi(Box::new(WifiCredential {
1100 ssid: Some("networker".to_string().into()),
1101 passphrase: Some("zhc6KLx9CD7Kj2RV9vPF".to_string().into()),
1102 network_security_type: Some(
1103 EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
1104 ),
1105 hidden: None,
1106 })),
1107 Credential::Note(Box::new(NoteCredential {
1108 content: "My notes heigfkfdkkcmdwkkfkckekfkjf".to_string().into(),
1109 })),
1110 Credential::CustomFields(Box::new(CustomFieldsCredential {
1111 id: None,
1112 label: None,
1113 fields: vec![
1114 EditableFieldValue::String("My Station".to_string().into()),
1115 EditableFieldValue::ConcealedString(
1116 "hf6LW9UMmaxDg4sy6YCv".to_string().into(),
1117 ),
1118 EditableFieldValue::String("1.1.1.3".to_string().into()),
1119 EditableFieldValue::String("".to_string().into()),
1120 EditableFieldValue::ConcealedString(
1121 "kJaFcs7KwETkrmnpiQER".to_string().into(),
1122 ),
1123 ],
1124 extensions: vec![],
1125 })),
1126 ],
1127 tags: None,
1128 extensions: None,
1129 scope: None,
1130 };
1131
1132 let ciphers: Vec<ImportingCipher> = parse_item(item);
1133 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
1136 assert_eq!(cipher.name, "Wireless Router");
1137 assert_eq!(
1138 cipher.notes,
1139 Some("My notes heigfkfdkkcmdwkkfkckekfkjf".to_string())
1140 );
1141
1142 match &cipher.r#type {
1143 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher"),
1145 };
1146
1147 assert_eq!(cipher.fields.len(), 8); assert!(
1152 cipher
1153 .fields
1154 .iter()
1155 .any(|f| f.name.as_deref() == Some("SSID")
1156 && f.value.as_deref() == Some("networker"))
1157 );
1158 assert!(
1159 cipher
1160 .fields
1161 .iter()
1162 .any(|f| f.name.as_deref() == Some("Passphrase")
1163 && f.value.as_deref() == Some("zhc6KLx9CD7Kj2RV9vPF")
1164 && f.r#type == FieldType::Hidden as u8)
1165 );
1166 assert!(
1167 cipher
1168 .fields
1169 .iter()
1170 .any(|f| f.name.as_deref() == Some("Network Security Type")
1171 && f.value.as_deref() == Some("WPA3 Personal"))
1172 );
1173
1174 assert!(
1176 cipher
1177 .fields
1178 .iter()
1179 .any(|f| f.value.as_deref() == Some("My Station"))
1180 );
1181 assert!(
1182 cipher
1183 .fields
1184 .iter()
1185 .any(|f| f.value.as_deref() == Some("hf6LW9UMmaxDg4sy6YCv")
1186 && f.r#type == FieldType::Hidden as u8)
1187 );
1188 assert!(
1189 cipher
1190 .fields
1191 .iter()
1192 .any(|f| f.value.as_deref() == Some("1.1.1.3"))
1193 );
1194 }
1195}