Skip to main content

cloakrs_patterns/
credit_card.rs

1use crate::common::{compile_regex, confidence, digits};
2use cloakrs_core::{Confidence, EntityType, Locale, PiiEntity, Recognizer, Span};
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6static CREDIT_CARD_REGEX: Lazy<Regex> = Lazy::new(|| compile_regex(r"\b(?:\d[ -.]?){13,19}\b"));
7
8/// Known payment card brand families.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CardBrand {
11    /// Visa.
12    Visa,
13    /// Mastercard.
14    Mastercard,
15    /// American Express.
16    AmericanExpress,
17    /// Discover.
18    Discover,
19}
20
21/// Recognizes credit and debit card numbers with Luhn validation.
22#[derive(Debug, Clone, Copy, Default)]
23pub struct CreditCardRecognizer;
24
25impl Recognizer for CreditCardRecognizer {
26    fn id(&self) -> &str {
27        "credit_card_luhn_v1"
28    }
29
30    fn entity_type(&self) -> EntityType {
31        EntityType::CreditCard
32    }
33
34    fn supported_locales(&self) -> &[Locale] {
35        &[]
36    }
37
38    fn scan(&self, text: &str) -> Vec<PiiEntity> {
39        CREDIT_CARD_REGEX
40            .find_iter(text)
41            .filter(|matched| self.is_valid_match(text, matched.start(), matched.end()))
42            .map(|matched| PiiEntity {
43                entity_type: self.entity_type(),
44                span: Span::new(matched.start(), matched.end()),
45                text: matched.as_str().trim().to_string(),
46                confidence: compute_confidence(matched.as_str()),
47                recognizer_id: self.id().to_string(),
48            })
49            .collect()
50    }
51
52    fn validate(&self, candidate: &str) -> bool {
53        let digits = digits(candidate);
54        (13..=19).contains(&digits.len()) && luhn_valid(&digits)
55    }
56}
57
58impl CreditCardRecognizer {
59    fn is_valid_match(&self, text: &str, start: usize, end: usize) -> bool {
60        self.validate(&text[start..end])
61            && !continues_number_backwards(&text[..start])
62            && !continues_number_forwards(&text[end..])
63    }
64}
65
66fn continues_number_backwards(prefix: &str) -> bool {
67    let mut chars = prefix.chars().rev();
68    match chars.next() {
69        Some(c) if c.is_ascii_digit() => true,
70        Some(' ' | '-' | '.') => chars.next().is_some_and(|c| c.is_ascii_digit()),
71        _ => false,
72    }
73}
74
75fn continues_number_forwards(suffix: &str) -> bool {
76    let mut chars = suffix.chars();
77    match chars.next() {
78        Some(c) if c.is_ascii_digit() => true,
79        Some(' ' | '-' | '.') => chars.next().is_some_and(|c| c.is_ascii_digit()),
80        _ => false,
81    }
82}
83
84fn compute_confidence(candidate: &str) -> Confidence {
85    let digits = digits(candidate);
86    if card_brand(&digits).is_some() {
87        confidence(0.99)
88    } else {
89        confidence(0.60)
90    }
91}
92
93/// Returns true when the supplied digits pass the Luhn checksum.
94#[must_use]
95pub fn luhn_valid(value: &str) -> bool {
96    let digits: Vec<u32> = value.chars().filter_map(|c| c.to_digit(10)).collect();
97    if digits.len() < 13 {
98        return false;
99    }
100
101    let mut sum = 0u32;
102    let mut double = false;
103    for digit in digits.iter().rev() {
104        let mut value = *digit;
105        if double {
106            value *= 2;
107            if value > 9 {
108                value -= 9;
109            }
110        }
111        sum += value;
112        double = !double;
113    }
114    sum % 10 == 0
115}
116
117/// Identifies common payment card brand families from card digits.
118#[must_use]
119pub fn card_brand(digits: &str) -> Option<CardBrand> {
120    if digits.starts_with('4') && (13..=19).contains(&digits.len()) {
121        return Some(CardBrand::Visa);
122    }
123    if digits.len() == 15 && (digits.starts_with("34") || digits.starts_with("37")) {
124        return Some(CardBrand::AmericanExpress);
125    }
126    if digits.len() == 16 && (digits.starts_with("6011") || digits.starts_with("65")) {
127        return Some(CardBrand::Discover);
128    }
129    if digits.len() == 16 {
130        let prefix2 = digits[..2].parse::<u32>().ok();
131        let prefix4 = digits[..4].parse::<u32>().ok();
132        if prefix2.is_some_and(|prefix| (51..=55).contains(&prefix))
133            || prefix4.is_some_and(|prefix| (2221..=2720).contains(&prefix))
134        {
135            return Some(CardBrand::Mastercard);
136        }
137    }
138    None
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn texts(input: &str) -> Vec<String> {
146        CreditCardRecognizer
147            .scan(input)
148            .into_iter()
149            .map(|finding| finding.text)
150            .collect()
151    }
152
153    #[test]
154    fn test_credit_card_visa_spaces_detected() {
155        assert_eq!(texts("card 4111 1111 1111 1111"), ["4111 1111 1111 1111"]);
156    }
157
158    #[test]
159    fn test_credit_card_visa_dashes_detected() {
160        assert_eq!(texts("4111-1111-1111-1111"), ["4111-1111-1111-1111"]);
161    }
162
163    #[test]
164    fn test_credit_card_visa_plain_detected() {
165        assert_eq!(texts("4111111111111111"), ["4111111111111111"]);
166    }
167
168    #[test]
169    fn test_credit_card_amex_detected() {
170        assert_eq!(texts("3782 822463 10005"), ["3782 822463 10005"]);
171    }
172
173    #[test]
174    fn test_credit_card_mastercard_detected() {
175        assert_eq!(texts("5555 5555 5555 4444"), ["5555 5555 5555 4444"]);
176    }
177
178    #[test]
179    fn test_credit_card_invalid_luhn_rejected() {
180        assert!(texts("4111 1111 1111 1112").is_empty());
181    }
182
183    #[test]
184    fn test_credit_card_short_sequence_rejected() {
185        assert!(texts("1234 5678").is_empty());
186    }
187
188    #[test]
189    fn test_credit_card_luhn_valid_accepts_test_card() {
190        assert!(luhn_valid("4111111111111111"));
191    }
192
193    #[test]
194    fn test_credit_card_brand_identifies_visa() {
195        assert_eq!(card_brand("4111111111111111"), Some(CardBrand::Visa));
196    }
197
198    #[test]
199    fn test_credit_card_brand_identifies_mastercard_2_series() {
200        assert_eq!(card_brand("2221000000000009"), Some(CardBrand::Mastercard));
201    }
202
203    #[test]
204    fn test_credit_card_discover_detected() {
205        assert_eq!(texts("6011 1111 1111 1117"), ["6011 1111 1111 1117"]);
206    }
207
208    #[test]
209    fn test_credit_card_mastercard_2_series_detected() {
210        assert_eq!(texts("2221 0000 0000 0009"), ["2221 0000 0000 0009"]);
211    }
212
213    #[test]
214    fn test_credit_card_amex_compact_detected() {
215        assert_eq!(texts("371449635398431"), ["371449635398431"]);
216    }
217
218    #[test]
219    fn test_credit_card_dotted_detected() {
220        assert_eq!(texts("4111.1111.1111.1111"), ["4111.1111.1111.1111"]);
221    }
222
223    #[test]
224    fn test_credit_card_visa_13_digit_detected() {
225        assert_eq!(texts("4222222222222"), ["4222222222222"]);
226    }
227
228    #[test]
229    fn test_credit_card_random_16_digits_rejected() {
230        assert!(texts("1234 5678 9012 3456").is_empty());
231    }
232
233    #[test]
234    fn test_credit_card_too_long_rejected() {
235        assert!(texts("4111 1111 1111 1111 1111").is_empty());
236    }
237
238    #[test]
239    fn test_credit_card_embedded_in_word_not_detected() {
240        assert!(texts("id4111111111111111").is_empty());
241    }
242
243    #[test]
244    fn test_credit_card_brand_identifies_amex() {
245        assert_eq!(
246            card_brand("378282246310005"),
247            Some(CardBrand::AmericanExpress)
248        );
249    }
250
251    #[test]
252    fn test_credit_card_brand_identifies_discover() {
253        assert_eq!(card_brand("6011111111111117"), Some(CardBrand::Discover));
254    }
255
256    #[test]
257    fn test_credit_card_brand_returns_none_for_unknown() {
258        assert_eq!(card_brand("9011111111111111"), None);
259    }
260
261    #[test]
262    fn test_credit_card_luhn_rejects_invalid_check_digit() {
263        assert!(!luhn_valid("4111111111111112"));
264    }
265
266    #[test]
267    fn test_credit_card_validate_accepts_separators() {
268        assert!(CreditCardRecognizer.validate("4111-1111-1111-1111"));
269    }
270
271    #[test]
272    fn test_credit_card_validate_rejects_letters() {
273        assert!(!CreditCardRecognizer.validate("4111-1111-1111-ABCD"));
274    }
275
276    #[test]
277    fn test_credit_card_known_brand_confidence_is_high() {
278        let finding = CreditCardRecognizer.scan("4111 1111 1111 1111");
279        assert_eq!(finding[0].confidence.value(), 0.99);
280    }
281}