Skip to main content

gaze_recognizers/
regex.rs

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    /// Strict decimal dotted-quad IPv4 parser.
20    Ipv4Parse,
21    /// RFC 4291 / RFC 5952 IPv6 textual parser.
22    Ipv6Parse,
23    /// EIP-55 Ethereum address checksum validator.
24    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            // With phone-parser disabled, phone validators fall through here so
50            // rulepack construction fails closed instead of silently dropping candidates.
51            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
99/// Regex-backed [`Recognizer`] implementation.
100///
101/// Construct via [`RegexDetector::emails`] for the bundled email recognizer, or
102/// supply a custom pattern through the rulepack mechanism. Patterns use Rust
103/// regex syntax: no lookahead, lookbehind, or backreferences. Prefer TOML
104/// literal strings (`'...'`) in policy files to avoid double-escaping.
105///
106/// [`Candidate::span`] uses byte ranges, not char indices.
107///
108/// [`Candidate::span`]: gaze_types::Candidate::span
109pub 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: &regex::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        // Source: NANPA 555-LINE Number Reservation.
316        // https://nationalnanpa.com/number_resource_info/555_numbers.html
317        Region::Us => {
318            digits == "15550100"
319                || matches!(digits.strip_prefix('1'), Some(rest) if rest.len() == 10 && rest[3..].starts_with("55501"))
320        }
321        // Source: synthetic-non-reachable; no DE equivalent of NANPA 555-01XX exists;
322        // literals chosen for parser-valid + non-routable fixtures.
323        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                // Source: NANPA 555-LINE Number Reservation.
486                // https://nationalnanpa.com/number_resource_info/555_numbers.html
487                "+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                // Source: synthetic-non-reachable; no DE equivalent of NANPA 555-01XX exists;
501                // literals chosen for parser-valid + non-routable.
502                "+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}