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