Skip to main content

sensitive_rs/
filter.rs

1use crate::engine::MatchAlgorithm;
2use crate::{engine::MultiPatternEngine, variant::VariantDetector};
3use lru::LruCache;
4use rayon::prelude::*;
5use regex::Regex;
6use std::num::NonZero;
7use std::sync::{Arc, Mutex};
8use std::{
9    fs::File,
10    io::{self, BufRead, BufReader},
11    path::Path,
12};
13
14/// Advanced sensitive word filter with variant detection
15pub struct Filter {
16    engine: MultiPatternEngine,        // Multi-pattern matching engine
17    variant_detector: VariantDetector, // Variation detector
18    noise: Regex,                      // Noise processing rules
19    cache: Arc<Mutex<LruCache<String, Vec<String>>>>,
20    #[cfg(feature = "net")]
21    http_client: reqwest::blocking::Client, // Network request client
22}
23
24impl std::fmt::Debug for Filter {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("Filter")
27            .field("engine", &self.engine)
28            .field("variant_detector", &self.variant_detector)
29            .field("noise", &self.noise)
30            .field("cache", &"<LruCache>")
31            .finish()
32    }
33}
34
35impl Filter {
36    /// Create a new filter with default settings
37    pub fn new() -> Self {
38        Self {
39            engine: MultiPatternEngine::new(None, &[]),
40            variant_detector: VariantDetector::new(),
41            noise: Regex::new(r"[^\w\s\u4e00-\u9fff]").unwrap(),
42            cache: Arc::new(Mutex::new(LruCache::new(NonZero::new(1000).unwrap()))), // Cache 1000 results
43            #[cfg(feature = "net")]
44            http_client: reqwest::blocking::Client::builder()
45                .timeout(std::time::Duration::from_secs(5))
46                .build()
47                .unwrap(),
48        }
49    }
50
51    fn check_cache(&self, text: &str) -> Option<Vec<String>> {
52        self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(text).cloned()
53    }
54
55    fn cache_result(&self, text: &str, results: &[String]) {
56        self.cache.lock().unwrap_or_else(|e| e.into_inner()).put(text.to_string(), results.to_vec());
57    }
58
59    /// Clear the cache
60    pub fn clear_cache(&self) {
61        self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
62    }
63
64    /// Create with specific algorithm
65    pub fn with_algorithm(algorithm: MatchAlgorithm) -> Self {
66        Self { engine: MultiPatternEngine::new(Some(algorithm), &[]), ..Self::new() }
67    }
68
69    /// Load default dictionary
70    pub fn with_default_dict() -> io::Result<Self> {
71        let mut filter = Self::new();
72        filter.load_word_dict("dict/dict.txt")?;
73        Ok(filter)
74    }
75
76    /// Update noise pattern
77    pub fn update_noise_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
78        self.noise = Regex::new(pattern)?;
79        Ok(())
80    }
81
82    /// Add a sensitive word
83    pub fn add_word(&mut self, word: &str) {
84        let patterns = {
85            let mut p = self.engine.get_patterns().to_vec();
86            p.push(word.to_string());
87            p
88        };
89        self.engine.rebuild(&patterns);
90        self.variant_detector.add_word(word);
91        self.clear_cache();
92    }
93
94    /// Add multiple words
95    pub fn add_words(&mut self, words: &[&str]) {
96        let mut patterns = self.engine.get_patterns().to_vec();
97        patterns.extend(words.iter().map(|s| s.to_string()));
98
99        self.engine.rebuild(&patterns);
100        for word in words {
101            self.variant_detector.add_word(word);
102        }
103        self.clear_cache();
104    }
105
106    /// Get the currently used algorithm
107    pub fn current_algorithm(&self) -> MatchAlgorithm {
108        self.engine.current_algorithm()
109    }
110
111    /// Remove a word
112    pub fn del_word(&mut self, word: &str) {
113        let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|&w| w != word).cloned().collect();
114
115        self.engine.rebuild(&patterns);
116        self.clear_cache();
117    }
118
119    /// Remove multiple words
120    pub fn del_words(&mut self, words: &[&str]) {
121        let word_set: std::collections::HashSet<_> = words.iter().collect();
122        let patterns: Vec<_> =
123            self.engine.get_patterns().iter().filter(|w| !word_set.contains(&w.as_str())).cloned().collect();
124
125        self.engine.rebuild(&patterns);
126        self.clear_cache();
127    }
128
129    /// Load dictionary from file
130    pub fn load_word_dict<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
131        let file = File::open(path)?;
132        self.load(BufReader::new(file))
133    }
134
135    /// Load dictionary from reader
136    pub fn load<R: BufRead>(&mut self, reader: R) -> io::Result<()> {
137        let words: Vec<_> = reader.lines().collect::<Result<_, _>>()?;
138        self.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
139        Ok(())
140    }
141
142    /// Load dictionary from URL
143    #[cfg(feature = "net")]
144    pub fn load_net_word_dict(&mut self, url: &str) -> io::Result<()> {
145        let response = self.http_client.get(url).send().map_err(io::Error::other)?;
146
147        if !response.status().is_success() {
148            return Err(io::Error::other(format!("HTTP request failed: {}", response.status())));
149        }
150
151        let reader = BufReader::new(response);
152        self.load(reader)
153    }
154
155    /// Find first sensitive word
156    pub fn find_in(&self, text: &str) -> (bool, String) {
157        let clean_text = self.remove_noise(text);
158
159        // 1. Try exact match first
160        if let Some(word) = self.engine.find_first(&clean_text) {
161            return (true, word);
162        }
163
164        // 2. Try variant detection
165        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
166
167        if let Some(word) = self.variant_detector.detect(&clean_text, &patterns).first() {
168            return (true, word.to_string());
169        }
170
171        (false, String::new())
172    }
173
174    /// Replace sensitive words with replacement character
175    pub fn replace(&self, text: &str, replacement: char) -> String {
176        let clean_text = self.remove_noise(text);
177
178        // Get all sensitive words (including variants) that need to be processed
179        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
180        let variants = self.variant_detector.detect(&clean_text, &patterns);
181
182        let mut result = clean_text;
183
184        // Replace sensitive words detected by the engine
185        for pattern in self.engine.get_patterns() {
186            let repl_str = replacement.to_string().repeat(pattern.chars().count());
187            result = result.replace(pattern, &repl_str);
188        }
189
190        // Replace the sensitive words detected by the variant
191        for variant in variants {
192            let repl_str = replacement.to_string().repeat(variant.chars().count());
193            result = result.replace(variant, &repl_str);
194        }
195
196        result
197    }
198
199    /// Filter out sensitive words (remove them completely)
200    pub fn filter(&self, text: &str) -> String {
201        let clean_text = self.remove_noise(text);
202
203        // Use engine's optimized replace_all for pattern removal
204        let mut result = self.engine.replace_all(&clean_text, "");
205
206        // Remove sensitive words detected by variants
207        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
208        let variants = self.variant_detector.detect(&result, &patterns);
209
210        for variant in variants {
211            result = result.replace(variant, "");
212        }
213
214        result
215    }
216
217    /// Validate text
218    pub fn validate(&self, text: &str) -> (bool, String) {
219        self.find_in(text)
220    }
221
222    /// Remove only specific noise characters, preserve spaces
223    pub fn remove_noise(&self, text: &str) -> String {
224        self.noise.replace_all(text, "").to_string()
225    }
226
227    /// Get current noise pattern
228    pub fn get_noise_pattern(&self) -> &Regex {
229        &self.noise
230    }
231}
232
233impl Filter {
234    /// Optimized method of finding all sensitive words
235    pub fn find_all(&self, text: &str) -> Vec<String> {
236        let clean_text = self.remove_noise(text);
237
238        // 1. Caching mechanism - Check whether the results have been cached
239        if let Some(cached_result) = self.check_cache(&clean_text) {
240            return cached_result;
241        }
242
243        let results = if clean_text.len() > 1000 {
244            // Long text is processed in parallel
245            self.find_all_parallel(&clean_text)
246        } else {
247            // General processing of short text
248            self.find_all_sequential(&clean_text)
249        };
250
251        // 3. Cache results
252        self.cache_result(&clean_text, &results);
253
254        results
255    }
256
257    /// Parallel Processing Version - For Long Text
258    fn find_all_parallel(&self, text: &str) -> Vec<String> {
259        let chunk_size = std::cmp::max(text.len() / rayon::current_num_threads(), 100);
260        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
261
262        // Compute overlap to catch patterns spanning chunk boundaries
263        let max_pattern_len = patterns.iter().map(|p| p.chars().count()).max().unwrap_or(0);
264        let overlap = max_pattern_len.min(chunk_size);
265
266        // Build overlapping chunks for parallel processing
267        let chars: Vec<char> = text.chars().collect();
268        let engine_results: Vec<String> = if chars.len() <= chunk_size {
269            self.engine.find_all(text)
270        } else {
271            let step = chunk_size;
272            chars
273                .windows(chunk_size + overlap)
274                .step_by(step)
275                .collect::<Vec<_>>()
276                .par_iter()
277                .flat_map(|window| {
278                    let chunk_text: String = window.iter().collect();
279                    self.engine.find_all(&chunk_text)
280                })
281                .collect()
282        };
283
284        // Parallel variant detection - Fixed parallel iterator problem
285        let variant_results: Vec<String> = text
286            .split_whitespace()
287            .collect::<Vec<_>>()
288            .par_iter()
289            .map(|segment| self.variant_detector.detect(segment, &patterns))
290            .flatten()
291            .map(|s| s.to_string())
292            .collect();
293
294        // Merge and remove repetition
295        let mut results = engine_results;
296        results.extend(variant_results);
297        self.deduplicate_and_sort(results)
298    }
299
300    /// Sequential processing version - suitable for short text
301    fn find_all_sequential(&self, text: &str) -> Vec<String> {
302        let mut results = self.engine.find_all(text);
303        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
304
305        // Add variant detection results
306        results.extend(self.variant_detector.detect(text, &patterns).into_iter().map(|s| s.to_string()));
307
308        self.deduplicate_and_sort(results)
309    }
310
311    /// Deduplication and sort
312    fn deduplicate_and_sort(&self, mut results: Vec<String>) -> Vec<String> {
313        results.sort_unstable();
314        results.dedup();
315        results
316    }
317
318    /// Bulk search for optimized versions
319    pub fn find_all_batch(&self, texts: &[&str]) -> Vec<Vec<String>> {
320        texts.par_iter().map(|text| self.find_all(text)).collect()
321    }
322
323    /// Hierarchical Matching - Preferential Matching by Sensitive Word Length
324    pub fn find_all_layered(&self, text: &str) -> Vec<String> {
325        let clean_text = self.remove_noise(text);
326        let mut results = Vec::new();
327        let mut remaining_text = clean_text.clone();
328
329        // Arrange patterns in descending order of length, prioritize long words
330        let mut sorted_patterns = self.engine.get_patterns().to_vec();
331        sorted_patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
332
333        // Hierarchical matching
334        for pattern in &sorted_patterns {
335            if remaining_text.contains(pattern) {
336                results.push(pattern.clone());
337                // Remove matching parts to avoid duplicate matches
338                remaining_text = remaining_text.replace(pattern, " ");
339            }
340        }
341
342        // Variation detection (for remaining text)
343        let patterns: Vec<_> = sorted_patterns.iter().map(|s| s.as_str()).collect();
344        results.extend(self.variant_detector.detect(&remaining_text, &patterns).into_iter().map(|s| s.to_string()));
345
346        self.deduplicate_and_sort(results)
347    }
348
349    /// Streaming version - suitable for oversized text
350    pub fn find_all_streaming<R: BufRead>(&self, reader: R) -> io::Result<Vec<String>> {
351        let mut all_results = Vec::new();
352
353        for line in reader.lines() {
354            let line = line?;
355            let results = self.find_all(&line);
356            all_results.extend(results);
357        }
358
359        Ok(self.deduplicate_and_sort(all_results))
360    }
361}
362
363impl Default for Filter {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use std::io::Cursor;
373    #[test]
374    fn test_integration() {
375        let mut filter = Filter::new();
376        filter.add_words(&["赌博", "色情"]);
377
378        // Exact match
379        assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
380
381        // Pinyin variant
382        assert_eq!(filter.find_in("含有 dubo"), (true, "赌博".to_string()));
383
384        // Replacement
385        assert_eq!(filter.replace("赌博 色情", '*'), "** **");
386
387        // Filter
388        assert_eq!(filter.filter("赌博内容"), "内容");
389    }
390
391    #[test]
392    fn test_noise_handling() {
393        let mut filter = Filter::new();
394        filter.add_word("赌博");
395
396        // 测试空格保留
397        assert_eq!(filter.remove_noise("赌 博"), "赌 博");
398
399        // 测试特殊符号移除
400        assert_eq!(filter.remove_noise("赌@#$博"), "赌博");
401    }
402
403    #[test]
404    fn test_replace_vs_filter() {
405        let mut filter = Filter::new();
406        filter.add_words(&["赌博", "色情"]);
407
408        let text = "这里有赌博和色情内容";
409
410        // replace should be replaced with characters
411        assert_eq!(filter.replace(text, '*'), "这里有**和**内容");
412
413        // filter should be completely removed
414        assert_eq!(filter.filter(text), "这里有和内容");
415    }
416
417    #[test]
418    fn test_variant_detection() {
419        let mut filter = Filter::new();
420        filter.add_word("测试");
421
422        assert_eq!(filter.find_in("ceshi"), (true, "测试".to_string()));
423    }
424
425    #[test]
426    fn test_algorithm_switch_one() {
427        // Use Wu-Manber in small quantities
428        let mut small = Filter::new();
429        small.add_words(&["a", "b", "c"]);
430        assert!(matches!(small.engine.current_algorithm(), MatchAlgorithm::WuManber));
431
432        // Aho-Corasick for medium quantity
433        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
434        let mut medium = Filter::new();
435        medium.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
436        println!("Medium current_algorithm: {:?}", medium.engine.current_algorithm());
437        assert!(matches!(medium.engine.current_algorithm(), MatchAlgorithm::AhoCorasick));
438    }
439
440    #[test]
441    fn test_io_operations() -> io::Result<()> {
442        let mut filter = Filter::new();
443        let cursor = Cursor::new("word1\nword2\nword3");
444        filter.load(cursor)?;
445
446        assert_eq!(filter.find_in("word2"), (true, "word2".to_string()));
447        Ok(())
448    }
449
450    #[test]
451    fn test_algorithm_recommendation() {
452        assert_eq!(MultiPatternEngine::recommend_algorithm(50), MatchAlgorithm::WuManber);
453        assert_eq!(MultiPatternEngine::recommend_algorithm(150), MatchAlgorithm::AhoCorasick);
454        assert_eq!(MultiPatternEngine::recommend_algorithm(15000), MatchAlgorithm::Regex);
455    }
456
457    #[test]
458    fn test_algorithm_switch() {
459        // Use Wu-Manber in small quantities
460        let mut small = Filter::new();
461        small.add_words(&["a", "b", "c"]);
462        println!("Small (3 words): {:?}", small.current_algorithm());
463        assert!(matches!(small.current_algorithm(), MatchAlgorithm::WuManber));
464
465        // Aho-Corasick for medium quantity
466        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
467        let word_refs: Vec<&str> = words.iter().map(|s| s.as_str()).collect();
468
469        let mut medium = Filter::new();
470        medium.add_words(&word_refs);
471
472        println!("Medium (150 words): {:?}", medium.current_algorithm());
473        println!("Pattern count: {}", medium.engine.get_patterns().len());
474
475        // Verification algorithm selection logic
476        let recommended = MultiPatternEngine::recommend_algorithm(150);
477        println!("Recommended algorithm for 150 words: {recommended:?}");
478
479        assert!(matches!(medium.current_algorithm(), MatchAlgorithm::AhoCorasick));
480    }
481
482    #[test]
483    fn test_cache_invalidation_on_add_word() {
484        let mut filter = Filter::new();
485        filter.add_word("赌博");
486
487        // First search populates cache
488        let results1 = filter.find_all("这里有赌博");
489        assert!(results1.contains(&"赌博".to_string()));
490
491        // Add a new word
492        filter.add_word("色情");
493
494        // Cache should be invalidated — new word must appear
495        let results2 = filter.find_all("这里有赌博和色情");
496        assert!(results2.contains(&"赌博".to_string()));
497        assert!(results2.contains(&"色情".to_string()));
498    }
499
500    #[test]
501    fn test_cache_invalidation_on_del_word() {
502        let mut filter = Filter::new();
503        filter.add_words(&["赌博", "色情"]);
504
505        // First search populates cache
506        let results1 = filter.find_all("这里有赌博和色情");
507        assert!(results1.contains(&"赌博".to_string()));
508        assert!(results1.contains(&"色情".to_string()));
509
510        // Delete a word
511        filter.del_word("赌博");
512
513        // Cache should be invalidated — deleted word must not appear
514        let results2 = filter.find_all("这里有赌博和色情");
515        assert!(!results2.contains(&"赌博".to_string()));
516        assert!(results2.contains(&"色情".to_string()));
517    }
518
519    #[test]
520    fn test_mutex_poison_recovery() {
521        use std::sync::Arc;
522
523        let filter = Arc::new(Filter::new());
524        let filter_clone = Arc::clone(&filter);
525
526        // Poison the mutex by panicking while holding the lock
527        let handle = std::thread::spawn(move || {
528            let _guard = filter_clone.cache.lock().unwrap();
529            panic!("intentional panic to poison mutex");
530        });
531        let _ = handle.join();
532
533        // Filter should still work — recovers from poisoned mutex
534        let results = filter.find_all("test");
535        assert!(results.is_empty());
536    }
537
538    #[test]
539    fn test_parallel_search_cross_boundary() {
540        let mut filter = Filter::new();
541        filter.add_word("赌博");
542
543        // Build text > 1000 bytes so find_all uses parallel path
544        // Place "赌博" at a position that could land on a chunk boundary
545        let prefix: String = "安全文字".repeat(200); // 800 bytes (4 chars × 3 bytes × 200)
546        let text = format!("{prefix}这里有赌博内容");
547
548        let results = filter.find_all(&text);
549        assert!(results.contains(&"赌博".to_string()));
550    }
551
552    #[test]
553    fn test_parallel_search_no_duplicates() {
554        let mut filter = Filter::new();
555        filter.add_word("赌博");
556
557        // Build text > 1000 bytes with the word in the middle
558        let prefix: String = "安全".repeat(300); // 1800 bytes
559        let text = format!("{prefix}赌博{prefix}");
560
561        let results = filter.find_all(&text);
562        let count = results.iter().filter(|w| *w == "赌博").count();
563        assert_eq!(count, 1, "expected exactly 1 match, got {count}");
564    }
565
566    // ---- Task 2: advanced methods (batch / layered / streaming) ----
567
568    #[test]
569    fn test_find_all_batch() {
570        let mut filter = Filter::new();
571        filter.add_words(&["赌博", "色情"]);
572
573        let texts = vec!["含有赌博", "含有色情", "正常内容"];
574        let results = filter.find_all_batch(&texts);
575
576        assert_eq!(results.len(), 3);
577        assert!(results[0].contains(&"赌博".to_string()));
578        assert!(results[1].contains(&"色情".to_string()));
579        assert!(results[2].is_empty());
580    }
581
582    #[test]
583    fn test_find_all_batch_empty() {
584        let filter = Filter::new();
585        let results = filter.find_all_batch(&[]);
586        assert!(results.is_empty());
587    }
588
589    #[test]
590    fn test_find_all_layered_prefers_longest() {
591        let mut filter = Filter::new();
592        filter.add_words(&["赌", "赌博", "赌博机"]);
593
594        // Longest match consumes the span; shorter overlapping words are dropped.
595        let results = filter.find_all_layered("这里有赌博机");
596        assert!(results.contains(&"赌博机".to_string()));
597        assert!(!results.contains(&"赌".to_string()));
598        assert!(!results.contains(&"赌博".to_string()));
599    }
600
601    #[test]
602    fn test_find_all_streaming_multiline() {
603        let mut filter = Filter::new();
604        filter.add_words(&["赌博", "色情"]);
605
606        let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
607        let cursor = std::io::Cursor::new(input);
608        let results = filter.find_all_streaming(cursor).unwrap();
609
610        assert!(results.contains(&"赌博".to_string()));
611        assert!(results.contains(&"色情".to_string()));
612        assert_eq!(results.len(), 2);
613    }
614
615    // ---- Task 3: LRU cache behavior ----
616
617    #[test]
618    fn test_cache_hit_returns_consistent_results() {
619        let mut filter = Filter::new();
620        filter.add_words(&["赌博", "色情"]);
621
622        // First call — cache miss; second call — cache hit.
623        let r1 = filter.find_all("含有赌博和色情内容");
624        let r2 = filter.find_all("含有赌博和色情内容");
625        assert_eq!(r1, r2);
626    }
627
628    #[test]
629    fn test_cache_clear() {
630        let mut filter = Filter::new();
631        filter.add_word("赌博");
632
633        filter.find_all("含有赌博"); // populate cache
634        filter.clear_cache();
635
636        // After clear, the result is recomputed (still correct).
637        let results = filter.find_all("含有赌博");
638        assert!(results.contains(&"赌博".to_string()));
639    }
640
641    // ---- Task 4: edge cases ----
642
643    #[test]
644    fn test_empty_text() {
645        let mut filter = Filter::new();
646        filter.add_word("赌博");
647
648        assert_eq!(filter.find_in(""), (false, String::new()));
649        assert!(filter.find_all("").is_empty());
650        assert_eq!(filter.replace("", '*'), "");
651        assert_eq!(filter.filter(""), "");
652    }
653
654    #[test]
655    fn test_empty_dictionary() {
656        let filter = Filter::new();
657
658        assert_eq!(filter.find_in("任何文本"), (false, String::new()));
659        assert!(filter.find_all("任何文本").is_empty());
660        assert_eq!(filter.replace("任何文本", '*'), "任何文本");
661        assert_eq!(filter.filter("任何文本"), "任何文本");
662    }
663
664    #[test]
665    fn test_unicode_emoji_does_not_interfere() {
666        let mut filter = Filter::new();
667        filter.add_word("赌博");
668
669        // Emoji are stripped by the noise regex; surrounding CJK still matches.
670        let (found, word) = filter.find_in("🎉 赌博 🎰");
671        assert!(found);
672        assert_eq!(word, "赌博");
673    }
674
675    #[test]
676    fn test_very_long_text() {
677        let mut filter = Filter::new();
678        filter.add_word("赌博");
679
680        // 100_000 chars = 300_000 bytes, exercises the >1000-byte parallel path.
681        let long_text = "正常".repeat(100_000) + "赌博";
682        let results = filter.find_all(&long_text);
683        assert!(results.contains(&"赌博".to_string()));
684    }
685
686    #[test]
687    fn test_cjk_extension_b_chars() {
688        let mut filter = Filter::new();
689        filter.add_word("赌博");
690
691        // CJK Extension B (outside BMP); preceding CJK still matches.
692        let text = "含有赌博内容 𠀀𠀁";
693        let (found, _) = filter.find_in(text);
694        assert!(found);
695    }
696}