Skip to main content

gaze_recognizers/
dictionary.rs

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
11/// Lookup-based [`Recognizer`] for tenant-specific PII.
12///
13/// Matches exact strings from a runtime-supplied dictionary: order IDs, song
14/// titles, customer codes, or any domain-specific identifiers that regex cannot
15/// reliably detect.
16///
17/// Supply dictionaries at runtime via [`DetectContext`] or the CLI's
18/// `--context-json` wiring. Dictionary entries are session-scoped detection
19/// inputs and are not stored in the audit log.
20pub 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}