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