cloakrs_patterns/
credit_card.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CardBrand {
11 Visa,
13 Mastercard,
15 AmericanExpress,
17 Discover,
19}
20
21#[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#[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#[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}