Skip to main content

gaze_recognizers/
regex.rs

1use gaze_types::{
2    Candidate, ConflictTier, DetectContext, Detection, Detector, LocaleTag, PiiClass, Recognizer,
3    ValidatorKind,
4};
5use regex::Regex;
6
7use crate::{RecognizerError, Result};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum NormalizerKind {
12    EmailCanonical,
13    IbanCanonical,
14}
15
16impl NormalizerKind {
17    pub fn parse(s: &str) -> Result<Self> {
18        match s {
19            "email_canonical" => Ok(Self::EmailCanonical),
20            "iban_canonical" => Ok(Self::IbanCanonical),
21            other => Err(RecognizerError::UnsupportedNormalizer {
22                kind: other.to_string(),
23            }),
24        }
25    }
26
27    pub fn normalize(self, input: &str) -> String {
28        match self {
29            Self::EmailCanonical => input.to_ascii_lowercase(),
30            Self::IbanCanonical => iban_canonicalize(input),
31        }
32    }
33}
34
35/// Regex-backed [`Recognizer`] implementation.
36///
37/// Construct via [`RegexDetector::emails`] for the bundled email recognizer, or
38/// supply a custom pattern through the rulepack mechanism. Patterns use Rust
39/// regex syntax: no lookahead, lookbehind, or backreferences. Prefer TOML
40/// literal strings (`'...'`) in policy files to avoid double-escaping.
41///
42/// [`Candidate::span`] uses byte ranges, not char indices.
43///
44/// [`Candidate::span`]: gaze_types::Candidate::span
45pub struct RegexDetector {
46    regex: Regex,
47    class: PiiClass,
48    source: String,
49    locales: Vec<LocaleTag>,
50    base_score: f32,
51    priority: i32,
52    token_family: String,
53    capture_groups: Option<Vec<u32>>,
54    exclusions: Vec<String>,
55    validator_kind: Option<ValidatorKind>,
56    normalizer_kind: Option<NormalizerKind>,
57    ascii_email_boundary: bool,
58}
59
60impl RegexDetector {
61    pub fn new(pattern: &str, class: PiiClass) -> Result<Self> {
62        Self::with_source(pattern, class, "regex")
63    }
64
65    pub fn with_source(pattern: &str, class: PiiClass, source: &str) -> Result<Self> {
66        Self::with_rulepack_fields(
67            pattern,
68            class,
69            source,
70            vec![LocaleTag::Global],
71            0.70,
72            0,
73            "counter",
74            None,
75            Vec::new(),
76            None,
77            None,
78        )
79    }
80
81    #[allow(clippy::too_many_arguments)]
82    pub fn with_rulepack_fields(
83        pattern: &str,
84        class: PiiClass,
85        source: &str,
86        locales: Vec<LocaleTag>,
87        base_score: f32,
88        priority: i32,
89        token_family: &str,
90        capture_groups: Option<Vec<u32>>,
91        exclusions: Vec<String>,
92        validator_kind: Option<ValidatorKind>,
93        normalizer_kind: Option<NormalizerKind>,
94    ) -> Result<Self> {
95        let regex = Regex::new(pattern).map_err(RecognizerError::InvalidRegex)?;
96        let ascii_email_boundary = class == PiiClass::Email && source == "email.global";
97
98        Ok(Self {
99            regex,
100            class,
101            source: source.to_string(),
102            locales,
103            base_score,
104            priority,
105            token_family: token_family.to_string(),
106            capture_groups,
107            exclusions,
108            validator_kind,
109            normalizer_kind,
110            ascii_email_boundary,
111        })
112    }
113
114    pub fn emails() -> Result<Self> {
115        let mut detector = Self::new(
116            r"(?i)[a-z0-9_][a-z0-9._%+\-]*@[a-z0-9.\-]+\.[a-z]{2,}",
117            PiiClass::Email,
118        )?;
119        detector.ascii_email_boundary = true;
120        Ok(detector)
121    }
122}
123
124impl Detector for RegexDetector {
125    fn detect(&self, input: &str) -> Vec<Detection> {
126        self.regex
127            .captures_iter(input)
128            .filter_map(|caps| self.span_from_captures(&caps))
129            .filter(|span| self.boundary_accepts(input, span))
130            .map(|span| Detection::new(span, self.class.clone(), self.source.clone()))
131            .collect()
132    }
133}
134
135impl Recognizer for RegexDetector {
136    fn id(&self) -> &str {
137        &self.source
138    }
139
140    fn supported_class(&self) -> &PiiClass {
141        &self.class
142    }
143
144    fn detect(
145        &self,
146        input: &str,
147        _ctx: &DetectContext<'_>,
148    ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
149        Ok(self
150            .regex
151            .captures_iter(input)
152            .filter_map(|caps| {
153                let span = self.span_from_captures(&caps)?;
154                if !self.boundary_accepts(input, &span) {
155                    return None;
156                }
157                let matched = &input[span.clone()];
158                (!self.is_excluded(matched)).then_some((span, matched))
159            })
160            .map(|(span, matched)| {
161                let canonical_form = self.canonical_form(matched);
162                Candidate::new(
163                    span,
164                    self.class.clone(),
165                    self.source.clone(),
166                    self.base_score,
167                    self.priority,
168                    canonical_form,
169                    self.token_family(),
170                    self.source.clone(),
171                    ConflictTier::None,
172                    Vec::new(),
173                )
174            })
175            .collect())
176    }
177
178    fn token_family(&self) -> &str {
179        &self.token_family
180    }
181
182    fn validator_kind(&self) -> Option<ValidatorKind> {
183        self.validator_kind
184    }
185
186    fn locales(&self) -> &[LocaleTag] {
187        &self.locales
188    }
189}
190
191impl RegexDetector {
192    fn is_excluded(&self, matched: &str) -> bool {
193        self.exclusions
194            .iter()
195            .any(|excluded| matched.eq_ignore_ascii_case(excluded) || matched.contains(excluded))
196    }
197
198    fn canonical_form(&self, matched: &str) -> Option<String> {
199        match self.validator_kind {
200            #[cfg(feature = "phone-parser")]
201            Some(ValidatorKind::E164PhoneNational(_)) => {
202                self.validator_kind?.canonical_form(matched)
203            }
204            Some(validator_kind) if validator_kind.validates(matched) => {
205                Some(self.normalizer_kind.map_or_else(
206                    || matched.to_string(),
207                    |normalizer| normalizer.normalize(matched),
208                ))
209            }
210            Some(_) => None,
211            None => None,
212        }
213    }
214
215    fn span_from_captures(&self, caps: &regex::Captures<'_>) -> Option<std::ops::Range<usize>> {
216        if let Some(groups) = &self.capture_groups {
217            groups
218                .iter()
219                .filter_map(|group| caps.get(*group as usize))
220                .find(|m| !m.as_str().is_empty())
221                .map(|m| m.range())
222        } else {
223            caps.get(0).map(|m| m.range())
224        }
225    }
226
227    fn boundary_accepts(&self, input: &str, span: &std::ops::Range<usize>) -> bool {
228        if !self.ascii_email_boundary {
229            return true;
230        }
231
232        let previous_ok = input[..span.start]
233            .chars()
234            .next_back()
235            .is_none_or(|ch| !is_ascii_email_continuation(ch));
236        let next_ok = input[span.end..]
237            .chars()
238            .next()
239            .is_none_or(|ch| !is_ascii_email_continuation(ch));
240
241        previous_ok && next_ok
242    }
243}
244
245fn iban_canonicalize(input: &str) -> String {
246    input
247        .chars()
248        .filter(|ch| !ch.is_ascii_whitespace())
249        .flat_map(char::to_uppercase)
250        .collect()
251}
252
253fn is_ascii_email_continuation(ch: char) -> bool {
254    ch.is_ascii_alphanumeric() || ch == '_'
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn bundled_email_detector_matches_before_non_ascii_letter() {
263        let detector = RegexDetector::emails().expect("email detector");
264        let detections = Detector::detect(&detector, "a@example.invalidø");
265
266        assert_eq!(detections.len(), 1);
267        assert_eq!(detections[0].span, 0..17);
268    }
269
270    #[test]
271    fn bundled_email_detector_matches_after_non_ascii_letter() {
272        let detector = RegexDetector::emails().expect("email detector");
273        let detections = Detector::detect(&detector, "øa@example.invalid");
274
275        assert_eq!(detections.len(), 1);
276        assert_eq!(detections[0].span, 2..19);
277    }
278
279    #[test]
280    fn bundled_email_detector_matches_comma_separated_addresses() {
281        let detector = RegexDetector::emails().expect("email detector");
282        let detections = Detector::detect(&detector, "a@example.invalid,b@example.invalid");
283
284        assert_eq!(detections.len(), 2);
285        assert_eq!(detections[0].span, 0..17);
286        assert_eq!(detections[1].span, 18..35);
287    }
288
289    #[test]
290    fn bundled_email_detector_accepts_ascii_punctuation_suffixes() {
291        let detector = RegexDetector::emails().expect("email detector");
292
293        for (input, span) in [
294            ("Contact a@example.invalid. Thanks", 8..25),
295            ("a@example.invalid- call me", 0..17),
296            ("a@example.invalid+", 0..17),
297            ("a@example.invalid%", 0..17),
298        ] {
299            let detections = Detector::detect(&detector, input);
300            assert_eq!(detections.len(), 1, "{input}");
301            assert_eq!(detections[0].span, span, "{input}");
302        }
303    }
304
305    #[test]
306    fn bundled_email_detector_keeps_leading_delimiters_out_of_span() {
307        let detector = RegexDetector::emails().expect("email detector");
308
309        for input in [
310            ".a@example.invalid",
311            "-a@example.invalid",
312            "+a@example.invalid",
313        ] {
314            let detections = Detector::detect(&detector, input);
315            assert_eq!(detections.len(), 1, "{input}");
316            assert_eq!(detections[0].span, 1..18, "{input}");
317        }
318    }
319
320    #[test]
321    fn bundled_email_detector_rejects_ascii_continuation_suffix() {
322        let detector = RegexDetector::emails().expect("email detector");
323        let detections = Detector::detect(&detector, "a@example.invalid1");
324
325        assert!(detections.is_empty());
326    }
327
328    #[test]
329    fn email_rfc_validator_kind_populates_canonical_form() {
330        let detector = RegexDetector::with_rulepack_fields(
331            r"(?i)\b[a-z0-9._%+\-]+@example\.invalid\b",
332            PiiClass::Email,
333            "email.test",
334            vec![LocaleTag::Global],
335            0.70,
336            0,
337            "counter",
338            None,
339            Vec::new(),
340            Some(ValidatorKind::EmailRfc),
341            Some(NormalizerKind::EmailCanonical),
342        )
343        .expect("regex detector");
344        let dictionaries = gaze_types::DictionaryBundle::default();
345        let ctx = DetectContext::new(&[LocaleTag::Global], &dictionaries);
346        let detections =
347            Recognizer::detect(&detector, "Email Alice@Example.invalid", &ctx).unwrap();
348
349        assert_eq!(
350            detections[0].canonical_form.as_deref(),
351            Some("alice@example.invalid")
352        );
353    }
354
355    #[test]
356    #[cfg(feature = "phone-parser")]
357    fn national_phone_validator_kind_accepts_safe_fixtures() {
358        let us = ValidatorKind::parse("e164_phone_national_us").expect("US validator");
359        assert_eq!(
360            us.canonical_form(
361                // Source: NANPA 555-LINE Number Reservation.
362                // https://nationalnanpa.com/number_resource_info/555_numbers.html
363                "+1 555 0100"
364            )
365            .as_deref(),
366            Some("+15550100")
367        );
368
369        let de = ValidatorKind::parse("e164_phone_national_de").expect("DE validator");
370        assert_eq!(
371            de.canonical_form(
372                // Source: synthetic-non-reachable; no DE equivalent of NANPA 555-01XX exists;
373                // literals chosen for parser-valid + non-routable.
374                "+49 30 0000 0000"
375            )
376            .as_deref(),
377            Some("+493000000000")
378        );
379    }
380
381    #[test]
382    #[cfg(not(feature = "phone-parser"))]
383    fn national_phone_validator_kind_fails_closed_without_feature() {
384        let err = ValidatorKind::parse("e164_phone_national_us")
385            .expect_err("phone parser feature is disabled");
386        assert!(matches!(
387            err,
388            gaze_types::ValidatorKindParseError::UnsupportedValidator { kind }
389                if kind == "e164_phone_national_us"
390        ));
391    }
392
393    #[test]
394    fn regex_recognizer_uses_first_non_empty_capture_group() {
395        let detector = RegexDetector::with_rulepack_fields(
396            r#"(?m)^From:\s+(?:"([^"]+)"|([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+))\s+<[^>]+>"#,
397            PiiClass::Name,
398            "email.header.name",
399            vec![LocaleTag::Global],
400            0.90,
401            0,
402            "email.header.name",
403            Some(vec![1, 2]),
404            Vec::new(),
405            None,
406            None,
407        )
408        .expect("regex detector");
409        let dictionaries = gaze_types::DictionaryBundle::default();
410        let ctx = DetectContext::new(&[LocaleTag::Global], &dictionaries);
411        let input =
412            "From: Dana Weber <user@example.invalid>\nFrom: \"Prof. Weber\" <other@example.invalid>";
413
414        let candidates = Recognizer::detect(&detector, input, &ctx).unwrap();
415        let matched = candidates
416            .iter()
417            .map(|candidate| &input[candidate.span.clone()])
418            .collect::<Vec<_>>();
419
420        assert_eq!(matched, vec!["Dana Weber", "Prof. Weber"]);
421    }
422}