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}
58
59impl RegexDetector {
60    pub fn new(pattern: &str, class: PiiClass) -> Result<Self> {
61        Self::with_source(pattern, class, "regex")
62    }
63
64    pub fn with_source(pattern: &str, class: PiiClass, source: &str) -> Result<Self> {
65        Self::with_rulepack_fields(
66            pattern,
67            class,
68            source,
69            vec![LocaleTag::Global],
70            0.70,
71            0,
72            "counter",
73            None,
74            Vec::new(),
75            None,
76            None,
77        )
78    }
79
80    #[allow(clippy::too_many_arguments)]
81    pub fn with_rulepack_fields(
82        pattern: &str,
83        class: PiiClass,
84        source: &str,
85        locales: Vec<LocaleTag>,
86        base_score: f32,
87        priority: i32,
88        token_family: &str,
89        capture_groups: Option<Vec<u32>>,
90        exclusions: Vec<String>,
91        validator_kind: Option<ValidatorKind>,
92        normalizer_kind: Option<NormalizerKind>,
93    ) -> Result<Self> {
94        let regex = Regex::new(pattern).map_err(RecognizerError::InvalidRegex)?;
95        Ok(Self {
96            regex,
97            class,
98            source: source.to_string(),
99            locales,
100            base_score,
101            priority,
102            token_family: token_family.to_string(),
103            capture_groups,
104            exclusions,
105            validator_kind,
106            normalizer_kind,
107        })
108    }
109
110    pub fn emails() -> Result<Self> {
111        Self::new(
112            r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b",
113            PiiClass::Email,
114        )
115    }
116}
117
118impl Detector for RegexDetector {
119    fn detect(&self, input: &str) -> Vec<Detection> {
120        self.regex
121            .captures_iter(input)
122            .filter_map(|caps| self.span_from_captures(&caps))
123            .map(|span| Detection::new(span, self.class.clone(), self.source.clone()))
124            .collect()
125    }
126}
127
128impl Recognizer for RegexDetector {
129    fn id(&self) -> &str {
130        &self.source
131    }
132
133    fn supported_class(&self) -> &PiiClass {
134        &self.class
135    }
136
137    fn detect(&self, input: &str, _ctx: &DetectContext<'_>) -> Vec<Candidate> {
138        self.regex
139            .captures_iter(input)
140            .filter_map(|caps| {
141                let span = self.span_from_captures(&caps)?;
142                let matched = &input[span.clone()];
143                (!self.is_excluded(matched)).then_some((span, matched))
144            })
145            .map(|(span, matched)| {
146                let canonical_form = self.canonical_form(matched);
147                Candidate::new(
148                    span,
149                    self.class.clone(),
150                    self.source.clone(),
151                    self.base_score,
152                    self.priority,
153                    canonical_form,
154                    self.token_family(),
155                    self.source.clone(),
156                    ConflictTier::None,
157                    Vec::new(),
158                )
159            })
160            .collect()
161    }
162
163    fn token_family(&self) -> &str {
164        &self.token_family
165    }
166
167    fn validator_kind(&self) -> Option<ValidatorKind> {
168        self.validator_kind
169    }
170
171    fn locales(&self) -> &[LocaleTag] {
172        &self.locales
173    }
174}
175
176impl RegexDetector {
177    fn is_excluded(&self, matched: &str) -> bool {
178        self.exclusions
179            .iter()
180            .any(|excluded| matched.eq_ignore_ascii_case(excluded) || matched.contains(excluded))
181    }
182
183    fn canonical_form(&self, matched: &str) -> Option<String> {
184        match self.validator_kind {
185            #[cfg(feature = "phone-parser")]
186            Some(ValidatorKind::E164PhoneNational(_)) => {
187                self.validator_kind?.canonical_form(matched)
188            }
189            Some(validator_kind) if validator_kind.validates(matched) => {
190                Some(self.normalizer_kind.map_or_else(
191                    || matched.to_string(),
192                    |normalizer| normalizer.normalize(matched),
193                ))
194            }
195            Some(_) => None,
196            None => None,
197        }
198    }
199
200    fn span_from_captures(&self, caps: &regex::Captures<'_>) -> Option<std::ops::Range<usize>> {
201        if let Some(groups) = &self.capture_groups {
202            groups
203                .iter()
204                .filter_map(|group| caps.get(*group as usize))
205                .find(|m| !m.as_str().is_empty())
206                .map(|m| m.range())
207        } else {
208            caps.get(0).map(|m| m.range())
209        }
210    }
211}
212
213fn iban_canonicalize(input: &str) -> String {
214    input
215        .chars()
216        .filter(|ch| !ch.is_ascii_whitespace())
217        .flat_map(char::to_uppercase)
218        .collect()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn email_rfc_validator_kind_populates_canonical_form() {
227        let detector = RegexDetector::with_rulepack_fields(
228            r"(?i)\b[a-z0-9._%+\-]+@example\.invalid\b",
229            PiiClass::Email,
230            "email.test",
231            vec![LocaleTag::Global],
232            0.70,
233            0,
234            "counter",
235            None,
236            Vec::new(),
237            Some(ValidatorKind::EmailRfc),
238            Some(NormalizerKind::EmailCanonical),
239        )
240        .expect("regex detector");
241        let dictionaries = gaze_types::DictionaryBundle::default();
242        let ctx = DetectContext::new(&[LocaleTag::Global], &dictionaries);
243        let detections = Recognizer::detect(&detector, "Email Alice@Example.invalid", &ctx);
244
245        assert_eq!(
246            detections[0].canonical_form.as_deref(),
247            Some("alice@example.invalid")
248        );
249    }
250
251    #[test]
252    #[cfg(feature = "phone-parser")]
253    fn national_phone_validator_kind_accepts_safe_fixtures() {
254        let us = ValidatorKind::parse("e164_phone_national_us").expect("US validator");
255        assert_eq!(
256            us.canonical_form(
257                // Source: NANPA 555-LINE Number Reservation.
258                // https://nationalnanpa.com/number_resource_info/555_numbers.html
259                "+1 555 0100"
260            )
261            .as_deref(),
262            Some("+15550100")
263        );
264
265        let de = ValidatorKind::parse("e164_phone_national_de").expect("DE validator");
266        assert_eq!(
267            de.canonical_form(
268                // Source: synthetic-non-reachable; no DE equivalent of NANPA 555-01XX exists;
269                // literals chosen for parser-valid + non-routable.
270                "+49 30 0000 0000"
271            )
272            .as_deref(),
273            Some("+493000000000")
274        );
275    }
276
277    #[test]
278    #[cfg(not(feature = "phone-parser"))]
279    fn national_phone_validator_kind_fails_closed_without_feature() {
280        let err = ValidatorKind::parse("e164_phone_national_us")
281            .expect_err("phone parser feature is disabled");
282        assert!(matches!(
283            err,
284            gaze_types::ValidatorKindParseError::UnsupportedValidator { kind }
285                if kind == "e164_phone_national_us"
286        ));
287    }
288
289    #[test]
290    fn regex_recognizer_uses_first_non_empty_capture_group() {
291        let detector = RegexDetector::with_rulepack_fields(
292            r#"(?m)^From:\s+(?:"([^"]+)"|([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+))\s+<[^>]+>"#,
293            PiiClass::Name,
294            "email.header.name",
295            vec![LocaleTag::Global],
296            0.90,
297            0,
298            "email.header.name",
299            Some(vec![1, 2]),
300            Vec::new(),
301            None,
302            None,
303        )
304        .expect("regex detector");
305        let dictionaries = gaze_types::DictionaryBundle::default();
306        let ctx = DetectContext::new(&[LocaleTag::Global], &dictionaries);
307        let input =
308            "From: Dana Weber <user@example.invalid>\nFrom: \"Prof. Weber\" <other@example.invalid>";
309
310        let candidates = Recognizer::detect(&detector, input, &ctx);
311        let matched = candidates
312            .iter()
313            .map(|candidate| &input[candidate.span.clone()])
314            .collect::<Vec<_>>();
315
316        assert_eq!(matched, vec!["Dana Weber", "Prof. Weber"]);
317    }
318}