1use super::{validation::validate_entity, Recognizer, RecognizerResult};
6use crate::types::EntityType;
7use anyhow::Result;
8use lazy_static::lazy_static;
9use regex::Regex;
10use std::collections::HashMap;
11
12struct SecretPattern {
18 entity_type: EntityType,
19 regex: &'static str,
20 score: f32,
21}
22
23const SECRET_PATTERNS: &[SecretPattern] = &[
30 SecretPattern {
31 entity_type: EntityType::PrivateKey,
32 regex: r"-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----",
33 score: 0.98,
34 },
35 SecretPattern {
36 entity_type: EntityType::JwtToken,
37 regex: r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*",
38 score: 0.90,
39 },
40 SecretPattern {
41 entity_type: EntityType::AwsAccessKey,
42 regex: r"\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[0-9A-Z]{16}\b",
43 score: 0.95,
44 },
45 SecretPattern {
46 entity_type: EntityType::AwsAccessKey,
47 regex: r"\b(ABSK[A-Za-z0-9+/]{109,269}={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
51 score: 0.95,
52 },
53 SecretPattern {
54 entity_type: EntityType::AwsAccessKey,
55 regex: r"\bbedrock-api-key-YmVkcm9jay5hbWF6b25hd3MuY29t\b",
56 score: 0.95,
57 },
58 SecretPattern {
59 entity_type: EntityType::GithubToken,
60 regex: r"\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\b",
61 score: 0.95,
62 },
63 SecretPattern {
64 entity_type: EntityType::GitlabToken,
65 regex: r"\bglpat-[A-Za-z0-9_-]{20}\b",
66 score: 0.95,
67 },
68 SecretPattern {
69 entity_type: EntityType::GitlabToken,
70 regex: r"\bglpat-[0-9a-zA-Z_-]{27,300}\.[0-9a-z]{2}[0-9a-z]{7}\b",
71 score: 0.95,
72 },
73 SecretPattern {
74 entity_type: EntityType::GitlabToken,
75 regex: r"\bglcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}\b",
76 score: 0.95,
77 },
78 SecretPattern {
79 entity_type: EntityType::GitlabToken,
80 regex: r"\bglagent-[A-Za-z0-9_-]{50}\b",
81 score: 0.95,
82 },
83 SecretPattern {
84 entity_type: EntityType::GitlabToken,
85 regex: r"\bgloas-[A-Za-z0-9_-]{64}\b",
86 score: 0.95,
87 },
88 SecretPattern {
89 entity_type: EntityType::GitlabToken,
90 regex: r"\bgldt-[A-Za-z0-9_-]{20}\b",
91 score: 0.95,
92 },
93 SecretPattern {
94 entity_type: EntityType::GitlabToken,
95 regex: r"\bglft-[A-Za-z0-9_-]{20}\b",
96 score: 0.95,
97 },
98 SecretPattern {
99 entity_type: EntityType::GitlabToken,
100 regex: r"\bglptt-[0-9a-fA-F]{40}\b",
101 score: 0.95,
102 },
103 SecretPattern {
104 entity_type: EntityType::SlackToken,
105 regex: r"\bxox[baprs]-[A-Za-z0-9-]{10,72}\b",
106 score: 0.95,
107 },
108 SecretPattern {
109 entity_type: EntityType::SlackWebhook,
110 regex: r"https://hooks\.slack\.com/services/T[A-Za-z0-9_]+/B[A-Za-z0-9_]+/[A-Za-z0-9_]{20,}",
111 score: 0.95,
112 },
113 SecretPattern {
114 entity_type: EntityType::StripeApiKey,
115 regex: r"\b(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,99}\b",
119 score: 0.95,
120 },
121 SecretPattern {
122 entity_type: EntityType::GoogleApiKey,
123 regex: r"\bAIza[0-9A-Za-z_-]{35}",
124 score: 0.95,
125 },
126 SecretPattern {
127 entity_type: EntityType::OpenAiApiKey,
128 regex: r"\bsk-(?:proj-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{32,})",
134 score: 0.90,
135 },
136 SecretPattern {
137 entity_type: EntityType::AnthropicApiKey,
138 regex: r"\bsk-ant-(?:api03-)?[A-Za-z0-9_-]{24,}",
139 score: 0.95,
140 },
141 SecretPattern {
142 entity_type: EntityType::NpmToken,
143 regex: r"\bnpm_[A-Za-z0-9]{36}\b",
144 score: 0.95,
145 },
146 SecretPattern {
147 entity_type: EntityType::PyPiToken,
148 regex: r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}",
149 score: 0.95,
150 },
151 SecretPattern {
152 entity_type: EntityType::SendGridApiKey,
153 regex: r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}",
154 score: 0.95,
155 },
156 SecretPattern {
157 entity_type: EntityType::TwilioApiKey,
158 regex: r"\bSK[0-9a-fA-F]{32}\b",
159 score: 0.85,
160 },
161 SecretPattern {
162 entity_type: EntityType::TelegramBotToken,
163 regex: r"\b\d{8,10}:AA[A-Za-z0-9_-]{33}",
164 score: 0.95,
165 },
166 SecretPattern {
167 entity_type: EntityType::HashicorpVaultToken,
168 regex: r"\bhv[sbr]\.[A-Za-z0-9_-]{24,}",
169 score: 0.95,
170 },
171 SecretPattern {
172 entity_type: EntityType::DatabaseConnectionString,
173 regex: r"\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|mariadb|redis|amqp|mssql)://[^:@/\s]+:[^@/\s]+@[^\s/]+(?:/[^\s?#]*)?(?:\?[^\s#]*)?",
177 score: 0.90,
178 },
179 SecretPattern {
180 entity_type: EntityType::HuggingFaceToken,
181 regex: r"\b(?:hf_|api_org_)[A-Za-z]{34}\b",
182 score: 0.95,
183 },
184 SecretPattern {
185 entity_type: EntityType::DatabricksToken,
186 regex: r"\bdapi[0-9a-fA-F]{32}(?:-\d)?\b",
187 score: 0.95,
188 },
189 SecretPattern {
190 entity_type: EntityType::DigitalOceanToken,
191 regex: r"\bdo[por]_v1_[0-9a-fA-F]{64}\b",
192 score: 0.95,
193 },
194 SecretPattern {
195 entity_type: EntityType::NotionApiKey,
196 regex: r"\bntn_[0-9]{11}[A-Za-z0-9]{35}\b",
197 score: 0.95,
198 },
199 SecretPattern {
200 entity_type: EntityType::PerplexityApiKey,
201 regex: r"\bpplx-[A-Za-z0-9]{48}\b",
202 score: 0.95,
203 },
204 SecretPattern {
205 entity_type: EntityType::HttpBasicAuth,
206 regex: r"(?i)\bBasic\s+([A-Za-z0-9+/]+={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
209 score: 0.95,
210 },
211];
212
213#[derive(Debug, Clone)]
215pub struct PatternRecognizer {
216 name: String,
217 patterns: HashMap<EntityType, Vec<CompiledPattern>>,
218 min_score: f32,
219}
220
221#[derive(Debug, Clone)]
222struct CompiledPattern {
223 regex: Regex,
224 score: f32,
225 context_words: Vec<String>,
226}
227
228impl PatternRecognizer {
229 pub fn new() -> Self {
231 let mut recognizer = Self {
232 name: "PatternRecognizer".to_string(),
233 patterns: HashMap::new(),
234 min_score: 0.5,
235 };
236 recognizer.load_default_patterns();
237 recognizer
238 }
239
240 pub fn with_name(name: impl Into<String>) -> Self {
242 let mut recognizer = Self::new();
243 recognizer.name = name.into();
244 recognizer
245 }
246
247 pub fn with_min_score(mut self, min_score: f32) -> Self {
249 self.min_score = min_score;
250 self
251 }
252
253 pub fn add_pattern(
255 &mut self,
256 entity_type: EntityType,
257 pattern: &str,
258 score: f32,
259 ) -> Result<()> {
260 let regex = Regex::new(pattern)?;
261 let compiled = CompiledPattern {
262 regex,
263 score,
264 context_words: vec![],
265 };
266 self.patterns.entry(entity_type).or_default().push(compiled);
267 Ok(())
268 }
269
270 pub fn add_pattern_with_context(
272 &mut self,
273 entity_type: EntityType,
274 pattern: &str,
275 score: f32,
276 context_words: Vec<String>,
277 ) -> Result<()> {
278 let regex = Regex::new(pattern)?;
279 let compiled = CompiledPattern {
280 regex,
281 score,
282 context_words,
283 };
284 self.patterns.entry(entity_type).or_default().push(compiled);
285 Ok(())
286 }
287
288 fn load_default_patterns(&mut self) {
290 let _ = self.add_pattern(
294 EntityType::EmailAddress,
295 r"(?:^|[^A-Za-z0-9._%+-])([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})(?:[^A-Za-z0-9]|$)",
296 0.8,
297 );
298
299 let _ = self.add_pattern(
305 EntityType::PhoneNumber,
306 r"\(\d{3}\)[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]?\d{4}\b",
307 0.7,
308 );
309
310 let _ = self.add_pattern(
315 EntityType::CreditCard,
316 r"(?:^|[^0-9A-Za-z])((?:\d[ \-]?){12,18}\d)(?:[^0-9]|$)",
317 0.9,
318 );
319
320 let _ = self.add_pattern(EntityType::UsSsn, r"\b\d{3}-\d{2}-\d{4}\b", 0.9);
323
324 let _ = self.add_pattern(
326 EntityType::IpAddress,
327 r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
328 0.8,
329 );
330
331 let _ = self.add_pattern(
333 EntityType::Url,
334 r"\b(?:https?://|www\.)[a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+(?:/[^\s]*)?\b",
335 0.7,
336 );
337
338 let _ = self.add_pattern(
340 EntityType::DomainName,
341 r"\b(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}\b",
342 0.7,
343 );
344
345 let _ = self.add_pattern(
347 EntityType::Guid,
348 r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
349 0.9,
350 );
351
352 let _ = self.add_pattern(
354 EntityType::MacAddress,
355 r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b",
356 0.9,
357 );
358
359 let _ = self.add_pattern_with_context(
361 EntityType::UkNhs,
362 r"\b(?:\d{3}\s?\d{3}\s?\d{4}|\d{10})\b",
363 0.6,
364 vec![
365 "NHS".to_string(),
366 "patient".to_string(),
367 "health".to_string(),
368 ],
369 );
370
371 let _ = self.add_pattern(
373 EntityType::UkNino,
374 r"\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}\d{6}[A-D]{1}\b",
375 0.85,
376 );
377
378 let _ = self.add_pattern(
380 EntityType::UkPostcode,
381 r"\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b",
382 0.75,
383 );
384
385 let _ = self.add_pattern(EntityType::UkSortCode, r"\b\d{2}-\d{2}-\d{2}\b", 0.7);
387
388 let _ = self.add_pattern(
391 EntityType::IbanCode,
392 r"(?:^|[^A-Z0-9])([A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30})(?:[^A-Z0-9]|$)",
393 0.75,
394 );
395
396 let _ = self.add_pattern(
398 EntityType::BtcAddress,
399 r"\b(?:bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}\b",
400 0.85,
401 );
402
403 let _ = self.add_pattern(EntityType::EthAddress, r"\b0x[a-fA-F0-9]{40}\b", 0.9);
405
406 let _ = self.add_pattern(EntityType::Md5Hash, r"\b[a-fA-F0-9]{32}\b", 0.6);
408
409 let _ = self.add_pattern(EntityType::Sha1Hash, r"\b[a-fA-F0-9]{40}\b", 0.6);
411
412 let _ = self.add_pattern(EntityType::Sha256Hash, r"\b[a-fA-F0-9]{64}\b", 0.6);
414
415 let _ = self.add_pattern(
417 EntityType::UsZipCode,
418 r"\b\d{5}(?:-\d{4})?\b",
419 0.6, );
421
422 let _ = self.add_pattern_with_context(
424 EntityType::PoBox,
425 r"\b(?:P\.?\s?O\.?|POST\s+OFFICE)\s*BOX\s+\d+\b",
426 0.85,
427 vec![
428 "address".to_string(),
429 "mail".to_string(),
430 "ship".to_string(),
431 ],
432 );
433
434 let _ = self.add_pattern(
436 EntityType::Isbn,
437 r"\b(?:ISBN(?:-1[03])?:?\s*)?(?:\d{9}[\dX]|\d{13})\b",
438 0.8,
439 );
440
441 let _ = self.add_pattern_with_context(
443 EntityType::PassportNumber,
444 r"\b[A-Z]{1,2}\d{6,9}\b",
445 0.7,
446 vec!["passport".to_string(), "travel".to_string()],
447 );
448
449 let _ = self.add_pattern_with_context(
451 EntityType::MedicalRecordNumber,
452 r"\b(?:MRN|Medical\s*Record|Patient\s*ID):?\s*[A-Z0-9]{6,12}\b",
453 0.85,
454 vec![
455 "patient".to_string(),
456 "medical".to_string(),
457 "hospital".to_string(),
458 ],
459 );
460
461 let _ = self.add_pattern_with_context(
463 EntityType::Age,
464 r"\b(?:age|aged|years old):?\s*(\d{1,3})\b",
465 0.8,
466 vec!["years".to_string(), "old".to_string(), "age".to_string()],
467 );
468
469 let _ = self.add_pattern(
471 EntityType::DateTime,
472 r"\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2})?)?\b",
473 0.5,
474 );
475
476 let _ = self.add_pattern_with_context(
482 EntityType::UsDriverLicense,
483 r"\b[A-Z]\d{6,8}\b|\b[A-Z]\d{3}-\d{4}-\d{4}\b",
484 0.4,
485 vec![
486 "driver".to_string(),
487 "license".to_string(),
488 "DL".to_string(),
489 "DMV".to_string(),
490 ],
491 );
492
493 let _ = self.add_pattern_with_context(
496 EntityType::UsPassport,
497 r"\b[A-Z]?\d{9}\b",
498 0.4,
499 vec![
500 "passport".to_string(),
501 "travel".to_string(),
502 "state department".to_string(),
503 ],
504 );
505
506 let _ = self.add_pattern_with_context(
510 EntityType::UsBankNumber,
511 r"(?:^|[^0-9A-Za-z])(\d{8,19})(?:[^0-9]|$)",
512 0.3,
513 vec![
514 "account number".to_string(),
515 "acct".to_string(),
516 "account no".to_string(),
517 "kontonummer".to_string(),
518 "número de cuenta".to_string(),
519 "số tài khoản".to_string(),
520 "账号".to_string(),
521 "口座番号".to_string(),
522 ],
523 );
524
525 let _ = self.add_pattern(
528 EntityType::UkDriverLicense,
529 r"\b[A-Z]{5}\d{6}[A-Z0-9]{2}\d[A-Z]{2}\s?\d{2}\b",
530 0.85,
531 );
532
533 let _ = self.add_pattern_with_context(
536 EntityType::UkPassportNumber,
537 r"\b\d{9}\b",
538 0.3,
539 vec![
540 "passport".to_string(),
541 "travel".to_string(),
542 "HMPO".to_string(),
543 ],
544 );
545
546 let _ = self.add_pattern(
548 EntityType::UkPhoneNumber,
549 r"\b(?:0[1-3]\d{2,3}\s?\d{3}\s?\d{4}|0[1-3]\d{2,3}\s?\d{6,7})\b",
550 0.75,
551 );
552
553 let _ = self.add_pattern(
555 EntityType::UkMobileNumber,
556 r"\b07\d{3}\s?\d{3}\s?\d{3}\b",
557 0.8,
558 );
559
560 let _ = self.add_pattern_with_context(
563 EntityType::UkCompanyNumber,
564 r"\b(?:\d{8}|[A-Z]{2}\d{6})\b",
565 0.3,
566 vec![
567 "company".to_string(),
568 "companies house".to_string(),
569 "registration".to_string(),
570 "CRN".to_string(),
571 ],
572 );
573
574 let _ = self.add_pattern_with_context(
576 EntityType::MedicalLicense,
577 r"\b(?:MD|DO|NP|PA|RN|LPN)[-\s]?\d{5,10}\b",
578 0.8,
579 vec![
580 "license".to_string(),
581 "medical".to_string(),
582 "physician".to_string(),
583 "doctor".to_string(),
584 "nurse".to_string(),
585 ],
586 );
587
588 let _ = self.add_pattern_with_context(
591 EntityType::CryptoWallet,
592 r"\b[LMr3][a-km-zA-HJ-NP-Z1-9]{25,34}\b",
593 0.75,
594 vec![
595 "wallet".to_string(),
596 "crypto".to_string(),
597 "address".to_string(),
598 "coin".to_string(),
599 ],
600 );
601
602 for p in SECRET_PATTERNS {
606 self.add_pattern(p.entity_type.clone(), p.regex, p.score)
607 .unwrap_or_else(|e| {
608 panic!(
609 "SECRET_PATTERNS regex failed to compile for {:?}: {e}",
610 p.entity_type
611 )
612 });
613 }
614 }
615
616 fn check_context(&self, text: &str, start: usize, end: usize, context_words: &[String]) -> f32 {
618 if context_words.is_empty() {
619 return 0.0;
620 }
621
622 let (context_start, context_end) = context_window(text, start, end, CONTEXT_CHARS);
623 let context = text
624 .get(context_start..context_end)
625 .unwrap_or("")
626 .to_lowercase();
627
628 let matches = context_words
630 .iter()
631 .filter(|word| context.contains(&word.to_lowercase()))
632 .count();
633
634 (matches as f32 / context_words.len() as f32) * 0.3
636 }
637}
638
639const CONTEXT_CHARS: usize = 50;
641
642fn context_window(text: &str, start: usize, end: usize, radius: usize) -> (usize, usize) {
647 let start = crate::floor_char_boundary(text, start);
648 let end = crate::ceil_char_boundary(text, end.max(start));
649 let window_start = text[..start]
650 .char_indices()
651 .rev()
652 .take(radius)
653 .last()
654 .map_or(start, |(index, _)| index);
655 let window_end = text[end..]
656 .char_indices()
657 .nth(radius)
658 .map_or(text.len(), |(index, _)| end + index);
659 (window_start, window_end)
660}
661
662impl Default for PatternRecognizer {
663 fn default() -> Self {
664 Self::new()
665 }
666}
667
668impl Recognizer for PatternRecognizer {
669 fn name(&self) -> &str {
670 &self.name
671 }
672
673 fn supported_entities(&self) -> &[EntityType] {
674 lazy_static! {
675 static ref SUPPORTED: Vec<EntityType> = vec![
676 EntityType::EmailAddress,
678 EntityType::PhoneNumber,
679 EntityType::IpAddress,
680 EntityType::Url,
681 EntityType::DomainName,
682 EntityType::CreditCard,
684 EntityType::IbanCode,
685 EntityType::UsBankNumber,
686 EntityType::UsSsn,
688 EntityType::UsDriverLicense,
689 EntityType::UsPassport,
690 EntityType::UsZipCode,
691 EntityType::UkNhs,
693 EntityType::UkNino,
694 EntityType::UkPostcode,
695 EntityType::UkSortCode,
696 EntityType::UkDriverLicense,
697 EntityType::UkPassportNumber,
698 EntityType::UkPhoneNumber,
699 EntityType::UkMobileNumber,
700 EntityType::UkCompanyNumber,
701 EntityType::MedicalLicense,
703 EntityType::MedicalRecordNumber,
704 EntityType::PassportNumber,
706 EntityType::Age,
707 EntityType::Isbn,
708 EntityType::PoBox,
709 EntityType::DateTime,
710 EntityType::CryptoWallet,
712 EntityType::BtcAddress,
713 EntityType::EthAddress,
714 EntityType::Guid,
716 EntityType::MacAddress,
717 EntityType::Md5Hash,
718 EntityType::Sha1Hash,
719 EntityType::Sha256Hash,
720 EntityType::PrivateKey,
722 EntityType::JwtToken,
723 EntityType::AwsAccessKey,
724 EntityType::GithubToken,
725 EntityType::GitlabToken,
726 EntityType::SlackToken,
727 EntityType::SlackWebhook,
728 EntityType::StripeApiKey,
729 EntityType::GoogleApiKey,
730 EntityType::OpenAiApiKey,
731 EntityType::AnthropicApiKey,
732 EntityType::NpmToken,
733 EntityType::PyPiToken,
734 EntityType::SendGridApiKey,
735 EntityType::TwilioApiKey,
736 EntityType::TelegramBotToken,
737 EntityType::HashicorpVaultToken,
738 EntityType::DatabaseConnectionString,
739 EntityType::HuggingFaceToken,
740 EntityType::DatabricksToken,
741 EntityType::DigitalOceanToken,
742 EntityType::NotionApiKey,
743 EntityType::PerplexityApiKey,
744 EntityType::HttpBasicAuth,
745 ];
746 }
747 &SUPPORTED
748 }
749
750 fn analyze(&self, text: &str, _language: &str) -> Result<Vec<RecognizerResult>> {
751 let _span = crate::operations_enabled()
752 .then(|| tracing::info_span!("redact.gateway.detect.patterns").entered());
753 let mut results = Vec::new();
754
755 for (entity_type, patterns) in &self.patterns {
756 for pattern in patterns {
757 for capture in pattern.regex.captures_iter(text) {
758 if let Some(matched) = match entity_type {
763 EntityType::HttpBasicAuth
764 | EntityType::AwsAccessKey
765 | EntityType::EmailAddress
766 | EntityType::CreditCard
767 | EntityType::IbanCode
768 | EntityType::UsBankNumber => capture.get(1).or_else(|| capture.get(0)),
769 _ => capture.get(0),
770 } {
771 let start = matched.start();
772 let end = matched.end();
773 let matched_text = matched.as_str();
774
775 let mut score = pattern.score;
777
778 if *entity_type == EntityType::UsBankNumber {
779 if self.check_context(text, start, end, &pattern.context_words) == 0.0 {
783 continue;
784 }
785 score = 0.6;
786 } else if !pattern.context_words.is_empty() {
787 score += self.check_context(text, start, end, &pattern.context_words);
788 score = score.min(1.0);
789 }
790
791 let validation_factor = validate_entity(entity_type, matched_text);
794 score *= validation_factor;
795
796 if score >= self.min_score {
797 results.push(
798 RecognizerResult::new(
799 entity_type.clone(),
800 start,
801 end,
802 score,
803 self.name(),
804 )
805 .with_text(text),
806 );
807 }
808 }
809 }
810 }
811 }
812
813 Ok(results)
814 }
815
816 fn min_score(&self) -> f32 {
817 self.min_score
818 }
819
820 fn supports_language(&self, _language: &str) -> bool {
821 true
822 }
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 #[test]
830 fn context_window_matches_legacy_byte_window_on_ascii() {
831 let text: String = (0..300).map(|i| (b'a' + (i % 26) as u8) as char).collect();
832 for start in 0..text.len() {
833 for end in start..(start + 20).min(text.len()) {
834 assert_eq!(
835 context_window(&text, start, end, 50),
836 (start.saturating_sub(50), (end + 50).min(text.len())),
837 "{start}..{end}"
838 );
839 }
840 }
841 }
842
843 #[test]
844 fn context_window_counts_vietnamese_chars_not_bytes() {
845 let text = format!("bank {}841234567890", "ạ ".repeat(20));
846 let start = text.find('8').unwrap();
847 let (window_start, window_end) = context_window(&text, start, start + 12, 50);
848 assert_eq!(text[window_start..start].chars().count(), 45);
849 assert_eq!(window_end, text.len());
850 assert!(text[window_start..window_end].contains("bank"));
851 }
852
853 #[test]
854 fn check_context_does_not_panic_on_multibyte_boundaries() {
855 let recognizer = PatternRecognizer::new();
856 for text in [
857 format!("841234567890{}ó", " ".repeat(49)),
858 format!("ử{}841234567890", " ".repeat(49)),
859 format!("841234567890{}📄", " ".repeat(48)),
860 "Mã đơn hàng 20260916 đã được xử lý thành công. Tổng cộng: 1.250.000 đồng.".to_string(),
861 ] {
862 recognizer.analyze(&text, "en").expect("analyze");
863 }
864 }
865
866 #[test]
867 fn test_age_span_includes_label() {
868 let recognizer = PatternRecognizer::new();
869 let text = "age: 42";
870 let results = recognizer.analyze(text, "en").unwrap();
871 let age = results
872 .iter()
873 .find(|r| r.entity_type == EntityType::Age)
874 .expect("AGE");
875 assert_eq!(&text[age.start..age.end], "age: 42");
876 }
877
878 #[test]
879 fn pattern_recognizer_supports_any_language() {
880 let recognizer = PatternRecognizer::new();
881 for language in ["en", "es", "vi", "zh", "de", "fr"] {
882 assert!(
883 recognizer.supports_language(language),
884 "{language} should run pattern detection"
885 );
886 }
887 }
888
889 #[test]
890 fn test_email_detection() {
891 let recognizer = PatternRecognizer::new();
892 let text = "Contact me at john.doe@example.com for details";
893 let results = recognizer.analyze(text, "en").unwrap();
894
895 let email_results: Vec<_> = results
896 .iter()
897 .filter(|r| r.entity_type == EntityType::EmailAddress)
898 .collect();
899 assert_eq!(email_results.len(), 1);
900 assert_eq!(
901 email_results[0].text,
902 Some("john.doe@example.com".to_string())
903 );
904 assert!(email_results[0].score >= 0.8);
905 }
906
907 #[test]
908 fn test_phone_detection() {
909 let recognizer = PatternRecognizer::new();
910 let text = "Call me at (555) 123-4567";
911 let results = recognizer.analyze(text, "en").unwrap();
912
913 assert!(!results.is_empty());
914 let phone_result = results
915 .iter()
916 .find(|r| r.entity_type == EntityType::PhoneNumber);
917 assert!(phone_result.is_some());
918 }
919
920 #[test]
921 fn test_credit_card_detection() {
922 let recognizer = PatternRecognizer::new();
923 let text = "Card number: 4532015112830366";
924 let results = recognizer.analyze(text, "en").unwrap();
925
926 assert!(!results.is_empty());
927 let cc_result = results
928 .iter()
929 .find(|r| r.entity_type == EntityType::CreditCard);
930 assert!(cc_result.is_some());
931 }
932
933 #[test]
934 fn test_spaced_amex_4_6_5_uses_full_span() {
935 let recognizer = PatternRecognizer::new();
936 let text = "Amex 3782 822463 10005";
937 let results = recognizer.analyze(text, "es").unwrap();
938 let card = results
939 .iter()
940 .find(|r| r.entity_type == EntityType::CreditCard)
941 .expect("spaced Amex");
942 assert_eq!(card.text.as_deref(), Some("3782 822463 10005"));
943 }
944
945 #[test]
946 fn test_email_and_card_after_cjk() {
947 let recognizer = PatternRecognizer::new();
948 let text = "邮箱maria@example.com卡4532015112830366";
949 let results = recognizer.analyze(text, "zh").unwrap();
950 let email = results
951 .iter()
952 .find(|r| r.entity_type == EntityType::EmailAddress)
953 .expect("CJK-adjacent email");
954 assert_eq!(email.text.as_deref(), Some("maria@example.com"));
955 let card = results
956 .iter()
957 .find(|r| r.entity_type == EntityType::CreditCard)
958 .expect("CJK-adjacent card");
959 assert_eq!(card.text.as_deref(), Some("4532015112830366"));
960 }
961
962 #[test]
963 fn test_spaced_iban_full_span() {
964 let recognizer = PatternRecognizer::new();
965 let text = "IBAN DE89 3704 0044 0532 0130 00";
966 let results = recognizer.analyze(text, "de").unwrap();
967 let iban = results
968 .iter()
969 .find(|r| r.entity_type == EntityType::IbanCode)
970 .expect("spaced IBAN");
971 assert_eq!(iban.text.as_deref(), Some("DE89 3704 0044 0532 0130 00"));
972 }
973
974 #[test]
975 fn test_ssn_detection() {
976 let recognizer = PatternRecognizer::new();
977 let text = "SSN: 123-45-6789";
978 let results = recognizer.analyze(text, "en").unwrap();
979
980 assert!(!results.is_empty());
981 let ssn_result = results.iter().find(|r| r.entity_type == EntityType::UsSsn);
982 assert!(ssn_result.is_some());
983 }
984
985 #[test]
986 fn test_uk_nhs_with_context() {
987 let recognizer = PatternRecognizer::new();
988 let text = "NHS patient number is 401 023 2137";
993 let results = recognizer.analyze(text, "en").unwrap();
994
995 assert!(!results.is_empty());
996 let nhs_result = results.iter().find(|r| r.entity_type == EntityType::UkNhs);
997 assert!(
998 nhs_result.is_some(),
999 "Should detect NHS number with context"
1000 );
1001 if let Some(result) = nhs_result {
1003 assert!(result.score > 0.6);
1004 }
1005 }
1006
1007 #[test]
1008 fn test_uk_nino_detection() {
1009 let recognizer = PatternRecognizer::new();
1010 let text = "NINO: AB123456C";
1011 let results = recognizer.analyze(text, "en").unwrap();
1012
1013 assert!(!results.is_empty());
1014 let nino_result = results.iter().find(|r| r.entity_type == EntityType::UkNino);
1015 assert!(nino_result.is_some());
1016 }
1017
1018 #[test]
1019 fn test_multiple_entities() {
1020 let recognizer = PatternRecognizer::new();
1021 let text = "Email john@example.com, phone (555) 123-4567, SSN 123-45-6789";
1022 let results = recognizer.analyze(text, "en").unwrap();
1023
1024 assert!(results.len() >= 3);
1025 assert!(results
1026 .iter()
1027 .any(|r| r.entity_type == EntityType::EmailAddress));
1028 assert!(results
1029 .iter()
1030 .any(|r| r.entity_type == EntityType::PhoneNumber));
1031 assert!(results.iter().any(|r| r.entity_type == EntityType::UsSsn));
1032 }
1033
1034 #[test]
1035 fn test_custom_pattern() {
1036 let mut recognizer = PatternRecognizer::new();
1037 recognizer
1038 .add_pattern(
1039 EntityType::Custom("CUSTOM_ID".to_string()),
1040 r"\bCID-\d{6}\b",
1041 0.9,
1042 )
1043 .unwrap();
1044
1045 let text = "Your customer ID is CID-123456";
1046 let results = recognizer.analyze(text, "en").unwrap();
1047
1048 let custom_result = results
1049 .iter()
1050 .find(|r| matches!(r.entity_type, EntityType::Custom(_)));
1051 assert!(custom_result.is_some());
1052 }
1053
1054 #[test]
1055 fn test_min_score_filtering() {
1056 let recognizer = PatternRecognizer::new().with_min_score(0.9);
1057 let text = "Date: 2024-01-15"; let results = recognizer.analyze(text, "en").unwrap();
1059
1060 let date_results = results
1062 .iter()
1063 .filter(|r| r.entity_type == EntityType::DateTime)
1064 .count();
1065 assert_eq!(date_results, 0);
1066 }
1067
1068 #[test]
1069 fn test_uk_driver_license_detection() {
1070 let recognizer = PatternRecognizer::new();
1071 let text = "UK DL: MORGA753116SM9IJ 35";
1072 let results = recognizer.analyze(text, "en").unwrap();
1073
1074 let dl_result = results
1075 .iter()
1076 .find(|r| r.entity_type == EntityType::UkDriverLicense);
1077 assert!(dl_result.is_some(), "Should detect UK driver's license");
1078 }
1079
1080 #[test]
1081 fn test_uk_mobile_detection() {
1082 let recognizer = PatternRecognizer::new();
1083 let text = "Call me on 07700 900123";
1084 let results = recognizer.analyze(text, "en").unwrap();
1085
1086 let mobile_result = results
1087 .iter()
1088 .find(|r| r.entity_type == EntityType::UkMobileNumber);
1089 assert!(mobile_result.is_some(), "Should detect UK mobile number");
1090 }
1091
1092 #[test]
1093 fn test_uk_phone_detection() {
1094 let recognizer = PatternRecognizer::new();
1095 let text = "Office: 0207 123 4567";
1096 let results = recognizer.analyze(text, "en").unwrap();
1097
1098 let phone_result = results
1099 .iter()
1100 .find(|r| r.entity_type == EntityType::UkPhoneNumber);
1101 assert!(phone_result.is_some(), "Should detect UK phone number");
1102 }
1103
1104 #[test]
1105 fn test_medical_license_detection() {
1106 let recognizer = PatternRecognizer::new();
1107 let text = "Medical license: MD-123456789";
1108 let results = recognizer.analyze(text, "en").unwrap();
1109
1110 let license_result = results
1111 .iter()
1112 .find(|r| r.entity_type == EntityType::MedicalLicense);
1113 assert!(license_result.is_some(), "Should detect medical license");
1114 }
1115
1116 #[test]
1117 fn test_supported_entities_count() {
1118 let recognizer = PatternRecognizer::new();
1119 let supported = recognizer.supported_entities();
1120 assert_eq!(
1122 supported.len(),
1123 60,
1124 "Should support 60 pattern-based entity types, got {}",
1125 supported.len()
1126 );
1127 }
1128}