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(&self, input: &str, ctx: &DetectContext<'_>) -> Vec<Candidate> {
128 let Some(entry) = ctx.dictionaries.get(&self.dictionary_name) else {
129 return Vec::new();
130 };
131 let automaton = self.automaton_for(entry);
132
133 automaton
134 .find_iter(input)
135 .map(|m| {
136 Candidate::new(
137 m.start()..m.end(),
138 self.class.clone(),
139 self.id.clone(),
140 self.score,
141 self.priority,
142 Some(input[m.start()..m.end()].to_string()),
143 self.token_family.clone(),
144 format!(
145 "dictionary:{}[#{}]",
146 self.dictionary_name,
147 m.pattern().as_usize()
148 ),
149 ConflictTier::None,
150 Vec::new(),
151 )
152 })
153 .collect()
154 }
155
156 fn token_family(&self) -> &str {
157 &self.token_family
158 }
159
160 fn locales(&self) -> &[LocaleTag] {
161 &self.locales
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use std::collections::HashMap;
168
169 use gaze::{
170 dictionary_bundle_from_context, ContextDictionary, RecognizerRegistry, TypedContext,
171 };
172 use serde_json::Map;
173
174 use super::*;
175
176 #[test]
177 fn recognizer_detects_dictionary_hits_from_context_bundle() {
178 let ctx = TypedContext {
179 dictionaries: HashMap::from([(
180 "dict_alpha".to_string(),
181 ContextDictionary {
182 terms: vec!["AAA-12345".to_string()],
183 case_sensitive: true,
184 },
185 )]),
186 class_map: HashMap::new(),
187 fields: Map::new(),
188 };
189 let bundle = dictionary_bundle_from_context(&ctx);
190 let detect_context = DetectContext::new(&[LocaleTag::Global], &bundle);
191 let recognizer = DictionaryRecognizer::new(
192 "dict/dict_alpha",
193 PiiClass::Custom("class_alpha".to_string()),
194 "dict_alpha",
195 true,
196 "counter",
197 );
198
199 let hits = recognizer.detect("Customer bought AAA-12345", &detect_context);
200 assert_eq!(hits.len(), 1);
201 assert_eq!(hits[0].span, 16..25);
202 assert_eq!(hits[0].class, PiiClass::Custom("class_alpha".to_string()));
203 }
204
205 #[test]
206 fn recognizer_locale_gates_dictionary_hits() {
207 let ctx = TypedContext {
208 dictionaries: HashMap::from([(
209 "songs".to_string(),
210 ContextDictionary {
211 terms: vec!["Bohemian Rhapsody".to_string()],
212 case_sensitive: false,
213 },
214 )]),
215 class_map: HashMap::new(),
216 fields: Map::new(),
217 };
218 let bundle = dictionary_bundle_from_context(&ctx);
219 let detect_context = DetectContext::new(&[LocaleTag::EnUs], &bundle);
220 let recognizer = DictionaryRecognizer::with_rulepack_fields(
221 "dict/songs",
222 PiiClass::Custom("song".to_string()),
223 "songs",
224 false,
225 "counter",
226 vec![LocaleTag::DeDe],
227 1.0,
228 0,
229 );
230
231 let registry = RecognizerRegistry::builder().register(recognizer).build();
232 let hits = registry
233 .detect_all("Listening to bohemian rhapsody", &detect_context)
234 .expect("detect all");
235 assert!(hits.is_empty());
236 }
237
238 #[test]
239 fn dictionary_recognizer_emits_per_term_source() {
240 let ctx = TypedContext {
241 dictionaries: HashMap::from([(
242 "songs".to_string(),
243 ContextDictionary {
244 terms: vec![
245 "alpha-one".to_string(),
246 "bravo-two".to_string(),
247 "charlie-three".to_string(),
248 ],
249 case_sensitive: true,
250 },
251 )]),
252 class_map: HashMap::new(),
253 fields: Map::new(),
254 };
255 let bundle = dictionary_bundle_from_context(&ctx);
256 let detect_context = DetectContext::new(&[LocaleTag::Global], &bundle);
257 let recognizer = DictionaryRecognizer::new(
258 "dict/songs",
259 PiiClass::Custom("song".to_string()),
260 "songs",
261 true,
262 "counter",
263 );
264
265 let hits = recognizer.detect(
266 "first alpha-one, second bravo-two, third charlie-three",
267 &detect_context,
268 );
269
270 assert_eq!(hits.len(), 3);
271 let source_shape = regex::Regex::new(r"^dictionary:[a-z_]+\[#\d+\]$").unwrap();
272 for hit in &hits {
273 assert!(
274 source_shape.is_match(&hit.source),
275 "unexpected source shape: {}",
276 hit.source
277 );
278 }
279 assert_eq!(hits[0].source, "dictionary:songs[#0]");
280 assert_eq!(hits[1].source, "dictionary:songs[#1]");
281 assert_eq!(hits[2].source, "dictionary:songs[#2]");
282 }
283
284 #[test]
285 fn dictionary_recognizer_source_index_matches_automaton_for_duplicate_terms() {
286 let ctx = TypedContext {
287 dictionaries: HashMap::from([(
288 "songs".to_string(),
289 ContextDictionary {
290 terms: vec![
291 "same-song".to_string(),
292 "same-song".to_string(),
293 "other-song".to_string(),
294 ],
295 case_sensitive: true,
296 },
297 )]),
298 class_map: HashMap::new(),
299 fields: Map::new(),
300 };
301 let bundle = dictionary_bundle_from_context(&ctx);
302 let detect_context = DetectContext::new(&[LocaleTag::Global], &bundle);
303 let recognizer = DictionaryRecognizer::new(
304 "dict/songs",
305 PiiClass::Custom("song".to_string()),
306 "songs",
307 true,
308 "counter",
309 );
310
311 let hits = recognizer.detect("same-song then other-song", &detect_context);
312
313 assert_eq!(hits.len(), 2);
314 assert_eq!(hits[0].source, "dictionary:songs[#0]");
315 assert_eq!(hits[1].source, "dictionary:songs[#2]");
316 }
317}