Skip to main content

bitwarden_exporters/cxf/
import.rs

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