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