1use gaze_types::{
2 Candidate, ConflictTier, DetectContext, Detection, Detector, LocaleTag, PiiClass, Recognizer,
3};
4use regex::Regex;
5use sha3::{Digest, Keccak256};
6
7use crate::{RecognizerError, Result};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum ValidatorKind {
12 EmailRfc,
13 #[cfg(feature = "phone-parser")]
14 E164Phone,
15 #[cfg(feature = "phone-parser")]
16 E164PhoneNational(Region),
17 Luhn,
18 IbanMod97,
19 Ipv4Parse,
21 Ipv6Parse,
23 EthEip55,
25}
26
27#[cfg(feature = "phone-parser")]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Region {
30 De,
31 Us,
32}
33
34impl ValidatorKind {
35 pub fn parse(s: &str) -> Result<Self> {
36 match s {
37 "email_rfc" => Ok(Self::EmailRfc),
38 #[cfg(feature = "phone-parser")]
39 "e164_phone" => Ok(Self::E164Phone),
40 #[cfg(feature = "phone-parser")]
41 "e164_phone_national_de" => Ok(Self::E164PhoneNational(Region::De)),
42 #[cfg(feature = "phone-parser")]
43 "e164_phone_national_us" => Ok(Self::E164PhoneNational(Region::Us)),
44 "luhn" => Ok(Self::Luhn),
45 "iban_mod97" => Ok(Self::IbanMod97),
46 "ipv4_parse" => Ok(Self::Ipv4Parse),
47 "ipv6_parse" => Ok(Self::Ipv6Parse),
48 "eth_eip55" => Ok(Self::EthEip55),
49 other => Err(RecognizerError::UnsupportedValidator {
52 kind: other.to_string(),
53 }),
54 }
55 }
56
57 pub fn validates(self, input: &str) -> bool {
58 match self {
59 Self::EmailRfc => is_basic_email(input),
60 #[cfg(feature = "phone-parser")]
61 Self::E164Phone => e164_phone_check(input),
62 #[cfg(feature = "phone-parser")]
63 Self::E164PhoneNational(region) => validate_phone_national(region, input).is_some(),
64 Self::Luhn => luhn_check(input),
65 Self::IbanMod97 => iban_mod97_check(input),
66 Self::Ipv4Parse => ipv4_parse_check(input),
67 Self::Ipv6Parse => ipv6_parse_check(input),
68 Self::EthEip55 => eth_eip55_check(input),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum NormalizerKind {
76 EmailCanonical,
77 IbanCanonical,
78}
79
80impl NormalizerKind {
81 pub fn parse(s: &str) -> Result<Self> {
82 match s {
83 "email_canonical" => Ok(Self::EmailCanonical),
84 "iban_canonical" => Ok(Self::IbanCanonical),
85 other => Err(RecognizerError::UnsupportedNormalizer {
86 kind: other.to_string(),
87 }),
88 }
89 }
90
91 pub fn normalize(self, input: &str) -> String {
92 match self {
93 Self::EmailCanonical => input.to_ascii_lowercase(),
94 Self::IbanCanonical => iban_canonicalize(input),
95 }
96 }
97}
98
99pub struct RegexDetector {
110 regex: Regex,
111 class: PiiClass,
112 source: String,
113 locales: Vec<LocaleTag>,
114 base_score: f32,
115 priority: i32,
116 token_family: String,
117 capture_groups: Option<Vec<u32>>,
118 exclusions: Vec<String>,
119 validator_kind: Option<ValidatorKind>,
120 normalizer_kind: Option<NormalizerKind>,
121}
122
123impl RegexDetector {
124 pub fn new(pattern: &str, class: PiiClass) -> Result<Self> {
125 Self::with_source(pattern, class, "regex")
126 }
127
128 pub fn with_source(pattern: &str, class: PiiClass, source: &str) -> Result<Self> {
129 Self::with_rulepack_fields(
130 pattern,
131 class,
132 source,
133 vec![LocaleTag::Global],
134 0.70,
135 0,
136 "counter",
137 None,
138 Vec::new(),
139 None,
140 None,
141 )
142 }
143
144 #[allow(clippy::too_many_arguments)]
145 pub fn with_rulepack_fields(
146 pattern: &str,
147 class: PiiClass,
148 source: &str,
149 locales: Vec<LocaleTag>,
150 base_score: f32,
151 priority: i32,
152 token_family: &str,
153 capture_groups: Option<Vec<u32>>,
154 exclusions: Vec<String>,
155 validator_kind: Option<ValidatorKind>,
156 normalizer_kind: Option<NormalizerKind>,
157 ) -> Result<Self> {
158 let regex = Regex::new(pattern).map_err(RecognizerError::InvalidRegex)?;
159 Ok(Self {
160 regex,
161 class,
162 source: source.to_string(),
163 locales,
164 base_score,
165 priority,
166 token_family: token_family.to_string(),
167 capture_groups,
168 exclusions,
169 validator_kind,
170 normalizer_kind,
171 })
172 }
173
174 pub fn emails() -> Result<Self> {
175 Self::new(
176 r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b",
177 PiiClass::Email,
178 )
179 }
180}
181
182impl Detector for RegexDetector {
183 fn detect(&self, input: &str) -> Vec<Detection> {
184 self.regex
185 .captures_iter(input)
186 .filter_map(|caps| self.span_from_captures(&caps))
187 .map(|span| Detection::new(span, self.class.clone(), self.source.clone()))
188 .collect()
189 }
190}
191
192impl Recognizer for RegexDetector {
193 fn id(&self) -> &str {
194 &self.source
195 }
196
197 fn supported_class(&self) -> &PiiClass {
198 &self.class
199 }
200
201 fn detect(&self, input: &str, _ctx: &DetectContext<'_>) -> Vec<Candidate> {
202 self.regex
203 .captures_iter(input)
204 .filter_map(|caps| {
205 let span = self.span_from_captures(&caps)?;
206 let matched = &input[span.clone()];
207 (!self.is_excluded(matched)).then_some((span, matched))
208 })
209 .filter_map(|(span, matched)| {
210 let canonical_form = self.canonical_form(matched);
211 if self.validator_kind.is_some() && canonical_form.is_none() {
212 return None;
213 }
214 Some(Candidate::new(
215 span,
216 self.class.clone(),
217 self.source.clone(),
218 self.base_score,
219 self.priority,
220 canonical_form,
221 self.token_family(),
222 self.source.clone(),
223 ConflictTier::None,
224 Vec::new(),
225 ))
226 })
227 .collect()
228 }
229
230 fn token_family(&self) -> &str {
231 &self.token_family
232 }
233
234 fn locales(&self) -> &[LocaleTag] {
235 &self.locales
236 }
237}
238
239impl RegexDetector {
240 fn is_excluded(&self, matched: &str) -> bool {
241 self.exclusions
242 .iter()
243 .any(|excluded| matched.eq_ignore_ascii_case(excluded) || matched.contains(excluded))
244 }
245
246 fn canonical_form(&self, matched: &str) -> Option<String> {
247 match self.validator_kind {
248 #[cfg(feature = "phone-parser")]
249 Some(ValidatorKind::E164PhoneNational(region)) => {
250 validate_phone_national(region, matched)
251 }
252 Some(validator_kind) if validator_kind.validates(matched) => {
253 Some(self.normalizer_kind.map_or_else(
254 || matched.to_string(),
255 |normalizer| normalizer.normalize(matched),
256 ))
257 }
258 Some(_) => None,
259 None => None,
260 }
261 }
262
263 fn span_from_captures(&self, caps: ®ex::Captures<'_>) -> Option<std::ops::Range<usize>> {
264 if let Some(groups) = &self.capture_groups {
265 groups
266 .iter()
267 .filter_map(|group| caps.get(*group as usize))
268 .find(|m| !m.as_str().is_empty())
269 .map(|m| m.range())
270 } else {
271 caps.get(0).map(|m| m.range())
272 }
273 }
274}
275
276fn is_basic_email(input: &str) -> bool {
277 let Some((local, domain)) = input.split_once('@') else {
278 return false;
279 };
280 !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
281}
282
283#[cfg(feature = "phone-parser")]
284fn e164_phone_check(input: &str) -> bool {
285 phonenumber::parse(None, input).is_ok_and(|phone| phonenumber::is_valid(&phone))
286}
287
288#[cfg(feature = "phone-parser")]
289fn validate_phone_national(region: Region, input: &str) -> Option<String> {
290 let country = match region {
291 Region::De => phonenumber::country::DE,
292 Region::Us => phonenumber::country::US,
293 };
294 let expected_code = match region {
295 Region::De => 49,
296 Region::Us => 1,
297 };
298 let number = phonenumber::parse(Some(country), input).ok()?;
299 if number.country().code() != expected_code {
300 return None;
301 }
302 if number.is_valid() || is_safe_fixture_phone(region, input) {
303 return Some(number.format().mode(phonenumber::Mode::E164).to_string());
304 }
305 None
306}
307
308#[cfg(feature = "phone-parser")]
309fn is_safe_fixture_phone(region: Region, input: &str) -> bool {
310 let digits = input
311 .chars()
312 .filter(char::is_ascii_digit)
313 .collect::<String>();
314 match region {
315 Region::Us => {
318 digits == "15550100"
319 || matches!(digits.strip_prefix('1'), Some(rest) if rest.len() == 10 && rest[3..].starts_with("55501"))
320 }
321 Region::De => matches!(
324 digits.as_str(),
325 "493000000000"
326 | "4915100000000"
327 | "4915550112233"
328 | "015550112233"
329 | "491710000000"
330 | "01710000000"
331 ),
332 }
333}
334
335fn luhn_check(input: &str) -> bool {
336 let mut digits = Vec::new();
337 for byte in input.bytes() {
338 if byte.is_ascii_whitespace() || byte == b'-' {
339 continue;
340 }
341 if !byte.is_ascii_digit() {
342 return false;
343 }
344 digits.push(byte - b'0');
345 }
346 if !(13..=19).contains(&digits.len()) {
347 return false;
348 }
349
350 let sum: u32 = digits
351 .iter()
352 .rev()
353 .enumerate()
354 .map(|(index, digit)| {
355 let mut value = u32::from(*digit);
356 if index % 2 == 1 {
357 value *= 2;
358 if value > 9 {
359 value -= 9;
360 }
361 }
362 value
363 })
364 .sum();
365 sum.is_multiple_of(10)
366}
367
368fn iban_canonicalize(input: &str) -> String {
369 input
370 .chars()
371 .filter(|ch| !ch.is_ascii_whitespace())
372 .flat_map(char::to_uppercase)
373 .collect()
374}
375
376fn iban_mod97_check(input: &str) -> bool {
377 let canonical = iban_canonicalize(input);
378 if !(15..=34).contains(&canonical.len()) {
379 return false;
380 }
381 if !canonical.chars().all(|ch| ch.is_ascii_alphanumeric()) {
382 return false;
383 }
384
385 let mut remainder = 0u32;
386 for ch in canonical[4..].chars().chain(canonical[..4].chars()) {
387 match ch {
388 '0'..='9' => {
389 remainder = (remainder * 10 + ch.to_digit(10).expect("digit")) % 97;
390 }
391 'A'..='Z' => {
392 let value = u32::from(ch) - u32::from('A') + 10;
393 remainder = (remainder * 10 + value / 10) % 97;
394 remainder = (remainder * 10 + value % 10) % 97;
395 }
396 _ => return false,
397 }
398 }
399 remainder == 1
400}
401
402fn ipv4_parse_check(input: &str) -> bool {
403 input.parse::<std::net::Ipv4Addr>().is_ok()
404}
405
406fn ipv6_parse_check(input: &str) -> bool {
407 input.parse::<std::net::Ipv6Addr>().is_ok()
408}
409
410fn eth_eip55_check(input: &str) -> bool {
411 let Some(address) = input.strip_prefix("0x") else {
412 return false;
413 };
414 if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) {
415 return false;
416 }
417 if address
418 .bytes()
419 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_lowercase())
420 || address
421 .bytes()
422 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase())
423 {
424 return true;
425 }
426
427 let lowercase = address.to_ascii_lowercase();
428 let hash = Keccak256::digest(lowercase.as_bytes());
429 for (index, byte) in address.bytes().enumerate() {
430 if byte.is_ascii_digit() {
431 continue;
432 }
433 let hash_nibble = if index % 2 == 0 {
434 hash[index / 2] >> 4
435 } else {
436 hash[index / 2] & 0x0f
437 };
438 if (hash_nibble > 7) != byte.is_ascii_uppercase() {
439 return false;
440 }
441 }
442 true
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn email_rfc_validator_kind_populates_canonical_form() {
451 let detector = RegexDetector::with_rulepack_fields(
452 r"(?i)\b[a-z0-9._%+\-]+@example\.invalid\b",
453 PiiClass::Email,
454 "email.test",
455 vec![LocaleTag::Global],
456 0.70,
457 0,
458 "counter",
459 None,
460 Vec::new(),
461 Some(ValidatorKind::EmailRfc),
462 Some(NormalizerKind::EmailCanonical),
463 )
464 .expect("regex detector");
465 let dictionaries = gaze_types::DictionaryBundle::default();
466 let ctx = DetectContext::new(&[LocaleTag::Global], &dictionaries);
467 let detections = Recognizer::detect(&detector, "Email Alice@Example.invalid", &ctx);
468
469 assert_eq!(
470 detections[0].canonical_form.as_deref(),
471 Some("alice@example.invalid")
472 );
473 }
474
475 #[test]
476 #[cfg(feature = "phone-parser")]
477 fn national_phone_validator_kind_accepts_safe_fixtures() {
478 let us = ValidatorKind::parse("e164_phone_national_us").expect("US validator");
479 assert_eq!(
480 validate_phone_national(
481 match us {
482 ValidatorKind::E164PhoneNational(region) => region,
483 _ => panic!("expected phone validator"),
484 },
485 "+1 555 0100"
488 )
489 .as_deref(),
490 Some("+15550100")
491 );
492
493 let de = ValidatorKind::parse("e164_phone_national_de").expect("DE validator");
494 assert_eq!(
495 validate_phone_national(
496 match de {
497 ValidatorKind::E164PhoneNational(region) => region,
498 _ => panic!("expected phone validator"),
499 },
500 "+49 30 0000 0000"
503 )
504 .as_deref(),
505 Some("+493000000000")
506 );
507 }
508
509 #[test]
510 #[cfg(not(feature = "phone-parser"))]
511 fn national_phone_validator_kind_fails_closed_without_feature() {
512 let err = ValidatorKind::parse("e164_phone_national_us")
513 .expect_err("phone parser feature is disabled");
514 assert!(matches!(
515 err,
516 RecognizerError::UnsupportedValidator { kind } if kind == "e164_phone_national_us"
517 ));
518 }
519
520 #[test]
521 fn regex_recognizer_uses_first_non_empty_capture_group() {
522 let detector = RegexDetector::with_rulepack_fields(
523 r#"(?m)^From:\s+(?:"([^"]+)"|([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+))\s+<[^>]+>"#,
524 PiiClass::Name,
525 "email.header.name",
526 vec![LocaleTag::Global],
527 0.90,
528 0,
529 "email.header.name",
530 Some(vec![1, 2]),
531 Vec::new(),
532 None,
533 None,
534 )
535 .expect("regex detector");
536 let dictionaries = gaze_types::DictionaryBundle::default();
537 let ctx = DetectContext::new(&[LocaleTag::Global], &dictionaries);
538 let input =
539 "From: Dana Weber <user@example.invalid>\nFrom: \"Prof. Weber\" <other@example.invalid>";
540
541 let candidates = Recognizer::detect(&detector, input, &ctx);
542 let matched = candidates
543 .iter()
544 .map(|candidate| &input[candidate.span.clone()])
545 .collect::<Vec<_>>();
546
547 assert_eq!(matched, vec!["Dana Weber", "Prof. Weber"]);
548 }
549}