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
35pub 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: ®ex::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 "+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 "+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}