Skip to main content

sensitive_rs/variant/
mod.rs

1//! Variant (evasion) detection.
2//!
3//! [`VariantDetector`] catches sensitive words that have been obfuscated. Two channels run
4//! in parallel and the results are merged and de-duplicated:
5//!
6//! - **Pinyin**: the text and each dictionary word are converted to tone-less pinyin; a word
7//!   matches if its pinyin appears as a substring (e.g. `dubo` → `赌博`).
8//! - **Shape** (形似字): characters are compared against a shape-confusable map loaded from
9//!   `dict/shape_map.txt`, where each entry is a full equivalence class (e.g. `睹` ↔ `赌`).
10
11use pinyin::Pinyin;
12use std::collections::HashMap;
13
14/// Variation detector
15pub struct VariantDetector {
16    pinyin_map: HashMap<String, Vec<String>>, // The mapping of pinyin to original word
17    shape_map: HashMap<char, Vec<char>>,      // SHAPED CLOSE CHARACTER MAPPING
18    char_to_pinyin: HashMap<char, String>,    // Character to pinyin mapping
19}
20
21impl std::fmt::Debug for VariantDetector {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.debug_struct("VariantDetector")
24            .field("pinyin_map_size", &self.pinyin_map.len())
25            .field("shape_map_size", &self.shape_map.len())
26            .field("char_to_pinyin_size", &self.char_to_pinyin.len())
27            .finish()
28    }
29}
30
31impl Default for VariantDetector {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl VariantDetector {
38    /// Create a new detector
39    ///
40    /// The detector starts empty; register words with [`VariantDetector::add_word`] before
41    /// calling [`VariantDetector::detect`].
42    ///
43    /// # Examples
44    ///
45    /// ```
46    /// use sensitive_rs::VariantDetector;
47    ///
48    /// let mut vd = VariantDetector::new();
49    /// vd.add_word("赌博");
50    /// assert_eq!(vd.detect("dubo", &["赌博"]), vec!["赌博"]); // pinyin variant
51    /// ```
52    pub fn new() -> Self {
53        VariantDetector {
54            pinyin_map: HashMap::new(),
55            shape_map: Self::build_shape_map(),
56            char_to_pinyin: HashMap::new(),
57        }
58    }
59
60    /// Construct pinyin index when adding sensitive words
61    pub fn add_word(&mut self, word: &str) {
62        let chars_result = Pinyin::chars(word).with_tone_style(pinyin::ToneStyle::None);
63        let han_chars: Vec<char> = word.chars().filter(|c| !c.is_ascii()).collect();
64        let pinyin_lookup: HashMap<char, String> = han_chars.into_iter().zip(chars_result.iter()).collect();
65
66        let pinyins: Vec<String> = word
67            .chars()
68            .filter_map(|c| {
69                pinyin_lookup.get(&c).map(|pinyin| {
70                    self.char_to_pinyin.insert(c, pinyin.clone());
71                    pinyin.clone()
72                })
73            })
74            .collect();
75
76        if !pinyins.is_empty() {
77            let pinyin_key = pinyins.join("");
78            self.pinyin_map.entry(pinyin_key).or_default().push(word.to_string());
79        }
80    }
81
82    /// Detect variants in text
83    ///
84    /// Returns the subset of `original_words` whose pinyin or shape variant appears in `text`.
85    /// The returned slices borrow from `original_words`.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use sensitive_rs::VariantDetector;
91    ///
92    /// let mut vd = VariantDetector::new();
93    /// vd.add_word("赌博");
94    /// // Shape variant: 睹 is shape-confusable with 赌.
95    /// assert_eq!(vd.detect("睹博", &["赌博"]), vec!["赌博"]);
96    /// ```
97    pub fn detect<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
98        let mut variants = Vec::new();
99
100        // 1. Detect pinyin variants
101        variants.extend(self.detect_pinyin_variants(text, original_words));
102
103        // 2. Detect shape-near-word variant
104        variants.extend(self.detect_shape_variants(text, original_words));
105
106        variants.sort_unstable();
107        variants.dedup();
108        variants
109    }
110
111    /// Detect pinyin variants
112    fn detect_pinyin_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
113        let text_pinyin = self.text_to_pinyin(text);
114
115        original_words
116            .iter()
117            .filter(|&&word| {
118                // Construct the pinyin of the word
119                let word_pinyin: String = word
120                    .chars()
121                    .map(|c| {
122                        self.char_to_pinyin.get(&c).cloned().unwrap_or_else(|| c.to_string())
123                        // Safe processing: Return original characters
124                    })
125                    .collect();
126
127                text_pinyin.contains(&word_pinyin)
128            })
129            .copied()
130            .collect()
131    }
132
133    /// Convert text to pinyin
134    fn text_to_pinyin(&self, text: &str) -> String {
135        // Build pinyin for uncached characters in batch
136        let uncached: Vec<char> =
137            text.chars().filter(|c| !c.is_ascii() && !self.char_to_pinyin.contains_key(c)).collect();
138        let extra: HashMap<char, String> = if uncached.is_empty() {
139            HashMap::new()
140        } else {
141            let uncached_str: String = uncached.iter().collect();
142            uncached
143                .into_iter()
144                .zip(Pinyin::chars(&uncached_str).with_tone_style(pinyin::ToneStyle::None).iter())
145                .collect()
146        };
147
148        text.chars()
149            .map(|c| {
150                self.char_to_pinyin.get(&c).cloned().or_else(|| extra.get(&c).cloned()).unwrap_or_else(|| c.to_string())
151            })
152            .collect()
153    }
154
155    /// Detect shape-near-word variant
156    fn detect_shape_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
157        original_words.iter().filter(|&&word| self.is_shape_variant(text, word)).copied().collect()
158    }
159
160    /// Determine whether it is a variant of the shape and character
161    fn is_shape_variant(&self, text: &str, word: &str) -> bool {
162        let text_chars: Vec<char> = text.chars().collect();
163        let word_chars: Vec<char> = word.chars().collect();
164
165        if text_chars.len() != word_chars.len() {
166            return false;
167        }
168
169        text_chars
170            .iter()
171            .zip(word_chars.iter())
172            .all(|(&tc, &wc)| tc == wc || self.shape_map.get(&wc).is_some_and(|variants| variants.contains(&tc)))
173    }
174
175    /// Constructing a shape-size-word mapping table
176    ///
177    /// Loaded from the embedded `dict/shape_map.txt`. Each non-comment line is one
178    /// shape-confusable equivalence class (`key:v1,v2,...`); every character in the
179    /// line is treated as confusable with every other (bidirectional), so evasions
180    /// are caught in both directions. Overlapping lines union naturally.
181    fn build_shape_map() -> HashMap<char, Vec<char>> {
182        let mut map: HashMap<char, Vec<char>> = HashMap::new();
183
184        for line in include_str!("../../dict/shape_map.txt").lines() {
185            let line = line.trim();
186            if line.is_empty() || line.starts_with('#') {
187                continue;
188            }
189
190            // Format: `key:v1,v2,...` — only the first char of key/value is used.
191            let Some((key_part, vals_part)) = line.split_once(':') else { continue };
192            let Some(key) = key_part.trim().chars().next() else { continue };
193            let group: Vec<char> =
194                std::iter::once(key).chain(vals_part.split(',').filter_map(|s| s.trim().chars().next())).collect();
195            if group.len() < 2 {
196                continue;
197            }
198
199            // Full equivalence class: every char maps to all the others.
200            for &c in &group {
201                let others: Vec<char> = group.iter().filter(|&&x| x != c).copied().collect();
202                map.entry(c).or_default().extend(others);
203            }
204        }
205
206        // Lines may overlap on shared chars; dedup each value list.
207        for vals in map.values_mut() {
208            vals.sort_unstable();
209            vals.dedup();
210        }
211        map
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_pinyin_detection() {
221        let mut vd = VariantDetector::new();
222        vd.add_word("赌博");
223        let results = vd.detect("dubo", &["赌博"]);
224        assert_eq!(results, vec!["赌博"]);
225    }
226
227    #[test]
228    fn test_pinyin_no_match() {
229        let mut vd = VariantDetector::new();
230        vd.add_word("赌博");
231        let results = vd.detect("hello", &["赌博"]);
232        assert!(results.is_empty());
233    }
234
235    #[test]
236    fn test_shape_variant_detection() {
237        let mut vd = VariantDetector::new();
238        vd.add_word("赌博");
239        // "睹" is a shape variant of "赌"
240        let results = vd.detect("睹博", &["赌博"]);
241        assert_eq!(results, vec!["赌博"]);
242    }
243
244    #[test]
245    fn test_shape_no_match_different_length() {
246        let mut vd = VariantDetector::new();
247        vd.add_word("赌博");
248        let results = vd.detect("赌", &["赌博"]);
249        assert!(results.is_empty()); // different length
250    }
251
252    #[test]
253    fn test_shape_map_loaded_from_file() {
254        // The shape map is loaded from dict/shape_map.txt and must cover 50+ groups.
255        let vd = VariantDetector::new();
256        assert!(vd.shape_map.len() >= 50, "shape_map has {} entries, expected >= 50", vd.shape_map.len());
257        // The original hard-coded entries are preserved.
258        assert!(vd.shape_map.get(&'赌').is_some_and(|v| v.contains(&'睹')));
259        assert!(vd.shape_map.get(&'博').is_some_and(|v| v.contains(&'膊')));
260    }
261
262    #[test]
263    fn test_shape_variant_bidirectional() {
264        // Full equivalence-class symmetry: a word built from a "variant" char is
265        // detected when the text uses the canonical char, and vice versa.
266        let mut vd = VariantDetector::new();
267        vd.add_word("睹博"); // text form of 赌博
268        assert_eq!(vd.detect("赌博", &["睹博"]), vec!["睹博"]);
269    }
270
271    #[test]
272    fn test_shape_group_full_symmetry() {
273        // 人:入,八 is one equivalence class — every char matches every other.
274        let mut vd = VariantDetector::new();
275        vd.add_word("人");
276        assert_eq!(vd.detect("入", &["人"]), vec!["人"]);
277        assert_eq!(vd.detect("八", &["人"]), vec!["人"]);
278    }
279
280    #[test]
281    fn test_empty_input() {
282        let mut vd = VariantDetector::new();
283        vd.add_word("赌博");
284        let results = vd.detect("", &["赌博"]);
285        assert!(results.is_empty());
286    }
287
288    #[test]
289    fn test_all_ascii_input() {
290        let mut vd = VariantDetector::new();
291        vd.add_word("赌博");
292        let results = vd.detect("hello world", &["赌博"]);
293        assert!(results.is_empty());
294    }
295
296    #[test]
297    fn test_mixed_script() {
298        let mut vd = VariantDetector::new();
299        vd.add_word("测试");
300        let results = vd.detect("这是 test 内容", &["测试"]);
301        // "test" pinyin doesn't match "测试"
302        assert!(results.is_empty());
303    }
304
305    #[test]
306    fn test_multiple_words() {
307        let mut vd = VariantDetector::new();
308        vd.add_word("赌博");
309        vd.add_word("色情");
310        let results = vd.detect("dubo and seqing", &["赌博", "色情"]);
311        assert_eq!(results.len(), 2);
312    }
313
314    #[test]
315    fn test_detect_dedup() {
316        // A single original word can be matched by both the pinyin and shape
317        // paths simultaneously; `detect` must sort+dedup so it appears once.
318        let mut vd = VariantDetector::new();
319        vd.add_word("赌博");
320        let results = vd.detect("睹博", &["赌博"]);
321        assert_eq!(results.len(), 1);
322        assert_eq!(results, vec!["赌博"]);
323    }
324
325    #[test]
326    fn test_detect_returns_borrowed_slices() {
327        // detect returns references into the caller's `original_words` slice,
328        // not freshly allocated Strings.
329        let mut vd = VariantDetector::new();
330        vd.add_word("赌博");
331        let words = ["赌博"];
332        let results = vd.detect("dubo", &words);
333        assert!(results.iter().all(|r| words.contains(r)));
334    }
335}