bitwarden_exporters/cxf/
card.rs

1//! Credit card credential conversion
2//!
3//! Handles conversion between internal [Card] and credential exchange [CreditCardCredential].
4
5use bitwarden_vault::CardBrand;
6use chrono::Month;
7use credential_exchange_format::{Credential, CreditCardCredential, EditableFieldYearMonth};
8use num_traits::FromPrimitive;
9
10use crate::{cxf::editable_field::create_field, Card, Field};
11
12impl From<Card> for Vec<Credential> {
13    fn from(value: Card) -> Self {
14        let expiry_date = match (value.exp_year, value.exp_month) {
15            (Some(year), Some(month)) => {
16                let year_parsed = year.parse().ok();
17                let numeric_month: Option<u32> = month.parse().ok();
18                let month_parsed = numeric_month.and_then(Month::from_u32);
19                match (year_parsed, month_parsed) {
20                    (Some(year), Some(month)) => {
21                        Some(EditableFieldYearMonth { year, month }.into())
22                    }
23                    _ => None,
24                }
25            }
26            _ => None,
27        };
28
29        vec![Credential::CreditCard(Box::new(CreditCardCredential {
30            number: value.number.map(|v| v.into()),
31            full_name: value.cardholder_name.map(|v| v.into()),
32            card_type: value.brand.map(|v| v.into()),
33            verification_number: value.code.map(|v| v.into()),
34            pin: None,
35            expiry_date,
36            valid_from: None,
37        }))]
38    }
39}
40
41impl From<&CreditCardCredential> for Card {
42    fn from(value: &CreditCardCredential) -> Self {
43        Card {
44            cardholder_name: value.full_name.clone().map(|v| v.into()),
45            exp_month: value
46                .expiry_date
47                .as_ref()
48                .map(|v| v.value.month.number_from_month().to_string()),
49            exp_year: value.expiry_date.as_ref().map(|v| v.value.year.to_string()),
50            code: value.verification_number.clone().map(|v| v.into()),
51            brand: value
52                .card_type
53                .as_ref()
54                .and_then(|brand| sanitize_brand(&brand.value.0)),
55            number: value.number.clone().map(|v| v.into()),
56        }
57    }
58}
59
60pub(super) fn to_card(credential: &CreditCardCredential) -> (Card, Vec<Field>) {
61    let card = credential.into();
62
63    let fields = [
64        credential
65            .pin
66            .as_ref()
67            .map(|v| create_field(v, Some("PIN"))),
68        credential
69            .valid_from
70            .as_ref()
71            .map(|v| create_field(v, Some("Valid From"))),
72    ]
73    .into_iter()
74    .flatten()
75    .collect();
76
77    (card, fields)
78}
79
80/// Sanitize credit card brand
81///
82/// Performs a fuzzy match on the string to find a matching brand. By converting to lowercase and
83/// removing all whitespace.
84///
85/// - For recognized brands, the brand is normalized before being converted to a string.
86/// - For unrecognized brands, `None` is returned.
87fn sanitize_brand(value: &str) -> Option<String> {
88    match value.to_lowercase().replace(" ", "").as_str() {
89        "visa" => Some(CardBrand::Visa),
90        "mastercard" => Some(CardBrand::Mastercard),
91        "amex" | "americanexpress" => Some(CardBrand::Amex),
92        "discover" => Some(CardBrand::Discover),
93        "dinersclub" => Some(CardBrand::DinersClub),
94        "jcb" => Some(CardBrand::Jcb),
95        "maestro" => Some(CardBrand::Maestro),
96        "unionpay" => Some(CardBrand::UnionPay),
97        "rupay" => Some(CardBrand::RuPay),
98        _ => None,
99    }
100    .and_then(|brand| serde_json::to_value(&brand).ok())
101    .and_then(|v| v.as_str().map(|s| s.to_string()))
102}
103
104#[cfg(test)]
105mod tests {
106    use bitwarden_vault::FieldType;
107    use chrono::Month;
108    use credential_exchange_format::EditableFieldYearMonth;
109
110    use super::*;
111
112    #[test]
113    fn test_sanitize_brand() {
114        assert_eq!(sanitize_brand("Visa"), Some("Visa".to_string()));
115        assert_eq!(sanitize_brand("  visa  "), Some("Visa".to_string()));
116        assert_eq!(sanitize_brand("MasterCard"), Some("Mastercard".to_string()));
117        assert_eq!(sanitize_brand("amex"), Some("Amex".to_string()));
118        assert_eq!(sanitize_brand("American Express"), Some("Amex".to_string()));
119        assert_eq!(
120            sanitize_brand("DinersClub"),
121            Some("Diners Club".to_string())
122        );
123        assert_eq!(sanitize_brand("j c b"), Some("JCB".to_string()));
124        assert_eq!(sanitize_brand("Some unknown"), None);
125    }
126
127    #[test]
128    fn test_card_to_credentials() {
129        let card = Card {
130            cardholder_name: Some("John Doe".to_string()),
131            exp_month: Some("12".to_string()),
132            exp_year: Some("2025".to_string()),
133            code: Some("123".to_string()),
134            brand: Some("Visa".to_string()),
135            number: Some("4111111111111111".to_string()),
136        };
137
138        let credentials: Vec<Credential> = card.into();
139        assert_eq!(credentials.len(), 1);
140
141        if let Credential::CreditCard(credit_card) = &credentials[0] {
142            assert_eq!(credit_card.full_name.as_ref().unwrap().value.0, "John Doe");
143            assert_eq!(
144                credit_card.expiry_date.as_ref().unwrap().value,
145                EditableFieldYearMonth {
146                    year: 2025,
147                    month: Month::December
148                }
149            );
150            assert_eq!(
151                credit_card.verification_number.as_ref().unwrap().value.0,
152                "123".to_string()
153            );
154            assert_eq!(
155                credit_card.card_type.as_ref().unwrap().value.0,
156                "Visa".to_string()
157            );
158            assert_eq!(
159                credit_card.number.as_ref().unwrap().value.0,
160                "4111111111111111"
161            );
162        } else {
163            panic!("Expected CreditCardCredential");
164        }
165    }
166
167    #[test]
168    fn test_credit_card_credential_to_card() {
169        let credit_card = CreditCardCredential {
170            number: Some("4111111111111111".to_string().into()),
171            full_name: Some("John Doe".to_string().into()),
172            card_type: Some("Visa".to_string().into()),
173            verification_number: Some("123".to_string().into()),
174            pin: Some("4567".to_string().into()),
175            expiry_date: Some(
176                EditableFieldYearMonth {
177                    year: 2025,
178                    month: Month::December,
179                }
180                .into(),
181            ),
182            valid_from: Some(
183                EditableFieldYearMonth {
184                    year: 2024,
185                    month: Month::January,
186                }
187                .into(),
188            ),
189        };
190
191        let (card, fields) = to_card(&credit_card);
192        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
193        assert_eq!(card.exp_month, Some("12".to_string()));
194        assert_eq!(card.exp_year, Some("2025".to_string()));
195        assert_eq!(card.code, Some("123".to_string()));
196        assert_eq!(card.brand, Some("Visa".to_string()));
197        assert_eq!(card.number, Some("4111111111111111".to_string()));
198
199        assert_eq!(
200            fields,
201            vec![
202                Field {
203                    name: Some("PIN".to_string()),
204                    value: Some("4567".to_string()),
205                    r#type: FieldType::Hidden as u8,
206                    linked_id: None,
207                },
208                Field {
209                    name: Some("Valid From".to_string()),
210                    value: Some("2024-01".to_string()),
211                    r#type: FieldType::Text as u8,
212                    linked_id: None,
213                },
214            ]
215        )
216    }
217}