1use std::collections::hash_map::DefaultHasher;
2use std::collections::HashMap;
3use std::hash::{Hash, Hasher};
4use std::sync::{Arc, Mutex};
5
6use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
7use gaze_types::{
8 Candidate, ConflictTier, DetectContext, DictionaryEntry, LocaleTag, PiiClass, Recognizer,
9};
10
11pub struct DictionaryRecognizer {
21 id: String,
22 class: PiiClass,
23 dictionary_name: String,
24 case_sensitive: bool,
25 token_family: String,
26 locales: Vec<LocaleTag>,
27 score: f32,
28 priority: i32,
29 compiled_dictionaries: Mutex<HashMap<DictionaryCacheKey, Arc<AhoCorasick>>>,
30}
31
32impl DictionaryRecognizer {
33 pub fn new(
34 id: impl Into<String>,
35 class: PiiClass,
36 dictionary_name: impl Into<String>,
37 case_sensitive: bool,
38 token_family: impl Into<String>,
39 ) -> Self {
40 Self::with_rulepack_fields(
41 id,
42 class,
43 dictionary_name,
44 case_sensitive,
45 token_family,
46 vec![LocaleTag::Global],
47 1.0,
48 0,
49 )
50 }
51
52 #[allow(clippy::too_many_arguments)]
53 pub fn with_rulepack_fields(
54 id: impl Into<String>,
55 class: PiiClass,
56 dictionary_name: impl Into<String>,
57 case_sensitive: bool,
58 token_family: impl Into<String>,
59 locales: Vec<LocaleTag>,
60 score: f32,
61 priority: i32,
62 ) -> Self {
63 Self {
64 id: id.into(),
65 class,
66 dictionary_name: dictionary_name.into(),
67 case_sensitive,
68 token_family: token_family.into(),
69 locales,
70 score,
71 priority,
72 compiled_dictionaries: Mutex::new(HashMap::new()),
73 }
74 }
75
76 pub fn dictionary_name(&self) -> &str {
77 &self.dictionary_name
78 }
79
80 pub fn case_sensitive(&self) -> bool {
81 self.case_sensitive
82 }
83
84 fn automaton_for(&self, entry: &DictionaryEntry) -> Arc<AhoCorasick> {
85 let key = DictionaryCacheKey::from_entry(entry);
86 let mut compiled = self
87 .compiled_dictionaries
88 .lock()
89 .expect("dictionary automaton cache poisoned");
90 Arc::clone(compiled.entry(key).or_insert_with(|| {
91 Arc::new(
92 AhoCorasickBuilder::new()
93 .ascii_case_insensitive(!entry.case_sensitive())
94 .build(entry.terms())
95 .expect("DictionaryEntry validates terms before automaton construction"),
96 )
97 }))
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102struct DictionaryCacheKey {
103 terms_hash: u64,
104 case_sensitive: bool,
105}
106
107impl DictionaryCacheKey {
108 fn from_entry(entry: &DictionaryEntry) -> Self {
109 let mut hasher = DefaultHasher::new();
110 entry.terms().hash(&mut hasher);
111 Self {
112 terms_hash: hasher.finish(),
113 case_sensitive: entry.case_sensitive(),
114 }
115 }
116}
117
118impl Recognizer for DictionaryRecognizer {
119 fn id(&self) -> &str {
120 &self.id
121 }
122
123 fn supported_class(&self) -> &PiiClass {
124 &self.class
125 }
126
127 fn detect(
128 &self,
129 input: &str,
130 ctx: &DetectContext<'_>,
131 ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
132 let Some(entry) = ctx.dictionaries.get(&self.dictionary_name) else {
133 return Ok(Vec::new());
134 };
135 let automaton = self.automaton_for(entry);
136
137 Ok(automaton
138 .find_iter(input)
139 .filter(|m| is_token_boundary_match(input, m.start(), m.end()))
140 .map(|m| {
141 Candidate::new(
142 m.start()..m.end(),
143 self.class.clone(),
144 self.id.clone(),
145 self.score,
146 self.priority,
147 Some(input[m.start()..m.end()].to_string()),
148 self.token_family.clone(),
149 format!(
150 "dictionary:{}[#{}]",
151 self.dictionary_name,
152 m.pattern().as_usize()
153 ),
154 ConflictTier::None,
155 Vec::new(),
156 )
157 })
158 .collect())
159 }
160
161 fn token_family(&self) -> &str {
162 &self.token_family
163 }
164
165 fn locales(&self) -> &[LocaleTag] {
166 &self.locales
167 }
168}
169
170fn is_token_boundary_match(input: &str, start: usize, end: usize) -> bool {
171 !has_identifier_char_before(input, start) && !has_identifier_char_after(input, end)
172}
173
174fn has_identifier_char_before(input: &str, start: usize) -> bool {
175 input[..start]
176 .chars()
177 .next_back()
178 .is_some_and(is_identifier_char)
179}
180
181fn has_identifier_char_after(input: &str, end: usize) -> bool {
182 input[end..].chars().next().is_some_and(is_identifier_char)
183}
184
185fn is_identifier_char(ch: char) -> bool {
186 ch == '_' || ch.is_alphanumeric()
187}
188
189#[cfg(test)]
190mod tests {
191 use std::collections::HashMap;
192
193 use gaze::{
194 dictionary_bundle_from_context, ContextDictionary, RecognizerRegistry, TypedContext,
195 };
196 use serde_json::Map;
197
198 use super::*;
199
200 #[test]
201 fn recognizer_detects_dictionary_hits_from_context_bundle() {
202 let ctx = TypedContext {
203 dictionaries: HashMap::from([(
204 "dict_alpha".to_string(),
205 ContextDictionary {
206 terms: vec!["AAA-12345".to_string()],
207 case_sensitive: true,
208 },
209 )]),
210 class_map: HashMap::new(),
211 fields: Map::new(),
212 };
213 let bundle = dictionary_bundle_from_context(&ctx);
214 let detect_context = DetectContext::new(&[LocaleTag::Global], &bundle);
215 let recognizer = DictionaryRecognizer::new(
216 "dict/dict_alpha",
217 PiiClass::Custom("class_alpha".to_string()),
218 "dict_alpha",
219 true,
220 "counter",
221 );
222
223 let hits = recognizer
224 .detect("Customer bought AAA-12345", &detect_context)
225 .unwrap();
226 assert_eq!(hits.len(), 1);
227 assert_eq!(hits[0].span, 16..25);
228 assert_eq!(hits[0].class, PiiClass::Custom("class_alpha".to_string()));
229 }
230
231 #[test]
232 fn recognizer_locale_gates_dictionary_hits() {
233 let ctx = TypedContext {
234 dictionaries: HashMap::from([(
235 "songs".to_string(),
236 ContextDictionary {
237 terms: vec!["Bohemian Rhapsody".to_string()],
238 case_sensitive: false,
239 },
240 )]),
241 class_map: HashMap::new(),
242 fields: Map::new(),
243 };
244 let bundle = dictionary_bundle_from_context(&ctx);
245 let detect_context = DetectContext::new(&[LocaleTag::EnUs], &bundle);
246 let recognizer = DictionaryRecognizer::with_rulepack_fields(
247 "dict/songs",
248 PiiClass::Custom("song".to_string()),
249 "songs",
250 false,
251 "counter",
252 vec![LocaleTag::DeDe],
253 1.0,
254 0,
255 );
256
257 let registry = RecognizerRegistry::builder().register(recognizer).build();
258 let hits = registry
259 .detect_all("Listening to bohemian rhapsody", &detect_context)
260 .expect("detect all");
261 assert!(hits.is_empty());
262 }
263
264 #[test]
265 fn dictionary_recognizer_emits_per_term_source() {
266 let ctx = TypedContext {
267 dictionaries: HashMap::from([(
268 "songs".to_string(),
269 ContextDictionary {
270 terms: vec![
271 "alpha-one".to_string(),
272 "bravo-two".to_string(),
273 "charlie-three".to_string(),
274 ],
275 case_sensitive: true,
276 },
277 )]),
278 class_map: HashMap::new(),
279 fields: Map::new(),
280 };
281 let bundle = dictionary_bundle_from_context(&ctx);
282 let detect_context = DetectContext::new(&[LocaleTag::Global], &bundle);
283 let recognizer = DictionaryRecognizer::new(
284 "dict/songs",
285 PiiClass::Custom("song".to_string()),
286 "songs",
287 true,
288 "counter",
289 );
290
291 let hits = recognizer
292 .detect(
293 "first alpha-one, second bravo-two, third charlie-three",
294 &detect_context,
295 )
296 .unwrap();
297
298 assert_eq!(hits.len(), 3);
299 let source_shape = regex::Regex::new(r"^dictionary:[a-z_]+\[#\d+\]$").unwrap();
300 for hit in &hits {
301 assert!(
302 source_shape.is_match(&hit.source),
303 "unexpected source shape: {}",
304 hit.source
305 );
306 }
307 assert_eq!(hits[0].source, "dictionary:songs[#0]");
308 assert_eq!(hits[1].source, "dictionary:songs[#1]");
309 assert_eq!(hits[2].source, "dictionary:songs[#2]");
310 }
311
312 #[test]
313 fn dictionary_recognizer_source_index_matches_automaton_for_duplicate_terms() {
314 let ctx = TypedContext {
315 dictionaries: HashMap::from([(
316 "songs".to_string(),
317 ContextDictionary {
318 terms: vec![
319 "same-song".to_string(),
320 "same-song".to_string(),
321 "other-song".to_string(),
322 ],
323 case_sensitive: true,
324 },
325 )]),
326 class_map: HashMap::new(),
327 fields: Map::new(),
328 };
329 let bundle = dictionary_bundle_from_context(&ctx);
330 let detect_context = DetectContext::new(&[LocaleTag::Global], &bundle);
331 let recognizer = DictionaryRecognizer::new(
332 "dict/songs",
333 PiiClass::Custom("song".to_string()),
334 "songs",
335 true,
336 "counter",
337 );
338
339 let hits = recognizer
340 .detect("same-song then other-song", &detect_context)
341 .unwrap();
342
343 assert_eq!(hits.len(), 2);
344 assert_eq!(hits[0].source, "dictionary:songs[#0]");
345 assert_eq!(hits[1].source, "dictionary:songs[#2]");
346 }
347
348 #[test]
349 fn dictionary_recognizer_does_not_match_inside_identifier() {
350 let ctx = TypedContext {
351 dictionaries: HashMap::from([(
352 "artist_refs".to_string(),
353 ContextDictionary {
354 terms: vec!["Artist".to_string()],
355 case_sensitive: true,
356 },
357 )]),
358 class_map: HashMap::new(),
359 fields: Map::new(),
360 };
361 let bundle = dictionary_bundle_from_context(&ctx);
362 let detect_context = DetectContext::new(&[LocaleTag::Global], &bundle);
363 let recognizer = DictionaryRecognizer::new(
364 "dict/artist_refs",
365 PiiClass::Custom("artist_ref".to_string()),
366 "artist_refs",
367 true,
368 "counter",
369 );
370
371 let hits = recognizer
372 .detect("Du antwortest als Artistfy-Support.", &detect_context)
373 .unwrap();
374
375 assert!(hits.is_empty(), "unexpected dictionary hits: {hits:?}");
376 }
377}