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.pin.as_ref().map(|v| create_field("PIN", v)),
65        credential
66            .valid_from
67            .as_ref()
68            .map(|v| create_field("Valid From", v)),
69    ]
70    .into_iter()
71    .flatten()
72    .collect();
73
74    (card, fields)
75}
76
77/// Sanitize credit card brand
78///
79/// Performs a fuzzy match on the string to find a matching brand. By converting to lowercase and
80/// removing all whitespace.
81///
82/// - For recognized brands, the brand is normalized before being converted to a string.
83/// - For unrecognized brands, `None` is returned.
84fn sanitize_brand(value: &str) -> Option<String> {
85    match value.to_lowercase().replace(" ", "").as_str() {
86        "visa" => Some(CardBrand::Visa),
87        "mastercard" => Some(CardBrand::Mastercard),
88        "amex" | "americanexpress" => Some(CardBrand::Amex),
89        "discover" => Some(CardBrand::Discover),
90        "dinersclub" => Some(CardBrand::DinersClub),
91        "jcb" => Some(CardBrand::Jcb),
92        "maestro" => Some(CardBrand::Maestro),
93        "unionpay" => Some(CardBrand::UnionPay),
94        "rupay" => Some(CardBrand::RuPay),
95        _ => None,
96    }
97    .and_then(|brand| serde_json::to_value(&brand).ok())
98    .and_then(|v| v.as_str().map(|s| s.to_string()))
99}
100
101#[cfg(test)]
102mod tests {
103    use bitwarden_vault::FieldType;
104    use chrono::Month;
105    use credential_exchange_format::EditableFieldYearMonth;
106
107    use super::*;
108
109    #[test]
110    fn test_sanitize_brand() {
111        assert_eq!(sanitize_brand("Visa"), Some("Visa".to_string()));
112        assert_eq!(sanitize_brand("  visa  "), Some("Visa".to_string()));
113        assert_eq!(sanitize_brand("MasterCard"), Some("Mastercard".to_string()));
114        assert_eq!(sanitize_brand("amex"), Some("Amex".to_string()));
115        assert_eq!(sanitize_brand("American Express"), Some("Amex".to_string()));
116        assert_eq!(
117            sanitize_brand("DinersClub"),
118            Some("Diners Club".to_string())
119        );
120        assert_eq!(sanitize_brand("j c b"), Some("JCB".to_string()));
121        assert_eq!(sanitize_brand("Some unknown"), None);
122    }
123
124    #[test]
125    fn test_card_to_credentials() {
126        let card = Card {
127            cardholder_name: Some("John Doe".to_string()),
128            exp_month: Some("12".to_string()),
129            exp_year: Some("2025".to_string()),
130            code: Some("123".to_string()),
131            brand: Some("Visa".to_string()),
132            number: Some("4111111111111111".to_string()),
133        };
134
135        let credentials: Vec<Credential> = card.into();
136        assert_eq!(credentials.len(), 1);
137
138        if let Credential::CreditCard(credit_card) = &credentials[0] {
139            assert_eq!(credit_card.full_name.as_ref().unwrap().value.0, "John Doe");
140            assert_eq!(
141                credit_card.expiry_date.as_ref().unwrap().value,
142                EditableFieldYearMonth {
143                    year: 2025,
144                    month: Month::December
145                }
146            );
147            assert_eq!(
148                credit_card.verification_number.as_ref().unwrap().value.0,
149                "123".to_string()
150            );
151            assert_eq!(
152                credit_card.card_type.as_ref().unwrap().value.0,
153                "Visa".to_string()
154            );
155            assert_eq!(
156                credit_card.number.as_ref().unwrap().value.0,
157                "4111111111111111"
158            );
159        } else {
160            panic!("Expected CreditCardCredential");
161        }
162    }
163
164    #[test]
165    fn test_credit_card_credential_to_card() {
166        let credit_card = CreditCardCredential {
167            number: Some("4111111111111111".to_string().into()),
168            full_name: Some("John Doe".to_string().into()),
169            card_type: Some("Visa".to_string().into()),
170            verification_number: Some("123".to_string().into()),
171            pin: Some("4567".to_string().into()),
172            expiry_date: Some(
173                EditableFieldYearMonth {
174                    year: 2025,
175                    month: Month::December,
176                }
177                .into(),
178            ),
179            valid_from: Some(
180                EditableFieldYearMonth {
181                    year: 2024,
182                    month: Month::January,
183                }
184                .into(),
185            ),
186        };
187
188        let (card, fields) = to_card(&credit_card);
189        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
190        assert_eq!(card.exp_month, Some("12".to_string()));
191        assert_eq!(card.exp_year, Some("2025".to_string()));
192        assert_eq!(card.code, Some("123".to_string()));
193        assert_eq!(card.brand, Some("Visa".to_string()));
194        assert_eq!(card.number, Some("4111111111111111".to_string()));
195
196        assert_eq!(
197            fields,
198            vec![
199                Field {
200                    name: Some("PIN".to_string()),
201                    value: Some("4567".to_string()),
202                    r#type: FieldType::Hidden as u8,
203                    linked_id: None,
204                },
205                Field {
206                    name: Some("Valid From".to_string()),
207                    value: Some("2024-01".to_string()),
208                    r#type: FieldType::Text as u8,
209                    linked_id: None,
210                },
211            ]
212        )
213    }
214}