From bcf040542096c5f051e2409831cf0f75d43d3561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 25 Jul 2025 11:10:31 +0200 Subject: [PATCH 01/17] address --- crates/bitwarden-exporters/src/cxf/address.rs | 175 ++++++++++++++++++ crates/bitwarden-exporters/src/cxf/import.rs | 61 ++++++ crates/bitwarden-exporters/src/cxf/mod.rs | 1 + 3 files changed, 237 insertions(+) create mode 100644 crates/bitwarden-exporters/src/cxf/address.rs diff --git a/crates/bitwarden-exporters/src/cxf/address.rs b/crates/bitwarden-exporters/src/cxf/address.rs new file mode 100644 index 000000000..46d903658 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/address.rs @@ -0,0 +1,175 @@ +use credential_exchange_format::AddressCredential; + +use crate::Identity; + +/// Convert address credentials to Identity following the CXF mapping convention +/// According to the mapping specification: +/// - streetAddress: EditableField<"string"> → Identity::address1 +/// - city: EditableField<"string"> → Identity::city +/// - territory: EditableField<"subdivision-code"> → Identity::state +/// - country: EditableField<"country-code"> → Identity::country +/// - tel: EditableField<"string"> → Identity::phone +/// - postalCode: EditableField<"string"> → Identity::postal_code (not in mapping but common field) +pub fn address_to_identity(address: &AddressCredential) -> Identity { + Identity { + title: None, + first_name: None, + middle_name: None, + last_name: None, + address1: address.street_address.as_ref().map(|s| s.value.0.clone()), + address2: None, + address3: None, + city: address.city.as_ref().map(|c| c.value.0.clone()), + state: address.territory.as_ref().map(|t| t.value.0.clone()), + postal_code: address.postal_code.as_ref().map(|p| p.value.0.clone()), + country: address.country.as_ref().map(|c| c.value.0.clone()), + company: None, + email: None, + phone: address.tel.as_ref().map(|t| t.value.0.clone()), + ssn: None, + username: None, + passport_number: None, + license_number: None, + } +} + +#[cfg(test)] +mod tests { + use credential_exchange_format::{ + EditableField, EditableFieldCountryCode, EditableFieldString, EditableFieldSubdivisionCode, + }; + + use super::*; + + fn create_address_credential( + street_address: Option<&str>, + city: Option<&str>, + territory: Option<&str>, + country: Option<&str>, + tel: Option<&str>, + postal_code: Option<&str>, + ) -> AddressCredential { + AddressCredential { + street_address: street_address.map(|s| EditableField { + id: None, + value: EditableFieldString(s.to_string()), + label: None, + extensions: None, + }), + city: city.map(|c| EditableField { + id: None, + value: EditableFieldString(c.to_string()), + label: None, + extensions: None, + }), + territory: territory.map(|t| EditableField { + id: None, + value: EditableFieldSubdivisionCode(t.to_string()), + label: None, + extensions: None, + }), + country: country.map(|c| EditableField { + id: None, + value: EditableFieldCountryCode(c.to_string()), + label: None, + extensions: None, + }), + tel: tel.map(|t| EditableField { + id: None, + value: EditableFieldString(t.to_string()), + label: None, + extensions: None, + }), + postal_code: postal_code.map(|p| EditableField { + id: None, + value: EditableFieldString(p.to_string()), + label: None, + extensions: None, + }), + } + } + + #[test] + fn test_address_to_identity_full() { + let address = create_address_credential( + Some("123 Main Street"), + Some("Springfield"), + Some("CA"), + Some("US"), + Some("+1-555-123-4567"), + Some("12345"), + ); + + let identity = address_to_identity(&address); + + assert_eq!(identity.address1, Some("123 Main Street".to_string())); + assert_eq!(identity.city, Some("Springfield".to_string())); + assert_eq!(identity.state, Some("CA".to_string())); + assert_eq!(identity.country, Some("US".to_string())); + assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); + assert_eq!(identity.postal_code, Some("12345".to_string())); + + // Verify unmapped fields are None + assert_eq!(identity.title, None); + assert_eq!(identity.first_name, None); + assert_eq!(identity.middle_name, None); + assert_eq!(identity.last_name, None); + assert_eq!(identity.address2, None); + assert_eq!(identity.address3, None); + assert_eq!(identity.company, None); + assert_eq!(identity.email, None); + assert_eq!(identity.ssn, None); + assert_eq!(identity.username, None); + assert_eq!(identity.passport_number, None); + assert_eq!(identity.license_number, None); + } + + #[test] + fn test_address_to_identity_minimal() { + let address = create_address_credential(Some("456 Oak St"), None, None, None, None, None); + + let identity = address_to_identity(&address); + + assert_eq!(identity.address1, Some("456 Oak St".to_string())); + assert_eq!(identity.city, None); + assert_eq!(identity.state, None); + assert_eq!(identity.country, None); + assert_eq!(identity.phone, None); + assert_eq!(identity.postal_code, None); + } + + #[test] + fn test_address_to_identity_empty() { + let address = create_address_credential(None, None, None, None, None, None); + + let identity = address_to_identity(&address); + + assert_eq!(identity.address1, None); + assert_eq!(identity.city, None); + assert_eq!(identity.state, None); + assert_eq!(identity.country, None); + assert_eq!(identity.phone, None); + assert_eq!(identity.postal_code, None); + } + + #[test] + fn test_address_to_identity_partial() { + let address = create_address_credential( + Some("789 Pine Ave"), + Some("Portland"), + Some("OR"), + None, + Some("555-0123"), + None, + ); + + let identity = address_to_identity(&address); + + assert_eq!(identity.address1, Some("789 Pine Ave".to_string())); + assert_eq!(identity.city, Some("Portland".to_string())); + assert_eq!(identity.state, Some("OR".to_string())); + assert_eq!(identity.country, None); + assert_eq!(identity.phone, Some("555-0123".to_string())); + assert_eq!(identity.postal_code, None); + } +} diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 89c28244b..3cb2ac3c6 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -6,6 +6,7 @@ use credential_exchange_format::{ use crate::{ cxf::{ + address::address_to_identity, api_key::api_key_to_fields, login::{to_fields, to_login}, wifi::wifi_to_fields, @@ -143,6 +144,26 @@ fn parse_item(value: Item) -> Vec { }) } + // Address credentials + if !grouped.address.is_empty() { + let address = grouped.address.first().expect("Address is not empty"); + + let identity = address_to_identity(address); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: vec![], + revision_date, + creation_date, + deleted_date: None, + }) + } + output } @@ -184,6 +205,10 @@ fn group_credentials_by_type(credentials: Vec) -> GroupedCredentials Credential::Wifi(wifi) => Some(wifi.as_ref()), _ => None, }), + address: filter_credentials(&credentials, |c| match c { + Credential::Address(address) => Some(address.as_ref()), + _ => None, + }), } } @@ -193,6 +218,7 @@ struct GroupedCredentials { passkey: Vec, credit_card: Vec, wifi: Vec, + address: Vec, } #[cfg(test)] @@ -338,6 +364,41 @@ mod tests { ); } + #[test] + fn test_address_integration() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + + let ciphers = result.unwrap(); + + // Find the address cipher - should be titled "House Address" + let address_cipher = ciphers + .iter() + .find(|c| c.name == "House Address") + .expect("Should find House Address item"); + + // Verify it's an Identity cipher + let identity = match &address_cipher.r#type { + CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for address"), + }; + + // Verify the address mapping + assert_eq!(identity.address1, Some("123 Main Street".to_string())); + assert_eq!(identity.city, Some("Springfield".to_string())); + assert_eq!(identity.state, Some("CA".to_string())); + assert_eq!(identity.country, Some("US".to_string())); + assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); + assert_eq!(identity.postal_code, Some("12345".to_string())); + + // Verify unmapped fields are None + assert_eq!(identity.title, None); + assert_eq!(identity.first_name, None); + assert_eq!(identity.last_name, None); + assert_eq!(identity.company, None); + assert_eq!(identity.email, None); + } + #[test] fn test_credit_card() { let item = Item { diff --git a/crates/bitwarden-exporters/src/cxf/mod.rs b/crates/bitwarden-exporters/src/cxf/mod.rs index dcd558ead..1f7d7d60f 100644 --- a/crates/bitwarden-exporters/src/cxf/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/mod.rs @@ -12,6 +12,7 @@ pub(crate) use export::build_cxf; pub use export::Account; mod import; pub(crate) use import::parse_cxf; +mod address; mod api_key; mod card; mod editable_field; From 723a90d612591fad09eb85f9abd9a71dfd72e419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 25 Jul 2025 12:05:28 +0200 Subject: [PATCH 02/17] identity docs --- crates/bitwarden-exporters/src/cxf/address.rs | 175 --- .../bitwarden-exporters/src/cxf/identity.rs | 1051 +++++++++++++++++ crates/bitwarden-exporters/src/cxf/import.rs | 250 +++- crates/bitwarden-exporters/src/cxf/mod.rs | 3 +- 4 files changed, 1256 insertions(+), 223 deletions(-) delete mode 100644 crates/bitwarden-exporters/src/cxf/address.rs create mode 100644 crates/bitwarden-exporters/src/cxf/identity.rs diff --git a/crates/bitwarden-exporters/src/cxf/address.rs b/crates/bitwarden-exporters/src/cxf/address.rs deleted file mode 100644 index 46d903658..000000000 --- a/crates/bitwarden-exporters/src/cxf/address.rs +++ /dev/null @@ -1,175 +0,0 @@ -use credential_exchange_format::AddressCredential; - -use crate::Identity; - -/// Convert address credentials to Identity following the CXF mapping convention -/// According to the mapping specification: -/// - streetAddress: EditableField<"string"> → Identity::address1 -/// - city: EditableField<"string"> → Identity::city -/// - territory: EditableField<"subdivision-code"> → Identity::state -/// - country: EditableField<"country-code"> → Identity::country -/// - tel: EditableField<"string"> → Identity::phone -/// - postalCode: EditableField<"string"> → Identity::postal_code (not in mapping but common field) -pub fn address_to_identity(address: &AddressCredential) -> Identity { - Identity { - title: None, - first_name: None, - middle_name: None, - last_name: None, - address1: address.street_address.as_ref().map(|s| s.value.0.clone()), - address2: None, - address3: None, - city: address.city.as_ref().map(|c| c.value.0.clone()), - state: address.territory.as_ref().map(|t| t.value.0.clone()), - postal_code: address.postal_code.as_ref().map(|p| p.value.0.clone()), - country: address.country.as_ref().map(|c| c.value.0.clone()), - company: None, - email: None, - phone: address.tel.as_ref().map(|t| t.value.0.clone()), - ssn: None, - username: None, - passport_number: None, - license_number: None, - } -} - -#[cfg(test)] -mod tests { - use credential_exchange_format::{ - EditableField, EditableFieldCountryCode, EditableFieldString, EditableFieldSubdivisionCode, - }; - - use super::*; - - fn create_address_credential( - street_address: Option<&str>, - city: Option<&str>, - territory: Option<&str>, - country: Option<&str>, - tel: Option<&str>, - postal_code: Option<&str>, - ) -> AddressCredential { - AddressCredential { - street_address: street_address.map(|s| EditableField { - id: None, - value: EditableFieldString(s.to_string()), - label: None, - extensions: None, - }), - city: city.map(|c| EditableField { - id: None, - value: EditableFieldString(c.to_string()), - label: None, - extensions: None, - }), - territory: territory.map(|t| EditableField { - id: None, - value: EditableFieldSubdivisionCode(t.to_string()), - label: None, - extensions: None, - }), - country: country.map(|c| EditableField { - id: None, - value: EditableFieldCountryCode(c.to_string()), - label: None, - extensions: None, - }), - tel: tel.map(|t| EditableField { - id: None, - value: EditableFieldString(t.to_string()), - label: None, - extensions: None, - }), - postal_code: postal_code.map(|p| EditableField { - id: None, - value: EditableFieldString(p.to_string()), - label: None, - extensions: None, - }), - } - } - - #[test] - fn test_address_to_identity_full() { - let address = create_address_credential( - Some("123 Main Street"), - Some("Springfield"), - Some("CA"), - Some("US"), - Some("+1-555-123-4567"), - Some("12345"), - ); - - let identity = address_to_identity(&address); - - assert_eq!(identity.address1, Some("123 Main Street".to_string())); - assert_eq!(identity.city, Some("Springfield".to_string())); - assert_eq!(identity.state, Some("CA".to_string())); - assert_eq!(identity.country, Some("US".to_string())); - assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); - assert_eq!(identity.postal_code, Some("12345".to_string())); - - // Verify unmapped fields are None - assert_eq!(identity.title, None); - assert_eq!(identity.first_name, None); - assert_eq!(identity.middle_name, None); - assert_eq!(identity.last_name, None); - assert_eq!(identity.address2, None); - assert_eq!(identity.address3, None); - assert_eq!(identity.company, None); - assert_eq!(identity.email, None); - assert_eq!(identity.ssn, None); - assert_eq!(identity.username, None); - assert_eq!(identity.passport_number, None); - assert_eq!(identity.license_number, None); - } - - #[test] - fn test_address_to_identity_minimal() { - let address = create_address_credential(Some("456 Oak St"), None, None, None, None, None); - - let identity = address_to_identity(&address); - - assert_eq!(identity.address1, Some("456 Oak St".to_string())); - assert_eq!(identity.city, None); - assert_eq!(identity.state, None); - assert_eq!(identity.country, None); - assert_eq!(identity.phone, None); - assert_eq!(identity.postal_code, None); - } - - #[test] - fn test_address_to_identity_empty() { - let address = create_address_credential(None, None, None, None, None, None); - - let identity = address_to_identity(&address); - - assert_eq!(identity.address1, None); - assert_eq!(identity.city, None); - assert_eq!(identity.state, None); - assert_eq!(identity.country, None); - assert_eq!(identity.phone, None); - assert_eq!(identity.postal_code, None); - } - - #[test] - fn test_address_to_identity_partial() { - let address = create_address_credential( - Some("789 Pine Ave"), - Some("Portland"), - Some("OR"), - None, - Some("555-0123"), - None, - ); - - let identity = address_to_identity(&address); - - assert_eq!(identity.address1, Some("789 Pine Ave".to_string())); - assert_eq!(identity.city, Some("Portland".to_string())); - assert_eq!(identity.state, Some("OR".to_string())); - assert_eq!(identity.country, None); - assert_eq!(identity.phone, Some("555-0123".to_string())); - assert_eq!(identity.postal_code, None); - } -} diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs new file mode 100644 index 000000000..c2ddf3579 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -0,0 +1,1051 @@ +use bitwarden_vault::FieldType; +use credential_exchange_format::{ + AddressCredential, DriversLicenseCredential, PassportCredential, PersonNameCredential, +}; + +use crate::{Field, Identity}; + +use credential_exchange_format::{ + EditableField, EditableFieldCountryCode, EditableFieldDate, EditableFieldString, +}; + +/// Helper function to create a custom field from an EditableField +fn create_text_field_from_string( + editable_field: Option<&EditableField>, + field_name: &str, +) -> Option { + editable_field.map(|field| Field { + name: Some(field_name.to_string()), + value: Some(field.value.0.clone()), + r#type: FieldType::Text as u8, + linked_id: None, + }) +} + +/// Helper function to create a custom field from an EditableField +fn create_text_field_from_country_code( + editable_field: Option<&EditableField>, + field_name: &str, +) -> Option { + editable_field.map(|field| Field { + name: Some(field_name.to_string()), + value: Some(field.value.0.clone()), + r#type: FieldType::Text as u8, + linked_id: None, + }) +} + +/// Helper function to create a custom field from an EditableField +fn create_text_field_from_date( + editable_field: Option<&EditableField>, + field_name: &str, +) -> Option { + editable_field.map(|field| Field { + name: Some(field_name.to_string()), + value: Some(field.value.0.to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }) +} + +/// Convert address credentials to Identity (no custom fields needed for address) +/// According to the mapping specification: +/// - streetAddress: EditableField<"string"> → Identity::address1 +/// - city: EditableField<"string"> → Identity::city +/// - territory: EditableField<"subdivision-code"> → Identity::state +/// - country: EditableField<"country-code"> → Identity::country +/// - tel: EditableField<"string"> → Identity::phone +/// - postalCode: EditableField<"string"> → Identity::postal_code +pub fn address_to_identity(address: &AddressCredential) -> (Identity, Vec) { + let identity = Identity { + title: None, + first_name: None, + middle_name: None, + last_name: None, + address1: address.street_address.as_ref().map(|s| s.value.0.clone()), + address2: None, + address3: None, + city: address.city.as_ref().map(|c| c.value.0.clone()), + state: address.territory.as_ref().map(|t| t.value.0.clone()), + postal_code: address.postal_code.as_ref().map(|p| p.value.0.clone()), + country: address.country.as_ref().map(|c| c.value.0.clone()), + company: None, + email: None, + phone: address.tel.as_ref().map(|t| t.value.0.clone()), + ssn: None, + username: None, + passport_number: None, + license_number: None, + }; + + // Address credentials don't have unmapped fields, so no custom fields needed + (identity, vec![]) +} + +/// Convert passport credentials to Identity and custom fields +/// According to CXF mapping document: +/// - passportNumber: EditableField<"string"> → Identity::passport_number +/// - nationalIdentificationNumber: EditableField<"string"> → Identity::ssn +/// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) +/// - All other fields → CustomFields +pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec) { + // Split full name into first and last name if available + let (first_name, last_name) = if let Some(full_name) = &passport.full_name { + let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); + match name_parts.len() { + 0 => (None, None), + 1 => (Some(name_parts[0].to_string()), None), + _ => { + let first = name_parts[0].to_string(); + let last = name_parts[1..].join(" "); + (Some(first), Some(last)) + } + } + } else { + (None, None) + }; + + let identity = Identity { + title: None, + first_name, + middle_name: None, + last_name, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, // According to mapping doc, issuingCountry should be CustomField + company: None, + email: None, + phone: None, + // Map nationalIdentificationNumber to ssn as closest available field + ssn: passport + .national_identification_number + .as_ref() + .map(|n| n.value.0.clone()), + username: None, + passport_number: passport.passport_number.as_ref().map(|p| p.value.0.clone()), + license_number: None, + }; + + // Create custom fields for unmapped data according to CXF mapping document + let mut custom_fields = Vec::new(); + + if let Some(field) = + create_text_field_from_country_code(passport.issuing_country.as_ref(), "Issuing Country") + { + custom_fields.push(field); + } + if let Some(field) = create_text_field_from_string(passport.nationality.as_ref(), "Nationality") + { + custom_fields.push(field); + } + if let Some(field) = create_text_field_from_date(passport.birth_date.as_ref(), "Birth Date") { + custom_fields.push(field); + } + if let Some(field) = create_text_field_from_string(passport.birth_place.as_ref(), "Birth Place") + { + custom_fields.push(field); + } + if let Some(field) = create_text_field_from_string(passport.sex.as_ref(), "Sex") { + custom_fields.push(field); + } + if let Some(field) = create_text_field_from_date(passport.issue_date.as_ref(), "Issue Date") { + custom_fields.push(field); + } + if let Some(field) = create_text_field_from_date(passport.expiry_date.as_ref(), "Expiry Date") { + custom_fields.push(field); + } + if let Some(field) = + create_text_field_from_string(passport.issuing_authority.as_ref(), "Issuing Authority") + { + custom_fields.push(field); + } + if let Some(field) = + create_text_field_from_string(passport.passport_type.as_ref(), "Passport Type") + { + custom_fields.push(field); + } + + (identity, custom_fields) +} + +/// Convert person name credentials to Identity and custom fields +/// According to CXF mapping: +/// - title: EditableField<"string"> → Identity::title +/// - given: EditableField<"string"> → Identity::first_name +/// - given2: EditableField<"string"> → Identity::middle_name +/// - surname: EditableField<"string"> → Identity::last_name +/// - surnamePrefix + surname + surname2: combine for complete last name +/// - credentials: EditableField<"string"> → Identity::company (as professional credentials) +/// - Other fields → CustomFields +pub fn person_name_to_identity(person_name: &PersonNameCredential) -> (Identity, Vec) { + // Construct complete last name from surnamePrefix, surname, and surname2 + let last_name = { + let mut parts = Vec::new(); + + if let Some(prefix) = &person_name.surname_prefix { + parts.push(prefix.value.0.clone()); + } + if let Some(surname) = &person_name.surname { + parts.push(surname.value.0.clone()); + } + if let Some(surname2) = &person_name.surname2 { + parts.push(surname2.value.0.clone()); + } + + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } + }; + + let identity = Identity { + title: person_name.title.as_ref().map(|t| t.value.0.clone()), + first_name: person_name.given.as_ref().map(|g| g.value.0.clone()), + middle_name: person_name.given2.as_ref().map(|g2| g2.value.0.clone()), + last_name, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, + // Map credentials (e.g., "PhD") to company field as professional qualifications + company: person_name.credentials.as_ref().map(|c| c.value.0.clone()), + email: None, + phone: None, + ssn: None, + username: None, + passport_number: None, + license_number: None, + }; + + // Create custom fields for unmapped data + let mut custom_fields = Vec::new(); + + if let Some(field) = + create_text_field_from_string(person_name.given_informal.as_ref(), "Informal Given Name") + { + custom_fields.push(field); + } + if let Some(field) = + create_text_field_from_string(person_name.generation.as_ref(), "Generation") + { + custom_fields.push(field); + } + + (identity, custom_fields) +} + +/// Convert drivers license credentials to Identity and custom fields +/// According to CXF mapping document: +/// - licenseNumber: EditableField<"string"> → Identity::license_number +/// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) +/// - territory: EditableField<"subdivision-code"> → Identity::state +/// - country: EditableField<"country-code"> → Identity::country +/// - All other fields → CustomFields +pub fn drivers_license_to_identity( + drivers_license: &DriversLicenseCredential, +) -> (Identity, Vec) { + // Split full name into first and last name if available + let (first_name, last_name) = if let Some(full_name) = &drivers_license.full_name { + let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); + match name_parts.len() { + 0 => (None, None), + 1 => (Some(name_parts[0].to_string()), None), + _ => { + let first = name_parts[0].to_string(); + let last = name_parts[1..].join(" "); + (Some(first), Some(last)) + } + } + } else { + (None, None) + }; + + let identity = Identity { + title: None, + first_name, + middle_name: None, + last_name, + address1: None, + address2: None, + address3: None, + city: None, + // Map territory (state/province) to state field + state: drivers_license + .territory + .as_ref() + .map(|t| t.value.0.clone()), + postal_code: None, + // Map country to country field + country: drivers_license.country.as_ref().map(|c| c.value.0.clone()), + company: None, // According to mapping doc, issuingAuthority should be CustomField + email: None, + phone: None, + ssn: None, + username: None, + passport_number: None, + license_number: drivers_license + .license_number + .as_ref() + .map(|l| l.value.0.clone()), + }; + + // Create custom fields for unmapped data according to CXF mapping document + let mut custom_fields = Vec::new(); + + if let Some(field) = + create_text_field_from_date(drivers_license.birth_date.as_ref(), "Birth Date") + { + custom_fields.push(field); + } + if let Some(field) = + create_text_field_from_date(drivers_license.issue_date.as_ref(), "Issue Date") + { + custom_fields.push(field); + } + if let Some(field) = + create_text_field_from_date(drivers_license.expiry_date.as_ref(), "Expiry Date") + { + custom_fields.push(field); + } + if let Some(field) = create_text_field_from_string( + drivers_license.issuing_authority.as_ref(), + "Issuing Authority", + ) { + custom_fields.push(field); + } + if let Some(field) = + create_text_field_from_string(drivers_license.license_class.as_ref(), "License Class") + { + custom_fields.push(field); + } + + (identity, custom_fields) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use credential_exchange_format::{ + EditableField, EditableFieldCountryCode, EditableFieldString, EditableFieldSubdivisionCode, + }; + + use crate::cxf::import::parse_cxf; + + use super::*; + + fn load_sample_cxf() -> Result, crate::cxf::CxfError> { + // Read the actual CXF example file + let cxf_data = fs::read_to_string("resources/cxf_example.json") + .expect("Should be able to read cxf_example.json"); + + // Workaround for library bug: the example file has "integrityHash" but the library expects + // "integrationHash" + let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); + + parse_cxf(fixed_cxf_data) + } + + fn create_address_credential( + street_address: Option<&str>, + city: Option<&str>, + territory: Option<&str>, + country: Option<&str>, + tel: Option<&str>, + postal_code: Option<&str>, + ) -> AddressCredential { + AddressCredential { + street_address: street_address.map(|s| EditableField { + id: None, + value: EditableFieldString(s.to_string()), + label: None, + extensions: None, + }), + city: city.map(|c| EditableField { + id: None, + value: EditableFieldString(c.to_string()), + label: None, + extensions: None, + }), + territory: territory.map(|t| EditableField { + id: None, + value: EditableFieldSubdivisionCode(t.to_string()), + label: None, + extensions: None, + }), + country: country.map(|c| EditableField { + id: None, + value: EditableFieldCountryCode(c.to_string()), + label: None, + extensions: None, + }), + tel: tel.map(|t| EditableField { + id: None, + value: EditableFieldString(t.to_string()), + label: None, + extensions: None, + }), + postal_code: postal_code.map(|p| EditableField { + id: None, + value: EditableFieldString(p.to_string()), + label: None, + extensions: None, + }), + } + } + + #[test] + fn test_address_to_identity_full() { + let address = create_address_credential( + Some("123 Main Street"), + Some("Springfield"), + Some("CA"), + Some("US"), + Some("+1-555-123-4567"), + Some("12345"), + ); + + let (identity, custom_fields) = address_to_identity(&address); + + assert_eq!(identity.address1, Some("123 Main Street".to_string())); + assert_eq!(identity.city, Some("Springfield".to_string())); + assert_eq!(identity.state, Some("CA".to_string())); + assert_eq!(identity.country, Some("US".to_string())); + assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); + assert_eq!(identity.postal_code, Some("12345".to_string())); + + // Address has no custom fields + assert_eq!(custom_fields.len(), 0); + } + + fn create_passport_credential( + passport_number: Option<&str>, + issuing_country: Option<&str>, + ) -> PassportCredential { + PassportCredential { + passport_number: passport_number.map(|p| EditableField { + id: None, + value: EditableFieldString(p.to_string()), + label: None, + extensions: None, + }), + issuing_country: issuing_country.map(|c| EditableField { + id: None, + value: EditableFieldCountryCode(c.to_string()), + label: None, + extensions: None, + }), + passport_type: None, + full_name: None, + birth_date: None, + issue_date: None, + expiry_date: None, + birth_place: None, + issuing_authority: None, + national_identification_number: None, + nationality: None, + sex: None, + } + } + + fn create_person_name_credential( + title: Option<&str>, + given: Option<&str>, + given2: Option<&str>, + surname: Option<&str>, + ) -> PersonNameCredential { + PersonNameCredential { + title: title.map(|t| EditableField { + id: None, + value: EditableFieldString(t.to_string()), + label: None, + extensions: None, + }), + given: given.map(|g| EditableField { + id: None, + value: EditableFieldString(g.to_string()), + label: None, + extensions: None, + }), + given2: given2.map(|g2| EditableField { + id: None, + value: EditableFieldString(g2.to_string()), + label: None, + extensions: None, + }), + surname: surname.map(|s| EditableField { + id: None, + value: EditableFieldString(s.to_string()), + label: None, + extensions: None, + }), + given_informal: None, + surname_prefix: None, + surname2: None, + credentials: None, + generation: None, + } + } + + fn create_drivers_license_credential( + license_number: Option<&str>, + full_name: Option<&str>, + ) -> DriversLicenseCredential { + DriversLicenseCredential { + license_number: license_number.map(|l| EditableField { + id: None, + value: EditableFieldString(l.to_string()), + label: None, + extensions: None, + }), + full_name: full_name.map(|f| EditableField { + id: None, + value: EditableFieldString(f.to_string()), + label: None, + extensions: None, + }), + birth_date: None, + issue_date: None, + expiry_date: None, + issuing_authority: None, + license_class: None, + country: None, + territory: None, + } + } + + #[test] + fn test_passport_to_identity() { + let passport = create_passport_credential(Some("A12345678"), Some("US")); + + let (identity, custom_fields) = passport_to_identity(&passport); + + assert_eq!(identity.passport_number, Some("A12345678".to_string())); + assert_eq!(identity.country, None); // Now custom field according to mapping + + // Verify other fields are None + assert_eq!(identity.title, None); + assert_eq!(identity.first_name, None); + assert_eq!(identity.address1, None); + + // Should have issuing country as custom field + assert_eq!(custom_fields.len(), 1); + assert_eq!(custom_fields[0].name, Some("Issuing Country".to_string())); + assert_eq!(custom_fields[0].value, Some("US".to_string())); + assert_eq!(custom_fields[0].r#type, FieldType::Text as u8); + } + + #[test] + fn test_person_name_to_identity() { + let person_name = + create_person_name_credential(Some("Dr."), Some("John"), Some("Michael"), Some("Doe")); + + let (identity, custom_fields) = person_name_to_identity(&person_name); + + assert_eq!(identity.title, Some("Dr.".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.middle_name, Some("Michael".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + + // Verify other fields are None + assert_eq!(identity.address1, None); + assert_eq!(identity.passport_number, None); + + // No unmapped fields in this test, so no custom fields + assert_eq!(custom_fields.len(), 0); + } + + #[test] + fn test_drivers_license_to_identity() { + let drivers_license = + create_drivers_license_credential(Some("D123456789"), Some("John Doe")); + + let (identity, custom_fields) = drivers_license_to_identity(&drivers_license); + + assert_eq!(identity.license_number, Some("D123456789".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + + // Verify other fields are None + assert_eq!(identity.title, None); + assert_eq!(identity.address1, None); + assert_eq!(identity.company, None); // Now custom field according to mapping + + // No unmapped fields in this test, so no custom fields + assert_eq!(custom_fields.len(), 0); + } + + #[test] + fn test_drivers_license_full_name_parsing() { + // Test single name + let dl_single = create_drivers_license_credential(None, Some("John")); + let (identity, _) = drivers_license_to_identity(&dl_single); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, None); + + // Test three names + let dl_three = create_drivers_license_credential(None, Some("John Michael Doe")); + let (identity, _) = drivers_license_to_identity(&dl_three); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Michael Doe".to_string())); + + // Test empty name + let dl_empty = create_drivers_license_credential(None, Some("")); + let (identity, _) = drivers_license_to_identity(&dl_empty); + assert_eq!(identity.first_name, None); + assert_eq!(identity.last_name, None); + } + + #[test] + fn test_address_integration_complete_data_mapping() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + + let ciphers = result.unwrap(); + + // Find the address cipher - should be titled "House Address" + let address_cipher = ciphers + .iter() + .find(|c| c.name == "House Address") + .expect("Should find House Address item"); + + // Verify it's an Identity cipher + let identity = match &address_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for address"), + }; + + // Verify ALL the address fields from cxf_example.json are mapped + // streetAddress → address1 + assert_eq!(identity.address1, Some("123 Main Street".to_string())); + // city → city + assert_eq!(identity.city, Some("Springfield".to_string())); + // territory → state + assert_eq!(identity.state, Some("CA".to_string())); + // country → country + assert_eq!(identity.country, Some("US".to_string())); + // tel → phone + assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); + // postalCode → postal_code + assert_eq!(identity.postal_code, Some("12345".to_string())); + } + + #[test] + fn test_passport_integration_complete_data_mapping() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + + let ciphers = result.unwrap(); + + // Find the passport cipher - should be titled "Passport" + let passport_cipher = ciphers + .iter() + .find(|c| c.name == "Passport") + .expect("Should find Passport item"); + + // Verify it's an Identity cipher + let identity = match &passport_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for passport"), + }; + + // Verify passport fields from cxf_example.json + // passportNumber → passport_number + assert_eq!(identity.passport_number, Some("A12345678".to_string())); + // nationalIdentificationNumber → ssn + assert_eq!(identity.ssn, Some("ID123456789".to_string())); + // fullName → first_name + last_name (split) + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + } + + #[test] + fn test_person_name_integration_complete_data_mapping() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + + let ciphers = result.unwrap(); + + // Find the person name cipher - should be titled "John Doe" + let person_name_cipher = ciphers + .iter() + .find(|c| c.name == "John Doe") + .expect("Should find John Doe item"); + + // Verify it's an Identity cipher + let identity = match &person_name_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for person name"), + }; + + // Verify ALL person name fields from cxf_example.json + // title → title + assert_eq!(identity.title, Some("Dr.".to_string())); + // given → first_name + assert_eq!(identity.first_name, Some("John".to_string())); + // given2 → middle_name + assert_eq!(identity.middle_name, Some("Michael".to_string())); + // surname → last_name (now includes surnamePrefix + surname + surname2) + assert_eq!(identity.last_name, Some("van Doe Smith".to_string())); + // credentials → company + assert_eq!(identity.company, Some("PhD".to_string())); + + // These should remain None for person-name-only credentials + assert_eq!(identity.address1, None); + assert_eq!(identity.passport_number, None); + assert_eq!(identity.license_number, None); + assert_eq!(identity.phone, None); + assert_eq!(identity.country, None); + } + + #[test] + fn test_drivers_license_integration_complete_data_mapping() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + + let ciphers = result.unwrap(); + + // Find the drivers license cipher - should be titled "Driver License" + let drivers_license_cipher = ciphers + .iter() + .find(|c| c.name == "Driver License") + .expect("Should find Driver License item"); + + // Verify it's an Identity cipher + let identity = match &drivers_license_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for drivers license"), + }; + + // Verify mapped fields according to CXF mapping document + assert_eq!(identity.license_number, Some("D12345678".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + assert_eq!(identity.state, Some("CA".to_string())); // territory → state + assert_eq!(identity.country, Some("US".to_string())); // country → country + + // issuingAuthority is now a custom field according to mapping document + assert_eq!(identity.company, None); + + // These should remain None for drivers-license-only credentials + assert_eq!(identity.address1, None); + assert_eq!(identity.passport_number, None); + assert_eq!(identity.phone, None); + assert_eq!(identity.email, None); + } + + #[test] + fn test_address_json_field_mapping() { + // Read the raw JSON file + let cxf_data = fs::read_to_string("resources/cxf_example.json") + .expect("Should be able to read cxf_example.json"); + let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); + + // Parse as generic JSON to inspect raw values + let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); + + // Find the address item + let address_item = json["accounts"][0]["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["title"] == "House Address") + .expect("Should find House Address item"); + + let address_cred = &address_item["credentials"][0]; + assert_eq!(address_cred["type"], "address"); + + // Extract all raw field values from JSON + let street_address = address_cred["streetAddress"]["value"].as_str().unwrap(); + let postal_code = address_cred["postalCode"]["value"].as_str().unwrap(); + let city = address_cred["city"]["value"].as_str().unwrap(); + let territory = address_cred["territory"]["value"].as_str().unwrap(); + let country = address_cred["country"]["value"].as_str().unwrap(); + let tel = address_cred["tel"]["value"].as_str().unwrap(); + + // Now test our mapping + let result = load_sample_cxf().unwrap(); + let address_cipher = result + .iter() + .find(|c| c.name == "House Address") + .expect("Should find mapped House Address"); + + let identity = match &address_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher"), + }; + + // Verify EVERY field is correctly mapped + assert_eq!( + identity.address1.as_deref(), + Some(street_address), + "streetAddress not mapped correctly" + ); + assert_eq!( + identity.postal_code.as_deref(), + Some(postal_code), + "postalCode not mapped correctly" + ); + assert_eq!( + identity.city.as_deref(), + Some(city), + "city not mapped correctly" + ); + assert_eq!( + identity.state.as_deref(), + Some(territory), + "territory not mapped correctly" + ); + assert_eq!( + identity.country.as_deref(), + Some(country), + "country not mapped correctly" + ); + assert_eq!( + identity.phone.as_deref(), + Some(tel), + "tel not mapped correctly" + ); + } + + #[test] + fn test_passport_json_field_mapping() { + let cxf_data = fs::read_to_string("resources/cxf_example.json") + .expect("Should be able to read cxf_example.json"); + let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); + + let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); + + let passport_item = json["accounts"][0]["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["title"] == "Passport") + .expect("Should find Passport item"); + + let passport_cred = &passport_item["credentials"][0]; + assert_eq!(passport_cred["type"], "passport"); + + // Extract ALL raw field values from JSON + let issuing_country = passport_cred["issuingCountry"]["value"].as_str().unwrap(); + let passport_type = passport_cred["passportType"]["value"].as_str().unwrap(); + let passport_number = passport_cred["passportNumber"]["value"].as_str().unwrap(); + let national_id = passport_cred["nationalIdentificationNumber"]["value"] + .as_str() + .unwrap(); + let nationality = passport_cred["nationality"]["value"].as_str().unwrap(); + let full_name = passport_cred["fullName"]["value"].as_str().unwrap(); + let birth_date = passport_cred["birthDate"]["value"].as_str().unwrap(); + let birth_place = passport_cred["birthPlace"]["value"].as_str().unwrap(); + + // Test our mapping + let result = load_sample_cxf().unwrap(); + let passport_cipher = result + .iter() + .find(|c| c.name == "Passport") + .expect("Should find mapped Passport"); + + let identity = match &passport_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher"), + }; + + assert_eq!( + identity.passport_number.as_deref(), + Some(passport_number), + "passportNumber not mapped correctly" + ); + + // Verify fullName is now properly split and mapped + assert_eq!( + identity.first_name.as_deref(), + Some("John"), + "fullName first name not extracted correctly" + ); + assert_eq!( + identity.last_name.as_deref(), + Some("Doe"), + "fullName last name not extracted correctly" + ); + + // Verify nationalIdentificationNumber is mapped to ssn + assert_eq!( + identity.ssn.as_deref(), + Some(national_id), + "nationalIdentificationNumber not mapped to ssn" + ); + + // Verify that unmapped data is preserved in custom fields + // Note: We can't easily test custom fields here because this test only checks Identity, + // but custom fields are stored in ImportingCipher.fields. The data should be preserved + // in custom fields: passportType, nationality, birthDate, birthPlace + } + + #[test] + fn test_person_name_json_field_mapping() { + let cxf_data = fs::read_to_string("resources/cxf_example.json") + .expect("Should be able to read cxf_example.json"); + let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); + + let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); + + let person_name_item = json["accounts"][0]["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["title"] == "John Doe") + .expect("Should find John Doe item"); + + let person_name_cred = &person_name_item["credentials"][0]; + assert_eq!(person_name_cred["type"], "person-name"); + + // Extract ALL raw field values from JSON + let title = person_name_cred["title"]["value"].as_str().unwrap(); + let given = person_name_cred["given"]["value"].as_str().unwrap(); + let given_informal = person_name_cred["givenInformal"]["value"].as_str().unwrap(); + let given2 = person_name_cred["given2"]["value"].as_str().unwrap(); + let surname_prefix = person_name_cred["surnamePrefix"]["value"].as_str().unwrap(); + let surname = person_name_cred["surname"]["value"].as_str().unwrap(); + let surname2 = person_name_cred["surname2"]["value"].as_str().unwrap(); + let credentials = person_name_cred["credentials"]["value"].as_str().unwrap(); + + // Test our mapping + let result = load_sample_cxf().unwrap(); + let person_name_cipher = result + .iter() + .find(|c| c.name == "John Doe") + .expect("Should find mapped John Doe"); + + let identity = match &person_name_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher"), + }; + + // Verify mapped fields + assert_eq!( + identity.title.as_deref(), + Some(title), + "title not mapped correctly" + ); + assert_eq!( + identity.first_name.as_deref(), + Some(given), + "given not mapped correctly" + ); + assert_eq!( + identity.middle_name.as_deref(), + Some(given2), + "given2 not mapped correctly" + ); + + // Verify complete last name construction (surnamePrefix + surname + surname2) + assert_eq!( + identity.last_name.as_deref(), + Some("van Doe Smith"), + "complete surname not constructed correctly" + ); + + // Verify credentials are mapped to company field + assert_eq!( + identity.company.as_deref(), + Some(credentials), + "credentials not mapped to company" + ); + + // Verify that unmapped data is preserved in custom fields + // Note: We can't easily test custom fields here because this test only checks Identity, + // but custom fields are stored in ImportingCipher.fields. The data should be preserved + // in custom fields: givenInformal + } + + #[test] + fn test_drivers_license_json_field_mapping() { + let cxf_data = fs::read_to_string("resources/cxf_example.json") + .expect("Should be able to read cxf_example.json"); + let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); + + let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); + + let drivers_license_item = json["accounts"][0]["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["title"] == "Driver License") + .expect("Should find Driver License item"); + + let drivers_license_cred = &drivers_license_item["credentials"][0]; + assert_eq!(drivers_license_cred["type"], "drivers-license"); + + // Extract ALL raw field values from JSON + let full_name = drivers_license_cred["fullName"]["value"].as_str().unwrap(); + let birth_date = drivers_license_cred["birthDate"]["value"].as_str().unwrap(); + let issue_date = drivers_license_cred["issueDate"]["value"].as_str().unwrap(); + let expiry_date = drivers_license_cred["expiryDate"]["value"] + .as_str() + .unwrap(); + let issuing_authority = drivers_license_cred["issuingAuthority"]["value"] + .as_str() + .unwrap(); + let territory = drivers_license_cred["territory"]["value"].as_str().unwrap(); + let country = drivers_license_cred["country"]["value"].as_str().unwrap(); + let license_number = drivers_license_cred["licenseNumber"]["value"] + .as_str() + .unwrap(); + + // Test our mapping + let result = load_sample_cxf().unwrap(); + let drivers_license_cipher = result + .iter() + .find(|c| c.name == "Driver License") + .expect("Should find mapped Driver License"); + + let identity = match &drivers_license_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher"), + }; + + // Verify mapped fields - now includes all major fields + assert_eq!( + identity.license_number.as_deref(), + Some(license_number), + "licenseNumber not mapped correctly" + ); + assert_eq!( + identity.first_name.as_deref(), + Some("John"), + "fullName first name not extracted correctly" + ); + assert_eq!( + identity.last_name.as_deref(), + Some("Doe"), + "fullName last name not extracted correctly" + ); + + // Verify new mappings + assert_eq!( + identity.state.as_deref(), + Some(territory), + "territory not mapped to state" + ); + assert_eq!( + identity.country.as_deref(), + Some(country), + "country not mapped correctly" + ); + // issuingAuthority is now a custom field according to mapping document + assert_eq!(identity.company, None); + + // Verify that unmapped data is preserved in custom fields + // Note: We can't easily test custom fields here because this test only checks Identity, + // but custom fields are stored in ImportingCipher.fields. The data should be preserved + // in custom fields: birthDate, issueDate, expiryDate + } +} diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 3cb2ac3c6..3d48e3f6d 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -1,13 +1,18 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ - Account as CxfAccount, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential, - Item, PasskeyCredential, WifiCredential, + Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, + CreditCardCredential, DriversLicenseCredential, Header, Item, PasskeyCredential, + PassportCredential, PersonNameCredential, WifiCredential, }; use crate::{ cxf::{ address::address_to_identity, api_key::api_key_to_fields, + identity::{ + address_to_identity, drivers_license_to_identity, passport_to_identity, + person_name_to_identity, + }, login::{to_fields, to_login}, wifi::wifi_to_fields, CxfError, @@ -84,12 +89,7 @@ fn parse_item(value: Item) -> Vec { }) } - if !grouped.credit_card.is_empty() { - let credit_card = grouped - .credit_card - .first() - .expect("Credit card is not empty"); - + if let Some(credit_card) = grouped.credit_card.first() { output.push(ImportingCipher { folder_id: None, // TODO: Handle folders name: value.title.clone(), @@ -145,10 +145,26 @@ fn parse_item(value: Item) -> Vec { } // Address credentials - if !grouped.address.is_empty() { - let address = grouped.address.first().expect("Address is not empty"); + if let Some(address) = grouped.address.first() { + let (identity, custom_fields) = address_to_identity(address); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } - let identity = address_to_identity(address); + // Passport credentials + if let Some(passport) = grouped.passport.first() { + let (identity, custom_fields) = passport_to_identity(passport); output.push(ImportingCipher { folder_id: None, // TODO: Handle folders @@ -157,7 +173,43 @@ fn parse_item(value: Item) -> Vec { r#type: CipherType::Identity(Box::new(identity)), favorite: false, reprompt: 0, - fields: vec![], + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + + // Person name credentials + if let Some(person_name) = grouped.person_name.first() { + let (identity, custom_fields) = person_name_to_identity(person_name); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + + // Drivers license credentials + if let Some(drivers_license) = grouped.drivers_license.first() { + let (identity, custom_fields) = drivers_license_to_identity(drivers_license); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, revision_date, creation_date, deleted_date: None, @@ -209,6 +261,18 @@ fn group_credentials_by_type(credentials: Vec) -> GroupedCredentials Credential::Address(address) => Some(address.as_ref()), _ => None, }), + passport: filter_credentials(&credentials, |c| match c { + Credential::Passport(passport) => Some(passport.as_ref()), + _ => None, + }), + person_name: filter_credentials(&credentials, |c| match c { + Credential::PersonName(person_name) => Some(person_name.as_ref()), + _ => None, + }), + drivers_license: filter_credentials(&credentials, |c| match c { + Credential::DriversLicense(drivers_license) => Some(drivers_license.as_ref()), + _ => None, + }), } } @@ -219,6 +283,9 @@ struct GroupedCredentials { credit_card: Vec, wifi: Vec, address: Vec, + passport: Vec, + person_name: Vec, + drivers_license: Vec, } #[cfg(test)] @@ -364,41 +431,6 @@ mod tests { ); } - #[test] - fn test_address_integration() { - let result = load_sample_cxf(); - assert!(result.is_ok()); - - let ciphers = result.unwrap(); - - // Find the address cipher - should be titled "House Address" - let address_cipher = ciphers - .iter() - .find(|c| c.name == "House Address") - .expect("Should find House Address item"); - - // Verify it's an Identity cipher - let identity = match &address_cipher.r#type { - CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher for address"), - }; - - // Verify the address mapping - assert_eq!(identity.address1, Some("123 Main Street".to_string())); - assert_eq!(identity.city, Some("Springfield".to_string())); - assert_eq!(identity.state, Some("CA".to_string())); - assert_eq!(identity.country, Some("US".to_string())); - assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); - assert_eq!(identity.postal_code, Some("12345".to_string())); - - // Verify unmapped fields are None - assert_eq!(identity.title, None); - assert_eq!(identity.first_name, None); - assert_eq!(identity.last_name, None); - assert_eq!(identity.company, None); - assert_eq!(identity.email, None); - } - #[test] fn test_credit_card() { let item = Item { @@ -447,4 +479,128 @@ mod tests { assert_eq!(card.brand, Some("Mastercard".to_string())); assert_eq!(card.number, Some("1234 5678 9012 3456".to_string())); } + + #[test] + fn test_passport_complete_mapping_with_custom_fields() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + let passport_cipher = ciphers + .iter() + .find(|c| c.name == "Passport") + .expect("Should find Passport item"); + let identity = match &passport_cipher.r#type { + CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher"), + }; + + // Verify Identity field mappings + assert_eq!(identity.passport_number, Some("A12345678".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + assert_eq!(identity.ssn, Some("ID123456789".to_string())); + assert_eq!(identity.country, None); // Now custom field + + // Verify custom fields preserve all other data + assert!( + passport_cipher.fields.len() >= 4, + "Should have multiple custom fields" + ); + let issuing_country = passport_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Country")) + .expect("Should have Issuing Country"); + assert_eq!(issuing_country.value, Some("US".to_string())); + let nationality = passport_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Nationality")) + .expect("Should have Nationality"); + assert_eq!(nationality.value, Some("American".to_string())); + } + + #[test] + fn test_drivers_license_complete_mapping_with_custom_fields() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + let drivers_license_cipher = ciphers + .iter() + .find(|c| c.name == "Driver License") + .expect("Should find Driver License item"); + let identity = match &drivers_license_cipher.r#type { + CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher"), + }; + + // Verify Identity field mappings + assert_eq!(identity.license_number, Some("D12345678".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + assert_eq!(identity.state, Some("CA".to_string())); + assert_eq!(identity.country, Some("US".to_string())); + assert_eq!(identity.company, None); // Now custom field + + // Verify custom fields preserve all other data + assert!( + drivers_license_cipher.fields.len() >= 3, + "Should have multiple custom fields" + ); + let issuing_authority = drivers_license_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Authority")) + .expect("Should have Issuing Authority"); + assert_eq!( + issuing_authority.value, + Some("Department of Motor Vehicles".to_string()) + ); + let license_class = drivers_license_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("License Class")) + .expect("Should have License Class"); + assert_eq!(license_class.value, Some("C".to_string())); + } + + #[test] + fn test_person_name_complete_mapping_with_custom_fields() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + let person_name_cipher = ciphers + .iter() + .find(|c| c.name == "John Doe") + .expect("Should find John Doe item"); + let identity = match &person_name_cipher.r#type { + CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher"), + }; + + // Verify Identity field mappings + assert_eq!(identity.title, Some("Dr.".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.middle_name, Some("Michael".to_string())); + assert_eq!(identity.last_name, Some("van Doe Smith".to_string())); + assert_eq!(identity.company, Some("PhD".to_string())); + + // Verify custom fields preserve unmapped data + assert!( + person_name_cipher.fields.len() >= 2, + "Should have custom fields" + ); + let informal_given = person_name_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Informal Given Name")) + .expect("Should have Informal Given Name"); + assert_eq!(informal_given.value, Some("Johnny".to_string())); + let generation = person_name_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Generation")) + .expect("Should have Generation"); + assert_eq!(generation.value, Some("III".to_string())); + } } diff --git a/crates/bitwarden-exporters/src/cxf/mod.rs b/crates/bitwarden-exporters/src/cxf/mod.rs index 1f7d7d60f..c645f01e9 100644 --- a/crates/bitwarden-exporters/src/cxf/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/mod.rs @@ -12,9 +12,10 @@ pub(crate) use export::build_cxf; pub use export::Account; mod import; pub(crate) use import::parse_cxf; -mod address; mod api_key; mod card; +mod card; mod editable_field; +mod identity; mod login; mod wifi; From c89f94d5c57d45a93bc7d182bd5b2ca0b457a71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 25 Jul 2025 12:12:00 +0200 Subject: [PATCH 03/17] Update identity.rs --- .../bitwarden-exporters/src/cxf/identity.rs | 798 +++--------------- 1 file changed, 137 insertions(+), 661 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index c2ddf3579..caa83757d 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -1,48 +1,42 @@ use bitwarden_vault::FieldType; use credential_exchange_format::{ - AddressCredential, DriversLicenseCredential, PassportCredential, PersonNameCredential, + AddressCredential, DriversLicenseCredential, EditableField, EditableFieldCountryCode, + EditableFieldDate, EditableFieldString, PassportCredential, PersonNameCredential, }; use crate::{Field, Identity}; -use credential_exchange_format::{ - EditableField, EditableFieldCountryCode, EditableFieldDate, EditableFieldString, -}; +/// Helper trait to extract value from various EditableField types +trait ExtractValue { + fn extract_value(&self) -> String; +} -/// Helper function to create a custom field from an EditableField -fn create_text_field_from_string( - editable_field: Option<&EditableField>, - field_name: &str, -) -> Option { - editable_field.map(|field| Field { - name: Some(field_name.to_string()), - value: Some(field.value.0.clone()), - r#type: FieldType::Text as u8, - linked_id: None, - }) +impl ExtractValue for EditableField { + fn extract_value(&self) -> String { + self.value.0.clone() + } } -/// Helper function to create a custom field from an EditableField -fn create_text_field_from_country_code( - editable_field: Option<&EditableField>, - field_name: &str, -) -> Option { - editable_field.map(|field| Field { - name: Some(field_name.to_string()), - value: Some(field.value.0.clone()), - r#type: FieldType::Text as u8, - linked_id: None, - }) +impl ExtractValue for EditableField { + fn extract_value(&self) -> String { + self.value.0.clone() + } +} + +impl ExtractValue for EditableField { + fn extract_value(&self) -> String { + self.value.0.to_string() + } } -/// Helper function to create a custom field from an EditableField -fn create_text_field_from_date( - editable_field: Option<&EditableField>, +/// Generic helper function to create a custom field from any EditableField type +fn create_custom_field( + editable_field: Option<&T>, field_name: &str, ) -> Option { editable_field.map(|field| Field { name: Some(field_name.to_string()), - value: Some(field.value.0.to_string()), + value: Some(field.extract_value()), r#type: FieldType::Text as u8, linked_id: None, }) @@ -133,39 +127,33 @@ pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec (Identity, let mut custom_fields = Vec::new(); if let Some(field) = - create_text_field_from_string(person_name.given_informal.as_ref(), "Informal Given Name") + create_custom_field(person_name.given_informal.as_ref(), "Informal Given Name") { custom_fields.push(field); } - if let Some(field) = - create_text_field_from_string(person_name.generation.as_ref(), "Generation") - { + if let Some(field) = create_custom_field(person_name.generation.as_ref(), "Generation") { custom_fields.push(field); } @@ -300,29 +286,23 @@ pub fn drivers_license_to_identity( // Create custom fields for unmapped data according to CXF mapping document let mut custom_fields = Vec::new(); - if let Some(field) = - create_text_field_from_date(drivers_license.birth_date.as_ref(), "Birth Date") - { + if let Some(field) = create_custom_field(drivers_license.birth_date.as_ref(), "Birth Date") { custom_fields.push(field); } - if let Some(field) = - create_text_field_from_date(drivers_license.issue_date.as_ref(), "Issue Date") - { + if let Some(field) = create_custom_field(drivers_license.issue_date.as_ref(), "Issue Date") { custom_fields.push(field); } - if let Some(field) = - create_text_field_from_date(drivers_license.expiry_date.as_ref(), "Expiry Date") - { + if let Some(field) = create_custom_field(drivers_license.expiry_date.as_ref(), "Expiry Date") { custom_fields.push(field); } - if let Some(field) = create_text_field_from_string( + if let Some(field) = create_custom_field( drivers_license.issuing_authority.as_ref(), "Issuing Authority", ) { custom_fields.push(field); } if let Some(field) = - create_text_field_from_string(drivers_license.license_class.as_ref(), "License Class") + create_custom_field(drivers_license.license_class.as_ref(), "License Class") { custom_fields.push(field); } @@ -334,14 +314,9 @@ pub fn drivers_license_to_identity( mod tests { use std::fs; - use credential_exchange_format::{ - EditableField, EditableFieldCountryCode, EditableFieldString, EditableFieldSubdivisionCode, - }; - + // Tests only use the public parse_cxf function, no direct function imports needed use crate::cxf::import::parse_cxf; - use super::*; - fn load_sample_cxf() -> Result, crate::cxf::CxfError> { // Read the actual CXF example file let cxf_data = fs::read_to_string("resources/cxf_example.json") @@ -354,264 +329,14 @@ mod tests { parse_cxf(fixed_cxf_data) } - fn create_address_credential( - street_address: Option<&str>, - city: Option<&str>, - territory: Option<&str>, - country: Option<&str>, - tel: Option<&str>, - postal_code: Option<&str>, - ) -> AddressCredential { - AddressCredential { - street_address: street_address.map(|s| EditableField { - id: None, - value: EditableFieldString(s.to_string()), - label: None, - extensions: None, - }), - city: city.map(|c| EditableField { - id: None, - value: EditableFieldString(c.to_string()), - label: None, - extensions: None, - }), - territory: territory.map(|t| EditableField { - id: None, - value: EditableFieldSubdivisionCode(t.to_string()), - label: None, - extensions: None, - }), - country: country.map(|c| EditableField { - id: None, - value: EditableFieldCountryCode(c.to_string()), - label: None, - extensions: None, - }), - tel: tel.map(|t| EditableField { - id: None, - value: EditableFieldString(t.to_string()), - label: None, - extensions: None, - }), - postal_code: postal_code.map(|p| EditableField { - id: None, - value: EditableFieldString(p.to_string()), - label: None, - extensions: None, - }), - } - } - #[test] - fn test_address_to_identity_full() { - let address = create_address_credential( - Some("123 Main Street"), - Some("Springfield"), - Some("CA"), - Some("US"), - Some("+1-555-123-4567"), - Some("12345"), - ); - - let (identity, custom_fields) = address_to_identity(&address); - - assert_eq!(identity.address1, Some("123 Main Street".to_string())); - assert_eq!(identity.city, Some("Springfield".to_string())); - assert_eq!(identity.state, Some("CA".to_string())); - assert_eq!(identity.country, Some("US".to_string())); - assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); - assert_eq!(identity.postal_code, Some("12345".to_string())); - - // Address has no custom fields - assert_eq!(custom_fields.len(), 0); - } - - fn create_passport_credential( - passport_number: Option<&str>, - issuing_country: Option<&str>, - ) -> PassportCredential { - PassportCredential { - passport_number: passport_number.map(|p| EditableField { - id: None, - value: EditableFieldString(p.to_string()), - label: None, - extensions: None, - }), - issuing_country: issuing_country.map(|c| EditableField { - id: None, - value: EditableFieldCountryCode(c.to_string()), - label: None, - extensions: None, - }), - passport_type: None, - full_name: None, - birth_date: None, - issue_date: None, - expiry_date: None, - birth_place: None, - issuing_authority: None, - national_identification_number: None, - nationality: None, - sex: None, - } - } - - fn create_person_name_credential( - title: Option<&str>, - given: Option<&str>, - given2: Option<&str>, - surname: Option<&str>, - ) -> PersonNameCredential { - PersonNameCredential { - title: title.map(|t| EditableField { - id: None, - value: EditableFieldString(t.to_string()), - label: None, - extensions: None, - }), - given: given.map(|g| EditableField { - id: None, - value: EditableFieldString(g.to_string()), - label: None, - extensions: None, - }), - given2: given2.map(|g2| EditableField { - id: None, - value: EditableFieldString(g2.to_string()), - label: None, - extensions: None, - }), - surname: surname.map(|s| EditableField { - id: None, - value: EditableFieldString(s.to_string()), - label: None, - extensions: None, - }), - given_informal: None, - surname_prefix: None, - surname2: None, - credentials: None, - generation: None, - } - } - - fn create_drivers_license_credential( - license_number: Option<&str>, - full_name: Option<&str>, - ) -> DriversLicenseCredential { - DriversLicenseCredential { - license_number: license_number.map(|l| EditableField { - id: None, - value: EditableFieldString(l.to_string()), - label: None, - extensions: None, - }), - full_name: full_name.map(|f| EditableField { - id: None, - value: EditableFieldString(f.to_string()), - label: None, - extensions: None, - }), - birth_date: None, - issue_date: None, - expiry_date: None, - issuing_authority: None, - license_class: None, - country: None, - territory: None, - } - } - - #[test] - fn test_passport_to_identity() { - let passport = create_passport_credential(Some("A12345678"), Some("US")); - - let (identity, custom_fields) = passport_to_identity(&passport); - - assert_eq!(identity.passport_number, Some("A12345678".to_string())); - assert_eq!(identity.country, None); // Now custom field according to mapping - - // Verify other fields are None - assert_eq!(identity.title, None); - assert_eq!(identity.first_name, None); - assert_eq!(identity.address1, None); - - // Should have issuing country as custom field - assert_eq!(custom_fields.len(), 1); - assert_eq!(custom_fields[0].name, Some("Issuing Country".to_string())); - assert_eq!(custom_fields[0].value, Some("US".to_string())); - assert_eq!(custom_fields[0].r#type, FieldType::Text as u8); - } - - #[test] - fn test_person_name_to_identity() { - let person_name = - create_person_name_credential(Some("Dr."), Some("John"), Some("Michael"), Some("Doe")); - - let (identity, custom_fields) = person_name_to_identity(&person_name); - - assert_eq!(identity.title, Some("Dr.".to_string())); - assert_eq!(identity.first_name, Some("John".to_string())); - assert_eq!(identity.middle_name, Some("Michael".to_string())); - assert_eq!(identity.last_name, Some("Doe".to_string())); - - // Verify other fields are None - assert_eq!(identity.address1, None); - assert_eq!(identity.passport_number, None); - - // No unmapped fields in this test, so no custom fields - assert_eq!(custom_fields.len(), 0); - } - - #[test] - fn test_drivers_license_to_identity() { - let drivers_license = - create_drivers_license_credential(Some("D123456789"), Some("John Doe")); - - let (identity, custom_fields) = drivers_license_to_identity(&drivers_license); - - assert_eq!(identity.license_number, Some("D123456789".to_string())); - assert_eq!(identity.first_name, Some("John".to_string())); - assert_eq!(identity.last_name, Some("Doe".to_string())); - - // Verify other fields are None - assert_eq!(identity.title, None); - assert_eq!(identity.address1, None); - assert_eq!(identity.company, None); // Now custom field according to mapping - - // No unmapped fields in this test, so no custom fields - assert_eq!(custom_fields.len(), 0); - } - - #[test] - fn test_drivers_license_full_name_parsing() { - // Test single name - let dl_single = create_drivers_license_credential(None, Some("John")); - let (identity, _) = drivers_license_to_identity(&dl_single); - assert_eq!(identity.first_name, Some("John".to_string())); - assert_eq!(identity.last_name, None); - - // Test three names - let dl_three = create_drivers_license_credential(None, Some("John Michael Doe")); - let (identity, _) = drivers_license_to_identity(&dl_three); - assert_eq!(identity.first_name, Some("John".to_string())); - assert_eq!(identity.last_name, Some("Michael Doe".to_string())); - - // Test empty name - let dl_empty = create_drivers_license_credential(None, Some("")); - let (identity, _) = drivers_license_to_identity(&dl_empty); - assert_eq!(identity.first_name, None); - assert_eq!(identity.last_name, None); - } - - #[test] - fn test_address_integration_complete_data_mapping() { + fn test_address_complete_mapping() { + // Test both unit logic and real data integration let result = load_sample_cxf(); assert!(result.is_ok()); - let ciphers = result.unwrap(); - // Find the address cipher - should be titled "House Address" + // Find the address cipher from cxf_example.json let address_cipher = ciphers .iter() .find(|c| c.name == "House Address") @@ -623,29 +348,31 @@ mod tests { _ => panic!("Expected Identity cipher for address"), }; - // Verify ALL the address fields from cxf_example.json are mapped - // streetAddress → address1 + // Verify all address field mappings from cxf_example.json assert_eq!(identity.address1, Some("123 Main Street".to_string())); - // city → city assert_eq!(identity.city, Some("Springfield".to_string())); - // territory → state assert_eq!(identity.state, Some("CA".to_string())); - // country → country assert_eq!(identity.country, Some("US".to_string())); - // tel → phone assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); - // postalCode → postal_code assert_eq!(identity.postal_code, Some("12345".to_string())); + + // Verify no unmapped fields (address has no custom fields) + assert_eq!(address_cipher.fields.len(), 0); + + // Verify unused Identity fields remain None + assert_eq!(identity.first_name, None); + assert_eq!(identity.passport_number, None); + assert_eq!(identity.license_number, None); } #[test] - fn test_passport_integration_complete_data_mapping() { + fn test_passport_complete_mapping() { + // Test both unit logic and real data integration let result = load_sample_cxf(); assert!(result.is_ok()); - let ciphers = result.unwrap(); - // Find the passport cipher - should be titled "Passport" + // Find the passport cipher from cxf_example.json let passport_cipher = ciphers .iter() .find(|c| c.name == "Passport") @@ -657,24 +384,47 @@ mod tests { _ => panic!("Expected Identity cipher for passport"), }; - // Verify passport fields from cxf_example.json - // passportNumber → passport_number + // Verify Identity field mappings from cxf_example.json assert_eq!(identity.passport_number, Some("A12345678".to_string())); - // nationalIdentificationNumber → ssn - assert_eq!(identity.ssn, Some("ID123456789".to_string())); - // fullName → first_name + last_name (split) assert_eq!(identity.first_name, Some("John".to_string())); assert_eq!(identity.last_name, Some("Doe".to_string())); + assert_eq!(identity.ssn, Some("ID123456789".to_string())); + assert_eq!(identity.country, None); // Now custom field per mapping + + // Verify custom fields preserve all unmapped data + assert!( + passport_cipher.fields.len() >= 4, + "Should have multiple custom fields" + ); + + // Check specific custom fields + let issuing_country = passport_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Country")) + .expect("Should have Issuing Country"); + assert_eq!(issuing_country.value, Some("US".to_string())); + + let nationality = passport_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Nationality")) + .expect("Should have Nationality"); + assert_eq!(nationality.value, Some("American".to_string())); + + // Verify unused Identity fields remain None + assert_eq!(identity.address1, None); + assert_eq!(identity.license_number, None); } #[test] - fn test_person_name_integration_complete_data_mapping() { + fn test_person_name_complete_mapping() { + // Test both unit logic and real data integration let result = load_sample_cxf(); assert!(result.is_ok()); - let ciphers = result.unwrap(); - // Find the person name cipher - should be titled "John Doe" + // Find the person name cipher from cxf_example.json let person_name_cipher = ciphers .iter() .find(|c| c.name == "John Doe") @@ -686,34 +436,47 @@ mod tests { _ => panic!("Expected Identity cipher for person name"), }; - // Verify ALL person name fields from cxf_example.json - // title → title + // Verify Identity field mappings from cxf_example.json assert_eq!(identity.title, Some("Dr.".to_string())); - // given → first_name assert_eq!(identity.first_name, Some("John".to_string())); - // given2 → middle_name assert_eq!(identity.middle_name, Some("Michael".to_string())); - // surname → last_name (now includes surnamePrefix + surname + surname2) - assert_eq!(identity.last_name, Some("van Doe Smith".to_string())); - // credentials → company - assert_eq!(identity.company, Some("PhD".to_string())); + assert_eq!(identity.last_name, Some("van Doe Smith".to_string())); // Combined surname + assert_eq!(identity.company, Some("PhD".to_string())); // credentials → company + + // Verify custom fields preserve unmapped data + assert!( + person_name_cipher.fields.len() >= 2, + "Should have custom fields" + ); + + let informal_given = person_name_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Informal Given Name")) + .expect("Should have Informal Given Name"); + assert_eq!(informal_given.value, Some("Johnny".to_string())); - // These should remain None for person-name-only credentials + let generation = person_name_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Generation")) + .expect("Should have Generation"); + assert_eq!(generation.value, Some("III".to_string())); + + // Verify unused Identity fields remain None assert_eq!(identity.address1, None); assert_eq!(identity.passport_number, None); assert_eq!(identity.license_number, None); - assert_eq!(identity.phone, None); - assert_eq!(identity.country, None); } #[test] - fn test_drivers_license_integration_complete_data_mapping() { + fn test_drivers_license_complete_mapping() { + // Test both unit logic and real data integration let result = load_sample_cxf(); assert!(result.is_ok()); - let ciphers = result.unwrap(); - // Find the drivers license cipher - should be titled "Driver License" + // Find the drivers license cipher from cxf_example.json let drivers_license_cipher = ciphers .iter() .find(|c| c.name == "Driver License") @@ -725,327 +488,40 @@ mod tests { _ => panic!("Expected Identity cipher for drivers license"), }; - // Verify mapped fields according to CXF mapping document + // Verify Identity field mappings from cxf_example.json assert_eq!(identity.license_number, Some("D12345678".to_string())); assert_eq!(identity.first_name, Some("John".to_string())); assert_eq!(identity.last_name, Some("Doe".to_string())); - assert_eq!(identity.state, Some("CA".to_string())); // territory → state - assert_eq!(identity.country, Some("US".to_string())); // country → country - - // issuingAuthority is now a custom field according to mapping document - assert_eq!(identity.company, None); - - // These should remain None for drivers-license-only credentials - assert_eq!(identity.address1, None); - assert_eq!(identity.passport_number, None); - assert_eq!(identity.phone, None); - assert_eq!(identity.email, None); - } - - #[test] - fn test_address_json_field_mapping() { - // Read the raw JSON file - let cxf_data = fs::read_to_string("resources/cxf_example.json") - .expect("Should be able to read cxf_example.json"); - let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); - - // Parse as generic JSON to inspect raw values - let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); - - // Find the address item - let address_item = json["accounts"][0]["items"] - .as_array() - .unwrap() - .iter() - .find(|item| item["title"] == "House Address") - .expect("Should find House Address item"); - - let address_cred = &address_item["credentials"][0]; - assert_eq!(address_cred["type"], "address"); - - // Extract all raw field values from JSON - let street_address = address_cred["streetAddress"]["value"].as_str().unwrap(); - let postal_code = address_cred["postalCode"]["value"].as_str().unwrap(); - let city = address_cred["city"]["value"].as_str().unwrap(); - let territory = address_cred["territory"]["value"].as_str().unwrap(); - let country = address_cred["country"]["value"].as_str().unwrap(); - let tel = address_cred["tel"]["value"].as_str().unwrap(); - - // Now test our mapping - let result = load_sample_cxf().unwrap(); - let address_cipher = result - .iter() - .find(|c| c.name == "House Address") - .expect("Should find mapped House Address"); - - let identity = match &address_cipher.r#type { - crate::CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher"), - }; - - // Verify EVERY field is correctly mapped - assert_eq!( - identity.address1.as_deref(), - Some(street_address), - "streetAddress not mapped correctly" - ); - assert_eq!( - identity.postal_code.as_deref(), - Some(postal_code), - "postalCode not mapped correctly" - ); - assert_eq!( - identity.city.as_deref(), - Some(city), - "city not mapped correctly" - ); - assert_eq!( - identity.state.as_deref(), - Some(territory), - "territory not mapped correctly" - ); - assert_eq!( - identity.country.as_deref(), - Some(country), - "country not mapped correctly" - ); - assert_eq!( - identity.phone.as_deref(), - Some(tel), - "tel not mapped correctly" - ); - } - - #[test] - fn test_passport_json_field_mapping() { - let cxf_data = fs::read_to_string("resources/cxf_example.json") - .expect("Should be able to read cxf_example.json"); - let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); - - let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); - - let passport_item = json["accounts"][0]["items"] - .as_array() - .unwrap() - .iter() - .find(|item| item["title"] == "Passport") - .expect("Should find Passport item"); - - let passport_cred = &passport_item["credentials"][0]; - assert_eq!(passport_cred["type"], "passport"); - - // Extract ALL raw field values from JSON - let issuing_country = passport_cred["issuingCountry"]["value"].as_str().unwrap(); - let passport_type = passport_cred["passportType"]["value"].as_str().unwrap(); - let passport_number = passport_cred["passportNumber"]["value"].as_str().unwrap(); - let national_id = passport_cred["nationalIdentificationNumber"]["value"] - .as_str() - .unwrap(); - let nationality = passport_cred["nationality"]["value"].as_str().unwrap(); - let full_name = passport_cred["fullName"]["value"].as_str().unwrap(); - let birth_date = passport_cred["birthDate"]["value"].as_str().unwrap(); - let birth_place = passport_cred["birthPlace"]["value"].as_str().unwrap(); - - // Test our mapping - let result = load_sample_cxf().unwrap(); - let passport_cipher = result - .iter() - .find(|c| c.name == "Passport") - .expect("Should find mapped Passport"); - - let identity = match &passport_cipher.r#type { - crate::CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher"), - }; - - assert_eq!( - identity.passport_number.as_deref(), - Some(passport_number), - "passportNumber not mapped correctly" - ); - - // Verify fullName is now properly split and mapped - assert_eq!( - identity.first_name.as_deref(), - Some("John"), - "fullName first name not extracted correctly" - ); - assert_eq!( - identity.last_name.as_deref(), - Some("Doe"), - "fullName last name not extracted correctly" - ); + assert_eq!(identity.state, Some("CA".to_string())); + assert_eq!(identity.country, Some("US".to_string())); + assert_eq!(identity.company, None); // issuingAuthority is now custom field - // Verify nationalIdentificationNumber is mapped to ssn - assert_eq!( - identity.ssn.as_deref(), - Some(national_id), - "nationalIdentificationNumber not mapped to ssn" + // Verify custom fields preserve unmapped data + assert!( + drivers_license_cipher.fields.len() >= 3, + "Should have multiple custom fields" ); - // Verify that unmapped data is preserved in custom fields - // Note: We can't easily test custom fields here because this test only checks Identity, - // but custom fields are stored in ImportingCipher.fields. The data should be preserved - // in custom fields: passportType, nationality, birthDate, birthPlace - } - - #[test] - fn test_person_name_json_field_mapping() { - let cxf_data = fs::read_to_string("resources/cxf_example.json") - .expect("Should be able to read cxf_example.json"); - let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); - - let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); - - let person_name_item = json["accounts"][0]["items"] - .as_array() - .unwrap() - .iter() - .find(|item| item["title"] == "John Doe") - .expect("Should find John Doe item"); - - let person_name_cred = &person_name_item["credentials"][0]; - assert_eq!(person_name_cred["type"], "person-name"); - - // Extract ALL raw field values from JSON - let title = person_name_cred["title"]["value"].as_str().unwrap(); - let given = person_name_cred["given"]["value"].as_str().unwrap(); - let given_informal = person_name_cred["givenInformal"]["value"].as_str().unwrap(); - let given2 = person_name_cred["given2"]["value"].as_str().unwrap(); - let surname_prefix = person_name_cred["surnamePrefix"]["value"].as_str().unwrap(); - let surname = person_name_cred["surname"]["value"].as_str().unwrap(); - let surname2 = person_name_cred["surname2"]["value"].as_str().unwrap(); - let credentials = person_name_cred["credentials"]["value"].as_str().unwrap(); - - // Test our mapping - let result = load_sample_cxf().unwrap(); - let person_name_cipher = result + let issuing_authority = drivers_license_cipher + .fields .iter() - .find(|c| c.name == "John Doe") - .expect("Should find mapped John Doe"); - - let identity = match &person_name_cipher.r#type { - crate::CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher"), - }; - - // Verify mapped fields - assert_eq!( - identity.title.as_deref(), - Some(title), - "title not mapped correctly" - ); - assert_eq!( - identity.first_name.as_deref(), - Some(given), - "given not mapped correctly" - ); - assert_eq!( - identity.middle_name.as_deref(), - Some(given2), - "given2 not mapped correctly" - ); - - // Verify complete last name construction (surnamePrefix + surname + surname2) + .find(|f| f.name.as_deref() == Some("Issuing Authority")) + .expect("Should have Issuing Authority"); assert_eq!( - identity.last_name.as_deref(), - Some("van Doe Smith"), - "complete surname not constructed correctly" + issuing_authority.value, + Some("Department of Motor Vehicles".to_string()) ); - // Verify credentials are mapped to company field - assert_eq!( - identity.company.as_deref(), - Some(credentials), - "credentials not mapped to company" - ); - - // Verify that unmapped data is preserved in custom fields - // Note: We can't easily test custom fields here because this test only checks Identity, - // but custom fields are stored in ImportingCipher.fields. The data should be preserved - // in custom fields: givenInformal - } - - #[test] - fn test_drivers_license_json_field_mapping() { - let cxf_data = fs::read_to_string("resources/cxf_example.json") - .expect("Should be able to read cxf_example.json"); - let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); - - let json: serde_json::Value = serde_json::from_str(&fixed_cxf_data).unwrap(); - - let drivers_license_item = json["accounts"][0]["items"] - .as_array() - .unwrap() - .iter() - .find(|item| item["title"] == "Driver License") - .expect("Should find Driver License item"); - - let drivers_license_cred = &drivers_license_item["credentials"][0]; - assert_eq!(drivers_license_cred["type"], "drivers-license"); - - // Extract ALL raw field values from JSON - let full_name = drivers_license_cred["fullName"]["value"].as_str().unwrap(); - let birth_date = drivers_license_cred["birthDate"]["value"].as_str().unwrap(); - let issue_date = drivers_license_cred["issueDate"]["value"].as_str().unwrap(); - let expiry_date = drivers_license_cred["expiryDate"]["value"] - .as_str() - .unwrap(); - let issuing_authority = drivers_license_cred["issuingAuthority"]["value"] - .as_str() - .unwrap(); - let territory = drivers_license_cred["territory"]["value"].as_str().unwrap(); - let country = drivers_license_cred["country"]["value"].as_str().unwrap(); - let license_number = drivers_license_cred["licenseNumber"]["value"] - .as_str() - .unwrap(); - - // Test our mapping - let result = load_sample_cxf().unwrap(); - let drivers_license_cipher = result + let license_class = drivers_license_cipher + .fields .iter() - .find(|c| c.name == "Driver License") - .expect("Should find mapped Driver License"); - - let identity = match &drivers_license_cipher.r#type { - crate::CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher"), - }; - - // Verify mapped fields - now includes all major fields - assert_eq!( - identity.license_number.as_deref(), - Some(license_number), - "licenseNumber not mapped correctly" - ); - assert_eq!( - identity.first_name.as_deref(), - Some("John"), - "fullName first name not extracted correctly" - ); - assert_eq!( - identity.last_name.as_deref(), - Some("Doe"), - "fullName last name not extracted correctly" - ); + .find(|f| f.name.as_deref() == Some("License Class")) + .expect("Should have License Class"); + assert_eq!(license_class.value, Some("C".to_string())); - // Verify new mappings - assert_eq!( - identity.state.as_deref(), - Some(territory), - "territory not mapped to state" - ); - assert_eq!( - identity.country.as_deref(), - Some(country), - "country not mapped correctly" - ); - // issuingAuthority is now a custom field according to mapping document - assert_eq!(identity.company, None); - - // Verify that unmapped data is preserved in custom fields - // Note: We can't easily test custom fields here because this test only checks Identity, - // but custom fields are stored in ImportingCipher.fields. The data should be preserved - // in custom fields: birthDate, issueDate, expiryDate + // Verify unused Identity fields remain None + assert_eq!(identity.title, None); + assert_eq!(identity.address1, None); + assert_eq!(identity.passport_number, None); } } From 54b1f5c88ffe75821ebd14220a1b48974cac2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 25 Jul 2025 12:20:34 +0200 Subject: [PATCH 04/17] Add identity-document --- .../bitwarden-exporters/src/cxf/identity.rs | 171 +++++++++++++++++- crates/bitwarden-exporters/src/cxf/import.rs | 31 +++- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index caa83757d..a80760ea1 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -1,7 +1,8 @@ use bitwarden_vault::FieldType; use credential_exchange_format::{ AddressCredential, DriversLicenseCredential, EditableField, EditableFieldCountryCode, - EditableFieldDate, EditableFieldString, PassportCredential, PersonNameCredential, + EditableFieldDate, EditableFieldString, IdentityDocumentCredential, PassportCredential, + PersonNameCredential, }; use crate::{Field, Identity}; @@ -310,6 +311,103 @@ pub fn drivers_license_to_identity( (identity, custom_fields) } +/// Convert identity document credentials to Identity and custom fields +/// According to CXF mapping document: IdentityDocument ↔︎ Identity +/// Fields are mapped similarly to passport but for general identity documents +/// - documentNumber: EditableField<"string"> → Identity::passport_number (reusing for general +/// document number) +/// - identificationNumber: EditableField<"string"> → Identity::ssn +/// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) +/// - All other fields → CustomFields +pub fn identity_document_to_identity( + identity_document: &IdentityDocumentCredential, +) -> (Identity, Vec) { + // Split full name into first and last name if available + let (first_name, last_name) = if let Some(full_name) = &identity_document.full_name { + let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); + match name_parts.len() { + 0 => (None, None), + 1 => (Some(name_parts[0].to_string()), None), + _ => { + let first = name_parts[0].to_string(); + let last = name_parts[1..].join(" "); + (Some(first), Some(last)) + } + } + } else { + (None, None) + }; + + let identity = Identity { + title: None, + first_name, + middle_name: None, + last_name, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, // issuingCountry goes to custom fields + company: None, + email: None, + phone: None, + // Map identificationNumber to ssn + ssn: identity_document + .identification_number + .as_ref() + .map(|n| n.value.0.clone()), + username: None, + // Map documentNumber to passport_number (reusing for document number) + passport_number: identity_document + .document_number + .as_ref() + .map(|d| d.value.0.clone()), + license_number: None, + }; + + // Create custom fields for unmapped data according to CXF mapping document + let mut custom_fields = Vec::new(); + + if let Some(field) = create_custom_field( + identity_document.issuing_country.as_ref(), + "Issuing Country", + ) { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.nationality.as_ref(), "Nationality") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.birth_date.as_ref(), "Birth Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.birth_place.as_ref(), "Birth Place") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.sex.as_ref(), "Sex") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.issue_date.as_ref(), "Issue Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.expiry_date.as_ref(), "Expiry Date") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field( + identity_document.issuing_authority.as_ref(), + "Issuing Authority", + ) { + custom_fields.push(field); + } + // Note: identity-document doesn't have a document_type field in the CXF example + + (identity, custom_fields) +} + #[cfg(test)] mod tests { use std::fs; @@ -524,4 +622,75 @@ mod tests { assert_eq!(identity.address1, None); assert_eq!(identity.passport_number, None); } + + #[test] + fn test_identity_document_complete_mapping() { + // Test both unit logic and real data integration + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + + // Find the identity document cipher from cxf_example.json + let identity_document_cipher = ciphers + .iter() + .find(|c| c.name == "ID card") + .expect("Should find ID card item"); + + // Verify it's an Identity cipher + let identity = match &identity_document_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for identity document"), + }; + + // Verify Identity field mappings from cxf_example.json + assert_eq!(identity.passport_number, Some("123456789".to_string())); // documentNumber → passport_number + assert_eq!(identity.first_name, Some("Jane".to_string())); // fullName split + assert_eq!(identity.last_name, Some("Doe".to_string())); // fullName split + assert_eq!(identity.ssn, Some("ID123456789".to_string())); // identificationNumber → ssn + assert_eq!(identity.country, None); // issuingCountry goes to custom fields + + // Verify custom fields preserve unmapped data + assert!( + identity_document_cipher.fields.len() >= 6, + "Should have multiple custom fields" + ); + + // Check specific custom fields + let issuing_country = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Country")) + .expect("Should have Issuing Country"); + assert_eq!(issuing_country.value, Some("US".to_string())); + + let nationality = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Nationality")) + .expect("Should have Nationality"); + assert_eq!(nationality.value, Some("American".to_string())); + + let birth_place = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Birth Place")) + .expect("Should have Birth Place"); + assert_eq!(birth_place.value, Some("New York, USA".to_string())); + + let issuing_authority = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Authority")) + .expect("Should have Issuing Authority"); + assert_eq!( + issuing_authority.value, + Some("Department of State".to_string()) + ); + + // Verify unused Identity fields remain None + assert_eq!(identity.title, None); + assert_eq!(identity.address1, None); + assert_eq!(identity.license_number, None); + assert_eq!(identity.company, None); + } } diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 3d48e3f6d..89241ced4 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -1,8 +1,8 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, - CreditCardCredential, DriversLicenseCredential, Header, Item, PasskeyCredential, - PassportCredential, PersonNameCredential, WifiCredential, + CreditCardCredential, DriversLicenseCredential, Header, IdentityDocumentCredential, Item, + PasskeyCredential, PassportCredential, PersonNameCredential, WifiCredential, }; use crate::{ @@ -10,8 +10,8 @@ use crate::{ address::address_to_identity, api_key::api_key_to_fields, identity::{ - address_to_identity, drivers_license_to_identity, passport_to_identity, - person_name_to_identity, + address_to_identity, drivers_license_to_identity, identity_document_to_identity, + passport_to_identity, person_name_to_identity, }, login::{to_fields, to_login}, wifi::wifi_to_fields, @@ -216,6 +216,24 @@ fn parse_item(value: Item) -> Vec { }) } + // Identity document credentials + if let Some(identity_document) = grouped.identity_document.first() { + let (identity, custom_fields) = identity_document_to_identity(identity_document); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + output } @@ -273,6 +291,10 @@ fn group_credentials_by_type(credentials: Vec) -> GroupedCredentials Credential::DriversLicense(drivers_license) => Some(drivers_license.as_ref()), _ => None, }), + identity_document: filter_credentials(&credentials, |c| match c { + Credential::IdentityDocument(identity_document) => Some(identity_document.as_ref()), + _ => None, + }), } } @@ -286,6 +308,7 @@ struct GroupedCredentials { passport: Vec, person_name: Vec, drivers_license: Vec, + identity_document: Vec, } #[cfg(test)] From 1ae20397103a01fa442f21f943cd8b634501e78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 25 Jul 2025 12:22:18 +0200 Subject: [PATCH 05/17] remove tests from import.rs --- crates/bitwarden-exporters/src/cxf/import.rs | 124 ------------------- 1 file changed, 124 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 89241ced4..dd1e3c156 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -502,128 +502,4 @@ mod tests { assert_eq!(card.brand, Some("Mastercard".to_string())); assert_eq!(card.number, Some("1234 5678 9012 3456".to_string())); } - - #[test] - fn test_passport_complete_mapping_with_custom_fields() { - let result = load_sample_cxf(); - assert!(result.is_ok()); - let ciphers = result.unwrap(); - let passport_cipher = ciphers - .iter() - .find(|c| c.name == "Passport") - .expect("Should find Passport item"); - let identity = match &passport_cipher.r#type { - CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher"), - }; - - // Verify Identity field mappings - assert_eq!(identity.passport_number, Some("A12345678".to_string())); - assert_eq!(identity.first_name, Some("John".to_string())); - assert_eq!(identity.last_name, Some("Doe".to_string())); - assert_eq!(identity.ssn, Some("ID123456789".to_string())); - assert_eq!(identity.country, None); // Now custom field - - // Verify custom fields preserve all other data - assert!( - passport_cipher.fields.len() >= 4, - "Should have multiple custom fields" - ); - let issuing_country = passport_cipher - .fields - .iter() - .find(|f| f.name.as_deref() == Some("Issuing Country")) - .expect("Should have Issuing Country"); - assert_eq!(issuing_country.value, Some("US".to_string())); - let nationality = passport_cipher - .fields - .iter() - .find(|f| f.name.as_deref() == Some("Nationality")) - .expect("Should have Nationality"); - assert_eq!(nationality.value, Some("American".to_string())); - } - - #[test] - fn test_drivers_license_complete_mapping_with_custom_fields() { - let result = load_sample_cxf(); - assert!(result.is_ok()); - let ciphers = result.unwrap(); - let drivers_license_cipher = ciphers - .iter() - .find(|c| c.name == "Driver License") - .expect("Should find Driver License item"); - let identity = match &drivers_license_cipher.r#type { - CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher"), - }; - - // Verify Identity field mappings - assert_eq!(identity.license_number, Some("D12345678".to_string())); - assert_eq!(identity.first_name, Some("John".to_string())); - assert_eq!(identity.last_name, Some("Doe".to_string())); - assert_eq!(identity.state, Some("CA".to_string())); - assert_eq!(identity.country, Some("US".to_string())); - assert_eq!(identity.company, None); // Now custom field - - // Verify custom fields preserve all other data - assert!( - drivers_license_cipher.fields.len() >= 3, - "Should have multiple custom fields" - ); - let issuing_authority = drivers_license_cipher - .fields - .iter() - .find(|f| f.name.as_deref() == Some("Issuing Authority")) - .expect("Should have Issuing Authority"); - assert_eq!( - issuing_authority.value, - Some("Department of Motor Vehicles".to_string()) - ); - let license_class = drivers_license_cipher - .fields - .iter() - .find(|f| f.name.as_deref() == Some("License Class")) - .expect("Should have License Class"); - assert_eq!(license_class.value, Some("C".to_string())); - } - - #[test] - fn test_person_name_complete_mapping_with_custom_fields() { - let result = load_sample_cxf(); - assert!(result.is_ok()); - let ciphers = result.unwrap(); - let person_name_cipher = ciphers - .iter() - .find(|c| c.name == "John Doe") - .expect("Should find John Doe item"); - let identity = match &person_name_cipher.r#type { - CipherType::Identity(identity) => identity, - _ => panic!("Expected Identity cipher"), - }; - - // Verify Identity field mappings - assert_eq!(identity.title, Some("Dr.".to_string())); - assert_eq!(identity.first_name, Some("John".to_string())); - assert_eq!(identity.middle_name, Some("Michael".to_string())); - assert_eq!(identity.last_name, Some("van Doe Smith".to_string())); - assert_eq!(identity.company, Some("PhD".to_string())); - - // Verify custom fields preserve unmapped data - assert!( - person_name_cipher.fields.len() >= 2, - "Should have custom fields" - ); - let informal_given = person_name_cipher - .fields - .iter() - .find(|f| f.name.as_deref() == Some("Informal Given Name")) - .expect("Should have Informal Given Name"); - assert_eq!(informal_given.value, Some("Johnny".to_string())); - let generation = person_name_cipher - .fields - .iter() - .find(|f| f.name.as_deref() == Some("Generation")) - .expect("Should have Generation"); - assert_eq!(generation.value, Some("III".to_string())); - } } From 5621373e3b12e13e978c86c49038c8b570c450f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 12 Aug 2025 14:19:36 +0200 Subject: [PATCH 06/17] Use Editable field --- .../src/cxf/editable_field.rs | 12 +- .../bitwarden-exporters/src/cxf/identity.rs | 156 ++++++------------ crates/bitwarden-exporters/src/cxf/import.rs | 7 +- crates/bitwarden-exporters/src/cxf/mod.rs | 1 - 4 files changed, 63 insertions(+), 113 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/editable_field.rs b/crates/bitwarden-exporters/src/cxf/editable_field.rs index 32e645a72..a24337bd5 100644 --- a/crates/bitwarden-exporters/src/cxf/editable_field.rs +++ b/crates/bitwarden-exporters/src/cxf/editable_field.rs @@ -1,7 +1,7 @@ use bitwarden_vault::FieldType; use credential_exchange_format::{ - EditableField, EditableFieldBoolean, EditableFieldConcealedString, EditableFieldDate, - EditableFieldString, EditableFieldWifiNetworkSecurityType, + EditableField, EditableFieldBoolean, EditableFieldConcealedString, EditableFieldCountryCode, + EditableFieldDate, EditableFieldString, EditableFieldWifiNetworkSecurityType, }; use crate::Field; @@ -58,6 +58,14 @@ impl EditableFieldToField for EditableField { + const FIELD_TYPE: FieldType = FieldType::Text; + + fn field_value(&self) -> String { + self.value.0.clone() + } +} + impl EditableFieldToField for EditableField { const FIELD_TYPE: FieldType = FieldType::Text; diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index a80760ea1..6d7dcc2b7 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -1,47 +1,9 @@ -use bitwarden_vault::FieldType; use credential_exchange_format::{ - AddressCredential, DriversLicenseCredential, EditableField, EditableFieldCountryCode, - EditableFieldDate, EditableFieldString, IdentityDocumentCredential, PassportCredential, + AddressCredential, DriversLicenseCredential, IdentityDocumentCredential, PassportCredential, PersonNameCredential, }; -use crate::{Field, Identity}; - -/// Helper trait to extract value from various EditableField types -trait ExtractValue { - fn extract_value(&self) -> String; -} - -impl ExtractValue for EditableField { - fn extract_value(&self) -> String { - self.value.0.clone() - } -} - -impl ExtractValue for EditableField { - fn extract_value(&self) -> String { - self.value.0.clone() - } -} - -impl ExtractValue for EditableField { - fn extract_value(&self) -> String { - self.value.0.to_string() - } -} - -/// Generic helper function to create a custom field from any EditableField type -fn create_custom_field( - editable_field: Option<&T>, - field_name: &str, -) -> Option { - editable_field.map(|field| Field { - name: Some(field_name.to_string()), - value: Some(field.extract_value()), - r#type: FieldType::Text as u8, - linked_id: None, - }) -} +use crate::{cxf::editable_field::create_field, Field, Identity}; /// Convert address credentials to Identity (no custom fields needed for address) /// According to the mapping specification: @@ -128,34 +90,32 @@ pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec (Identity, // Create custom fields for unmapped data let mut custom_fields = Vec::new(); - if let Some(field) = - create_custom_field(person_name.given_informal.as_ref(), "Informal Given Name") - { - custom_fields.push(field); + if let Some(given_informal) = &person_name.given_informal { + custom_fields.push(create_field("Informal Given Name", given_informal)); } - if let Some(field) = create_custom_field(person_name.generation.as_ref(), "Generation") { - custom_fields.push(field); + if let Some(generation) = &person_name.generation { + custom_fields.push(create_field("Generation", generation)); } (identity, custom_fields) @@ -287,25 +245,20 @@ pub fn drivers_license_to_identity( // Create custom fields for unmapped data according to CXF mapping document let mut custom_fields = Vec::new(); - if let Some(field) = create_custom_field(drivers_license.birth_date.as_ref(), "Birth Date") { - custom_fields.push(field); + if let Some(birth_date) = &drivers_license.birth_date { + custom_fields.push(create_field("Birth Date", birth_date)); } - if let Some(field) = create_custom_field(drivers_license.issue_date.as_ref(), "Issue Date") { - custom_fields.push(field); + if let Some(issue_date) = &drivers_license.issue_date { + custom_fields.push(create_field("Issue Date", issue_date)); } - if let Some(field) = create_custom_field(drivers_license.expiry_date.as_ref(), "Expiry Date") { - custom_fields.push(field); + if let Some(expiry_date) = &drivers_license.expiry_date { + custom_fields.push(create_field("Expiry Date", expiry_date)); } - if let Some(field) = create_custom_field( - drivers_license.issuing_authority.as_ref(), - "Issuing Authority", - ) { - custom_fields.push(field); + if let Some(issuing_authority) = &drivers_license.issuing_authority { + custom_fields.push(create_field("Issuing Authority", issuing_authority)); } - if let Some(field) = - create_custom_field(drivers_license.license_class.as_ref(), "License Class") - { - custom_fields.push(field); + if let Some(license_class) = &drivers_license.license_class { + custom_fields.push(create_field("License Class", license_class)); } (identity, custom_fields) @@ -370,38 +323,29 @@ pub fn identity_document_to_identity( // Create custom fields for unmapped data according to CXF mapping document let mut custom_fields = Vec::new(); - if let Some(field) = create_custom_field( - identity_document.issuing_country.as_ref(), - "Issuing Country", - ) { - custom_fields.push(field); + if let Some(issuing_country) = &identity_document.issuing_country { + custom_fields.push(create_field("Issuing Country", issuing_country)); } - if let Some(field) = create_custom_field(identity_document.nationality.as_ref(), "Nationality") - { - custom_fields.push(field); + if let Some(nationality) = &identity_document.nationality { + custom_fields.push(create_field("Nationality", nationality)); } - if let Some(field) = create_custom_field(identity_document.birth_date.as_ref(), "Birth Date") { - custom_fields.push(field); + if let Some(birth_date) = &identity_document.birth_date { + custom_fields.push(create_field("Birth Date", birth_date)); } - if let Some(field) = create_custom_field(identity_document.birth_place.as_ref(), "Birth Place") - { - custom_fields.push(field); + if let Some(birth_place) = &identity_document.birth_place { + custom_fields.push(create_field("Birth Place", birth_place)); } - if let Some(field) = create_custom_field(identity_document.sex.as_ref(), "Sex") { - custom_fields.push(field); + if let Some(sex) = &identity_document.sex { + custom_fields.push(create_field("Sex", sex)); } - if let Some(field) = create_custom_field(identity_document.issue_date.as_ref(), "Issue Date") { - custom_fields.push(field); + if let Some(issue_date) = &identity_document.issue_date { + custom_fields.push(create_field("Issue Date", issue_date)); } - if let Some(field) = create_custom_field(identity_document.expiry_date.as_ref(), "Expiry Date") - { - custom_fields.push(field); + if let Some(expiry_date) = &identity_document.expiry_date { + custom_fields.push(create_field("Expiry Date", expiry_date)); } - if let Some(field) = create_custom_field( - identity_document.issuing_authority.as_ref(), - "Issuing Authority", - ) { - custom_fields.push(field); + if let Some(issuing_authority) = &identity_document.issuing_authority { + custom_fields.push(create_field("Issuing Authority", issuing_authority)); } // Note: identity-document doesn't have a document_type field in the CXF example diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index dd1e3c156..4b9e1de52 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -1,13 +1,12 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ - Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, - CreditCardCredential, DriversLicenseCredential, Header, IdentityDocumentCredential, Item, - PasskeyCredential, PassportCredential, PersonNameCredential, WifiCredential, + AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential, + DriversLicenseCredential, Header, IdentityDocumentCredential, Item, PasskeyCredential, + PassportCredential, PersonNameCredential, WifiCredential, }; use crate::{ cxf::{ - address::address_to_identity, api_key::api_key_to_fields, identity::{ address_to_identity, drivers_license_to_identity, identity_document_to_identity, diff --git a/crates/bitwarden-exporters/src/cxf/mod.rs b/crates/bitwarden-exporters/src/cxf/mod.rs index c645f01e9..4ea42f280 100644 --- a/crates/bitwarden-exporters/src/cxf/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/mod.rs @@ -14,7 +14,6 @@ mod import; pub(crate) use import::parse_cxf; mod api_key; mod card; -mod card; mod editable_field; mod identity; mod login; From de8b9e641d65c6f8bbc57b5106c36c9bca77367a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 12 Aug 2025 14:25:00 +0200 Subject: [PATCH 07/17] Use a more idiomatic rust pattern (iterators) --- .../bitwarden-exporters/src/cxf/identity.rs | 193 ++++++++++-------- 1 file changed, 113 insertions(+), 80 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index 6d7dcc2b7..14dc58765 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -88,35 +88,44 @@ pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec (Identity, }; // Create custom fields for unmapped data - let mut custom_fields = Vec::new(); - - if let Some(given_informal) = &person_name.given_informal { - custom_fields.push(create_field("Informal Given Name", given_informal)); - } - if let Some(generation) = &person_name.generation { - custom_fields.push(create_field("Generation", generation)); - } + let custom_fields = [ + person_name + .given_informal + .as_ref() + .map(|given_informal| create_field("Informal Given Name", given_informal)), + person_name + .generation + .as_ref() + .map(|generation| create_field("Generation", generation)), + ] + .into_iter() + .flatten() + .collect(); (identity, custom_fields) } @@ -243,23 +257,31 @@ pub fn drivers_license_to_identity( }; // Create custom fields for unmapped data according to CXF mapping document - let mut custom_fields = Vec::new(); - - if let Some(birth_date) = &drivers_license.birth_date { - custom_fields.push(create_field("Birth Date", birth_date)); - } - if let Some(issue_date) = &drivers_license.issue_date { - custom_fields.push(create_field("Issue Date", issue_date)); - } - if let Some(expiry_date) = &drivers_license.expiry_date { - custom_fields.push(create_field("Expiry Date", expiry_date)); - } - if let Some(issuing_authority) = &drivers_license.issuing_authority { - custom_fields.push(create_field("Issuing Authority", issuing_authority)); - } - if let Some(license_class) = &drivers_license.license_class { - custom_fields.push(create_field("License Class", license_class)); - } + let custom_fields = [ + drivers_license + .birth_date + .as_ref() + .map(|birth_date| create_field("Birth Date", birth_date)), + drivers_license + .issue_date + .as_ref() + .map(|issue_date| create_field("Issue Date", issue_date)), + drivers_license + .expiry_date + .as_ref() + .map(|expiry_date| create_field("Expiry Date", expiry_date)), + drivers_license + .issuing_authority + .as_ref() + .map(|issuing_authority| create_field("Issuing Authority", issuing_authority)), + drivers_license + .license_class + .as_ref() + .map(|license_class| create_field("License Class", license_class)), + ] + .into_iter() + .flatten() + .collect(); (identity, custom_fields) } @@ -321,32 +343,43 @@ pub fn identity_document_to_identity( }; // Create custom fields for unmapped data according to CXF mapping document - let mut custom_fields = Vec::new(); - - if let Some(issuing_country) = &identity_document.issuing_country { - custom_fields.push(create_field("Issuing Country", issuing_country)); - } - if let Some(nationality) = &identity_document.nationality { - custom_fields.push(create_field("Nationality", nationality)); - } - if let Some(birth_date) = &identity_document.birth_date { - custom_fields.push(create_field("Birth Date", birth_date)); - } - if let Some(birth_place) = &identity_document.birth_place { - custom_fields.push(create_field("Birth Place", birth_place)); - } - if let Some(sex) = &identity_document.sex { - custom_fields.push(create_field("Sex", sex)); - } - if let Some(issue_date) = &identity_document.issue_date { - custom_fields.push(create_field("Issue Date", issue_date)); - } - if let Some(expiry_date) = &identity_document.expiry_date { - custom_fields.push(create_field("Expiry Date", expiry_date)); - } - if let Some(issuing_authority) = &identity_document.issuing_authority { - custom_fields.push(create_field("Issuing Authority", issuing_authority)); - } + let custom_fields = [ + identity_document + .issuing_country + .as_ref() + .map(|issuing_country| create_field("Issuing Country", issuing_country)), + identity_document + .nationality + .as_ref() + .map(|nationality| create_field("Nationality", nationality)), + identity_document + .birth_date + .as_ref() + .map(|birth_date| create_field("Birth Date", birth_date)), + identity_document + .birth_place + .as_ref() + .map(|birth_place| create_field("Birth Place", birth_place)), + identity_document + .sex + .as_ref() + .map(|sex| create_field("Sex", sex)), + identity_document + .issue_date + .as_ref() + .map(|issue_date| create_field("Issue Date", issue_date)), + identity_document + .expiry_date + .as_ref() + .map(|expiry_date| create_field("Expiry Date", expiry_date)), + identity_document + .issuing_authority + .as_ref() + .map(|issuing_authority| create_field("Issuing Authority", issuing_authority)), + ] + .into_iter() + .flatten() + .collect(); // Note: identity-document doesn't have a document_type field in the CXF example (identity, custom_fields) From e3d512a1d7b6049634a05e3732fbac3bbf5dfba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 12 Aug 2025 17:41:51 +0200 Subject: [PATCH 08/17] Use the default trait --- crates/bitwarden-exporters/src/cxf/identity.rs | 14 +------------- crates/bitwarden-exporters/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index 14dc58765..eadea6ee1 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -228,32 +228,20 @@ pub fn drivers_license_to_identity( }; let identity = Identity { - title: None, first_name, - middle_name: None, last_name, - address1: None, - address2: None, - address3: None, - city: None, // Map territory (state/province) to state field state: drivers_license .territory .as_ref() .map(|t| t.value.0.clone()), - postal_code: None, // Map country to country field country: drivers_license.country.as_ref().map(|c| c.value.0.clone()), - company: None, // According to mapping doc, issuingAuthority should be CustomField - email: None, - phone: None, - ssn: None, - username: None, - passport_number: None, license_number: drivers_license .license_number .as_ref() .map(|l| l.value.0.clone()), + ..Default::default() }; // Create custom fields for unmapped data according to CXF mapping document diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs index 4466460d8..58ec6b30f 100644 --- a/crates/bitwarden-exporters/src/lib.rs +++ b/crates/bitwarden-exporters/src/lib.rs @@ -280,7 +280,7 @@ pub enum SecureNoteType { } #[allow(missing_docs)] -#[derive(Clone)] +#[derive(Clone, Default)] pub struct Identity { pub title: Option, pub first_name: Option, From ec43b32549385e75eb26b3b6640465b34cc0d496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 12 Aug 2025 19:04:52 +0200 Subject: [PATCH 09/17] Correct use of parse cxf --- crates/bitwarden-exporters/src/cxf/identity.rs | 8 ++------ crates/bitwarden-exporters/src/cxf/import.rs | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index eadea6ee1..1b171dcee 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -378,18 +378,14 @@ mod tests { use std::fs; // Tests only use the public parse_cxf function, no direct function imports needed - use crate::cxf::import::parse_cxf; + use crate::cxf::import::parse_cxf_spec; fn load_sample_cxf() -> Result, crate::cxf::CxfError> { // Read the actual CXF example file let cxf_data = fs::read_to_string("resources/cxf_example.json") .expect("Should be able to read cxf_example.json"); - // Workaround for library bug: the example file has "integrityHash" but the library expects - // "integrationHash" - let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); - - parse_cxf(fixed_cxf_data) + parse_cxf_spec(cxf_data) } #[test] diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 4b9e1de52..54ba6ebc0 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -1,8 +1,8 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ - AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential, - DriversLicenseCredential, Header, IdentityDocumentCredential, Item, PasskeyCredential, - PassportCredential, PersonNameCredential, WifiCredential, + Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, + CreditCardCredential, DriversLicenseCredential, IdentityDocumentCredential, Item, + PasskeyCredential, PassportCredential, PersonNameCredential, WifiCredential, }; use crate::{ From 6f2b9fb1834d6d9cf850b390b862576635058e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 12 Aug 2025 19:08:32 +0200 Subject: [PATCH 10/17] Use default --- .../bitwarden-exporters/src/cxf/identity.rs | 57 ++----------------- 1 file changed, 4 insertions(+), 53 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index 1b171dcee..39fecf133 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -15,24 +15,13 @@ use crate::{cxf::editable_field::create_field, Field, Identity}; /// - postalCode: EditableField<"string"> → Identity::postal_code pub fn address_to_identity(address: &AddressCredential) -> (Identity, Vec) { let identity = Identity { - title: None, - first_name: None, - middle_name: None, - last_name: None, address1: address.street_address.as_ref().map(|s| s.value.0.clone()), - address2: None, - address3: None, city: address.city.as_ref().map(|c| c.value.0.clone()), state: address.territory.as_ref().map(|t| t.value.0.clone()), postal_code: address.postal_code.as_ref().map(|p| p.value.0.clone()), country: address.country.as_ref().map(|c| c.value.0.clone()), - company: None, - email: None, phone: address.tel.as_ref().map(|t| t.value.0.clone()), - ssn: None, - username: None, - passport_number: None, - license_number: None, + ..Default::default() }; // Address credentials don't have unmapped fields, so no custom fields needed @@ -63,28 +52,15 @@ pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec (Identity, first_name: person_name.given.as_ref().map(|g| g.value.0.clone()), middle_name: person_name.given2.as_ref().map(|g2| g2.value.0.clone()), last_name, - address1: None, - address2: None, - address3: None, - city: None, - state: None, - postal_code: None, - country: None, // Map credentials (e.g., "PhD") to company field as professional qualifications company: person_name.credentials.as_ref().map(|c| c.value.0.clone()), - email: None, - phone: None, - ssn: None, - username: None, - passport_number: None, - license_number: None, + ..Default::default() }; // Create custom fields for unmapped data @@ -302,32 +266,19 @@ pub fn identity_document_to_identity( }; let identity = Identity { - title: None, first_name, - middle_name: None, last_name, - address1: None, - address2: None, - address3: None, - city: None, - state: None, - postal_code: None, - country: None, // issuingCountry goes to custom fields - company: None, - email: None, - phone: None, // Map identificationNumber to ssn ssn: identity_document .identification_number .as_ref() .map(|n| n.value.0.clone()), - username: None, // Map documentNumber to passport_number (reusing for document number) passport_number: identity_document .document_number .as_ref() .map(|d| d.value.0.clone()), - license_number: None, + ..Default::default() }; // Create custom fields for unmapped data according to CXF mapping document From 4f7d9ed3d46a8a945a946c7f88b3f10823abac33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 14 Aug 2025 15:47:15 +0200 Subject: [PATCH 11/17] Update crates/bitwarden-exporters/src/cxf/import.rs Co-authored-by: Oscar Hinton --- crates/bitwarden-exporters/src/cxf/import.rs | 63 +++----------------- 1 file changed, 8 insertions(+), 55 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 54ba6ebc0..b1daaae29 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -143,94 +143,47 @@ fn parse_item(value: Item) -> Vec { }) } - // Address credentials - if let Some(address) = grouped.address.first() { - let (identity, custom_fields) = address_to_identity(address); - + let mut add_item = |t: CipherType, fields: Vec| { output.push(ImportingCipher { folder_id: None, // TODO: Handle folders name: value.title.clone(), notes: None, - r#type: CipherType::Identity(Box::new(identity)), + r#type: t, favorite: false, reprompt: 0, - fields: custom_fields, + fields, revision_date, creation_date, deleted_date: None, }) - } + }; // Passport credentials if let Some(passport) = grouped.passport.first() { let (identity, custom_fields) = passport_to_identity(passport); - output.push(ImportingCipher { - folder_id: None, // TODO: Handle folders - name: value.title.clone(), - notes: None, - r#type: CipherType::Identity(Box::new(identity)), - favorite: false, - reprompt: 0, - fields: custom_fields, - revision_date, - creation_date, - deleted_date: None, - }) + add_item(CipherType::Identity(Box::new(identity)), custom_fields) } // Person name credentials if let Some(person_name) = grouped.person_name.first() { let (identity, custom_fields) = person_name_to_identity(person_name); - output.push(ImportingCipher { - folder_id: None, // TODO: Handle folders - name: value.title.clone(), - notes: None, - r#type: CipherType::Identity(Box::new(identity)), - favorite: false, - reprompt: 0, - fields: custom_fields, - revision_date, - creation_date, - deleted_date: None, - }) + add_item(CipherType::Identity(Box::new(identity)), custom_fields); } // Drivers license credentials if let Some(drivers_license) = grouped.drivers_license.first() { let (identity, custom_fields) = drivers_license_to_identity(drivers_license); - output.push(ImportingCipher { - folder_id: None, // TODO: Handle folders - name: value.title.clone(), - notes: None, - r#type: CipherType::Identity(Box::new(identity)), - favorite: false, - reprompt: 0, - fields: custom_fields, - revision_date, - creation_date, - deleted_date: None, - }) + add_item(CipherType::Identity(Box::new(identity)), custom_fields); } // Identity document credentials if let Some(identity_document) = grouped.identity_document.first() { let (identity, custom_fields) = identity_document_to_identity(identity_document); - output.push(ImportingCipher { - folder_id: None, // TODO: Handle folders - name: value.title.clone(), - notes: None, - r#type: CipherType::Identity(Box::new(identity)), - favorite: false, - reprompt: 0, - fields: custom_fields, - revision_date, - creation_date, - deleted_date: None, - }) + add_item(CipherType::Identity(Box::new(identity)), custom_fields); } output From fe47ac245883a226aca3904107a81d0cbf6cc92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 14 Aug 2025 15:49:12 +0200 Subject: [PATCH 12/17] fix --- crates/bitwarden-exporters/src/cxf/identity.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index 39fecf133..9c80b360d 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -13,7 +13,7 @@ use crate::{cxf::editable_field::create_field, Field, Identity}; /// - country: EditableField<"country-code"> → Identity::country /// - tel: EditableField<"string"> → Identity::phone /// - postalCode: EditableField<"string"> → Identity::postal_code -pub fn address_to_identity(address: &AddressCredential) -> (Identity, Vec) { +pub(super) fn address_to_identity(address: &AddressCredential) -> (Identity, Vec) { let identity = Identity { address1: address.street_address.as_ref().map(|s| s.value.0.clone()), city: address.city.as_ref().map(|c| c.value.0.clone()), @@ -24,7 +24,6 @@ pub fn address_to_identity(address: &AddressCredential) -> (Identity, Vec ..Default::default() }; - // Address credentials don't have unmapped fields, so no custom fields needed (identity, vec![]) } From c57ca7a0223b73a8deff135589d6d8e3c8176aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 14 Aug 2025 15:50:22 +0200 Subject: [PATCH 13/17] Update crates/bitwarden-exporters/src/cxf/identity.rs Co-authored-by: Oscar Hinton --- .../bitwarden-exporters/src/cxf/identity.rs | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index 9c80b360d..30857d95a 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -116,25 +116,17 @@ pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec (Identity, Vec) { // Construct complete last name from surnamePrefix, surname, and surname2 - let last_name = { - let mut parts = Vec::new(); - - if let Some(prefix) = &person_name.surname_prefix { - parts.push(prefix.value.0.clone()); - } - if let Some(surname) = &person_name.surname { - parts.push(surname.value.0.clone()); - } - if let Some(surname2) = &person_name.surname2 { - parts.push(surname2.value.0.clone()); - } - - if parts.is_empty() { - None - } else { - Some(parts.join(" ")) - } - }; + let last_name = [ + person_name.surname_prefix.as_ref(), + person_name.surname.as_ref(), + person_name.surname2.as_ref(), + ] + .into_iter() + .flatten() + .map(|field| field.value.0.clone()) + .collect::>() + .into_iter() + .reduce(|acc, part| format!("{} {}", acc, part)); let identity = Identity { title: person_name.title.as_ref().map(|t| t.value.0.clone()), From 77fbc9cbd0508311ca350dda22348b4b71e90d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 14 Aug 2025 16:06:48 +0200 Subject: [PATCH 14/17] Use a helper function and add back missing adress credentials --- .../bitwarden-exporters/src/cxf/identity.rs | 69 +++++++------------ crates/bitwarden-exporters/src/cxf/import.rs | 10 ++- 2 files changed, 33 insertions(+), 46 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index 30857d95a..f4101fbc3 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -1,6 +1,6 @@ use credential_exchange_format::{ - AddressCredential, DriversLicenseCredential, IdentityDocumentCredential, PassportCredential, - PersonNameCredential, + AddressCredential, DriversLicenseCredential, EditableField, EditableFieldString, + IdentityDocumentCredential, PassportCredential, PersonNameCredential, }; use crate::{cxf::editable_field::create_field, Field, Identity}; @@ -35,20 +35,8 @@ pub(super) fn address_to_identity(address: &AddressCredential) -> (Identity, Vec /// - All other fields → CustomFields pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec) { // Split full name into first and last name if available - let (first_name, last_name) = if let Some(full_name) = &passport.full_name { - let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); - match name_parts.len() { - 0 => (None, None), - 1 => (Some(name_parts[0].to_string()), None), - _ => { - let first = name_parts[0].to_string(); - let last = name_parts[1..].join(" "); - (Some(first), Some(last)) - } - } - } else { - (None, None) - }; + + let (first_name, last_name) = split_name(&passport.full_name); let identity = Identity { first_name, @@ -167,20 +155,7 @@ pub fn drivers_license_to_identity( drivers_license: &DriversLicenseCredential, ) -> (Identity, Vec) { // Split full name into first and last name if available - let (first_name, last_name) = if let Some(full_name) = &drivers_license.full_name { - let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); - match name_parts.len() { - 0 => (None, None), - 1 => (Some(name_parts[0].to_string()), None), - _ => { - let first = name_parts[0].to_string(); - let last = name_parts[1..].join(" "); - (Some(first), Some(last)) - } - } - } else { - (None, None) - }; + let (first_name, last_name) = split_name(&drivers_license.full_name); let identity = Identity { first_name, @@ -241,20 +216,7 @@ pub fn identity_document_to_identity( identity_document: &IdentityDocumentCredential, ) -> (Identity, Vec) { // Split full name into first and last name if available - let (first_name, last_name) = if let Some(full_name) = &identity_document.full_name { - let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); - match name_parts.len() { - 0 => (None, None), - 1 => (Some(name_parts[0].to_string()), None), - _ => { - let first = name_parts[0].to_string(); - let last = name_parts[1..].join(" "); - (Some(first), Some(last)) - } - } - } else { - (None, None) - }; + let (first_name, last_name) = split_name(&identity_document.full_name); let identity = Identity { first_name, @@ -315,6 +277,25 @@ pub fn identity_document_to_identity( (identity, custom_fields) } +fn split_name( + full_name: &Option>, +) -> (Option, Option) { + if let Some(full_name) = full_name { + let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); + match name_parts.len() { + 0 => (None, None), + 1 => (Some(name_parts[0].to_string()), None), + _ => { + let first = name_parts[0].to_string(); + let last = name_parts[1..].join(" "); + (Some(first), Some(last)) + } + } + } else { + (None, None) + } +} + #[cfg(test)] mod tests { use std::fs; diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index b1daaae29..c15490c9f 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -16,7 +16,7 @@ use crate::{ wifi::wifi_to_fields, CxfError, }, - CipherType, ImportingCipher, SecureNote, SecureNoteType, + CipherType, Field, ImportingCipher, SecureNote, SecureNoteType, }; /** @@ -143,7 +143,7 @@ fn parse_item(value: Item) -> Vec { }) } - let mut add_item = |t: CipherType, fields: Vec| { + let mut add_item = |t: CipherType, fields: Vec| { output.push(ImportingCipher { folder_id: None, // TODO: Handle folders name: value.title.clone(), @@ -158,6 +158,12 @@ fn parse_item(value: Item) -> Vec { }) }; + // Address credentials + if let Some(address) = grouped.address.first() { + let (identity, custom_fields) = address_to_identity(address); + add_item(CipherType::Identity(Box::new(identity)), custom_fields); + } + // Passport credentials if let Some(passport) = grouped.passport.first() { let (identity, custom_fields) = passport_to_identity(passport); From 48232279294aecf7262d0e8e62d99b94acd39659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 14 Aug 2025 16:58:47 +0200 Subject: [PATCH 15/17] method vis --- crates/bitwarden-exporters/src/cxf/identity.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index f4101fbc3..e6e0989fd 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -33,7 +33,7 @@ pub(super) fn address_to_identity(address: &AddressCredential) -> (Identity, Vec /// - nationalIdentificationNumber: EditableField<"string"> → Identity::ssn /// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) /// - All other fields → CustomFields -pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec) { +pub(super) fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec) { // Split full name into first and last name if available let (first_name, last_name) = split_name(&passport.full_name); @@ -102,7 +102,9 @@ pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec → Identity::company (as professional credentials) /// - Other fields → CustomFields -pub fn person_name_to_identity(person_name: &PersonNameCredential) -> (Identity, Vec) { +pub(super) fn person_name_to_identity( + person_name: &PersonNameCredential, +) -> (Identity, Vec) { // Construct complete last name from surnamePrefix, surname, and surname2 let last_name = [ person_name.surname_prefix.as_ref(), @@ -151,7 +153,7 @@ pub fn person_name_to_identity(person_name: &PersonNameCredential) -> (Identity, /// - territory: EditableField<"subdivision-code"> → Identity::state /// - country: EditableField<"country-code"> → Identity::country /// - All other fields → CustomFields -pub fn drivers_license_to_identity( +pub(super) fn drivers_license_to_identity( drivers_license: &DriversLicenseCredential, ) -> (Identity, Vec) { // Split full name into first and last name if available @@ -212,7 +214,7 @@ pub fn drivers_license_to_identity( /// - identificationNumber: EditableField<"string"> → Identity::ssn /// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) /// - All other fields → CustomFields -pub fn identity_document_to_identity( +pub(super) fn identity_document_to_identity( identity_document: &IdentityDocumentCredential, ) -> (Identity, Vec) { // Split full name into first and last name if available From 2cfea9c5ec6ce8c9531aeceb640fd362332475ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 14 Aug 2025 16:59:21 +0200 Subject: [PATCH 16/17] Update crates/bitwarden-exporters/src/cxf/identity.rs Co-authored-by: Oscar Hinton --- .../bitwarden-exporters/src/cxf/identity.rs | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs index e6e0989fd..3cef27e41 100644 --- a/crates/bitwarden-exporters/src/cxf/identity.rs +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -282,20 +282,14 @@ pub(super) fn identity_document_to_identity( fn split_name( full_name: &Option>, ) -> (Option, Option) { - if let Some(full_name) = full_name { - let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); - match name_parts.len() { - 0 => (None, None), - 1 => (Some(name_parts[0].to_string()), None), - _ => { - let first = name_parts[0].to_string(); - let last = name_parts[1..].join(" "); - (Some(first), Some(last)) - } + full_name.as_ref().map_or((None, None), |name| { + let parts: Vec<&str> = name.value.0.split_whitespace().collect(); + match parts.as_slice() { + [] => (None, None), + [first] => (Some(first.to_string()), None), + [first, rest @ ..] => (Some(first.to_string()), Some(rest.join(" "))), } - } else { - (None, None) - } + }) } #[cfg(test)] From b36e49e45e8a33d943496273aabf335a7a6ce3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 14 Aug 2025 17:05:01 +0200 Subject: [PATCH 17/17] fmt --- crates/bitwarden-exporters/src/cxf/import.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 655c79283..acb01cb91 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -1,8 +1,8 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, - CreditCardCredential, DriversLicenseCredential, IdentityDocumentCredential, Item, NoteCredential, - PasskeyCredential, PassportCredential, PersonNameCredential, WifiCredential, + CreditCardCredential, DriversLicenseCredential, IdentityDocumentCredential, Item, + NoteCredential, PasskeyCredential, PassportCredential, PersonNameCredential, WifiCredential, }; use crate::{ @@ -146,8 +146,8 @@ pub(crate) fn parse_item(value: Item) -> Vec { deleted_date: None, }) } - - // Standalone Note credentials -> Secure Note (only if no other credentials exist) + + // Standalone Note credentials -> Secure Note (only if no other credentials exist) if !grouped.note.is_empty() && output.is_empty() { let note_content = grouped.note.first().map(extract_note_content); @@ -161,11 +161,11 @@ pub(crate) fn parse_item(value: Item) -> Vec { favorite: false, reprompt: 0, fields: vec![], - revision_date, + revision_date, creation_date, deleted_date: None, - }) - } + }) + } let mut add_item = |t: CipherType, fields: Vec| { output.push(ImportingCipher {