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(&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}