Skip to main content

sz_orm_masking/
lib.rs

1//! # SZ-ORM Masking — Data Masking
2//!
3//! Provides masking for sensitive fields such as phone number, email, ID card, bank card, name, address, and supports custom
4//! prefix/suffix retention rules. Unicode safe, with reasonable fallback for short input, does not panic.
5//!
6//! ## Main Types
7//!
8//! - [`MaskingRule`] — Masking rule enum
9//! - [`DataMasker`] — Masking executor
10
11use serde::{Deserialize, Serialize};
12
13pub mod audit;
14pub mod config;
15pub mod maskers;
16pub mod strategy;
17
18#[cfg(feature = "dynamic-masking")]
19pub mod dynamic_masking;
20#[cfg(feature = "dynamic-masking")]
21pub use dynamic_masking::{
22    apply_strategy, EncryptAlgorithm, EncryptConfig, HashConfig, MaskConfig,
23    MaskingHashAlgorithm, MaskingPolicyConflict, MaskingRuleConfig, MaskingRuleSet, MaskingStrategy,
24};
25
26// 重导出新模块的主要类型
27pub use audit::{
28    rule_name, FieldReport, MaskingAuditEntry as DetailedAuditEntry,
29    MaskingAuditLog as DetailedAuditLog, MaskingReport, RuleReport,
30};
31pub use config::{FieldPattern, MaskingConfigManager, MaskingProfile, SensitiveFieldDetector};
32pub use maskers::{wildcard_match, HashAlgorithm, HashMasker, PartialDisplayMasker, PatternMasker};
33pub use strategy::{
34    FieldMaskingRule, MaskingCondition, MaskingPipeline, MaskingStrategyEngine, MaskingValidator,
35    PipelineStage,
36};
37
38/// Masking rules supported by [`DataMasker`].
39///
40/// `Custom(String)` expects a configuration of the form `"prefix,suffix"`
41/// where `prefix` and `suffix` are the number of characters (Unicode scalar
42/// values) to retain from the start and end of the input. Example:
43/// `Custom("3,2".to_string())` keeps the first 3 and last 2 characters and
44/// replaces everything in between with `*`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub enum MaskingRule {
47    Phone,
48    Email,
49    IdCard,
50    BankCard,
51    Name,
52    Address,
53    Ip,
54    Imei,
55    Plate,
56    Custom(String),
57    Password,
58    ApiKey,
59}
60
61pub struct DataMasker;
62
63impl DataMasker {
64    /// Applies the given masking `rule` to `value`. The implementation is
65    /// Unicode-safe (works on `char` boundaries rather than byte slices) and
66    /// never panics: inputs shorter than the rule's required visible prefix
67    /// return a sensible fallback (the original value, or `"***"` when even
68    /// the original cannot be safely revealed).
69    pub fn apply(rule: &MaskingRule, value: &str) -> String {
70        match rule {
71            MaskingRule::Phone => mask_prefix_suffix(value, 3, 4),
72            MaskingRule::Email => mask_email(value),
73            MaskingRule::IdCard => mask_prefix_suffix(value, 4, 4),
74            MaskingRule::BankCard => mask_prefix_suffix(value, 4, 4),
75            MaskingRule::Name => mask_name(value),
76            MaskingRule::Address => mask_address(value, 6),
77            MaskingRule::Ip => mask_ip(value),
78            MaskingRule::Imei => mask_imei(value),
79            MaskingRule::Plate => mask_plate(value),
80            MaskingRule::Custom(spec) => mask_custom(value, spec),
81            MaskingRule::Password => "***".to_string(),
82            MaskingRule::ApiKey => mask_api_key(value),
83        }
84    }
85
86    /// Applies a sequence of rules to the same value, in order.
87    ///
88    /// Useful for stacked masking (e.g. first mask the whole phone, then
89    /// apply a custom prefix/suffix rule). Each rule is applied to the
90    /// output of the previous one.
91    pub fn apply_many(rules: &[MaskingRule], value: &str) -> String {
92        let mut out = value.to_string();
93        for rule in rules {
94            out = Self::apply(rule, &out);
95        }
96        out
97    }
98
99    /// Masks specific fields of a `HashMap<String, String>` in place,
100    /// returning a new map with only the masked fields replaced.
101    ///
102    /// Fields whose rule is `None` are copied through unchanged. Fields not
103    /// present in `rules` are also copied through, so callers can pass a
104    /// partial rule set without losing data.
105    pub fn mask_map(
106        rules: &std::collections::HashMap<String, MaskingRule>,
107        data: &std::collections::HashMap<String, String>,
108    ) -> std::collections::HashMap<String, String> {
109        data.iter()
110            .map(|(k, v)| match rules.get(k) {
111                Some(rule) => (k.clone(), Self::apply(rule, v)),
112                None => (k.clone(), v.clone()),
113            })
114            .collect()
115    }
116
117    /// Masks a JSON object string by field name.
118    ///
119    /// `rules` maps field names to masking rules. Only top-level fields are
120    /// matched (nested objects are left untouched); non-JSON input is
121    /// returned unchanged so callers can rely on this being lossless for
122    /// malformed payloads.
123    pub fn mask_json(rules: &std::collections::HashMap<String, MaskingRule>, json: &str) -> String {
124        let Ok(mut value) = serde_json::from_str::<serde_json::Value>(json) else {
125            return json.to_string();
126        };
127        let Some(obj) = value.as_object_mut() else {
128            return json.to_string();
129        };
130        for (field, rule) in rules {
131            if let Some(serde_json::Value::String(s)) = obj.get_mut(field) {
132                *s = Self::apply(rule, s);
133            }
134        }
135        serde_json::to_string(&value).unwrap_or_else(|_| json.to_string())
136    }
137}
138
139/// Masks the middle of the input, keeping the first `prefix` and last
140/// `suffix` characters visible. Returns `"***"` when the input is too short
141/// to reveal `prefix + suffix` characters (or when `prefix`/`suffix` are
142/// zero, the rule degrades gracefully).
143fn mask_prefix_suffix(value: &str, prefix: usize, suffix: usize) -> String {
144    let chars: Vec<char> = value.chars().collect();
145    let len = chars.len();
146    if len == 0 {
147        return "***".to_string();
148    }
149    // Need at least one extra char beyond prefix+suffix to mask; otherwise
150    // the value has nothing to hide and we return the original.
151    if len <= prefix + suffix {
152        // Too short to safely mask without revealing the structure; return "***".
153        return "***".to_string();
154    }
155    let hidden = len - prefix - suffix;
156    let mut out = String::with_capacity(len);
157    for &c in &chars[..prefix] {
158        out.push(c);
159    }
160    for _ in 0..hidden {
161        out.push('*');
162    }
163    for &c in &chars[len - suffix..] {
164        out.push(c);
165    }
166    out
167}
168
169/// Masks an API key by keeping the first 4 and last 4 characters visible
170/// and replacing everything in between with `*`. Returns `"***"` for inputs
171/// too short to safely mask (≤ 8 characters).
172fn mask_api_key(value: &str) -> String {
173    mask_prefix_suffix(value, 4, 4)
174}
175
176fn mask_email(value: &str) -> String {
177    let parts: Vec<&str> = value.splitn(2, '@').collect();
178    if parts.len() != 2 {
179        // Not a valid email; do not attempt to mask structurally.
180        return "***".to_string();
181    }
182    let local = parts[0];
183    let domain = parts[1];
184    let local_chars: Vec<char> = local.chars().collect();
185    if local_chars.is_empty() {
186        return "***".to_string();
187    }
188    let mut out = String::with_capacity(value.len());
189    out.push(local_chars[0]);
190    // Hide the rest of the local part with one `*` per hidden character.
191    for _ in 1..local_chars.len() {
192        out.push('*');
193    }
194    out.push('@');
195    out.push_str(domain);
196    out
197}
198
199fn mask_name(value: &str) -> String {
200    let chars: Vec<char> = value.chars().collect();
201    if chars.is_empty() {
202        return String::new();
203    }
204    let mut out = String::with_capacity(chars.len());
205    out.push(chars[0]);
206    for _ in 1..chars.len() {
207        out.push('*');
208    }
209    out
210}
211
212fn mask_address(value: &str, keep: usize) -> String {
213    let chars: Vec<char> = value.chars().collect();
214    if chars.is_empty() {
215        return String::new();
216    }
217    if chars.len() <= keep {
218        // Nothing meaningful to mask: hide everything to avoid leaking
219        // the structure of very short addresses.
220        return "*".repeat(chars.len());
221    }
222    let hidden = chars.len() - keep;
223    let mut out = String::with_capacity(chars.len());
224    for &c in &chars[..keep] {
225        out.push(c);
226    }
227    for _ in 0..hidden {
228        out.push('*');
229    }
230    out
231}
232
233/// IP address masking: 192.168.1.100 → 192.168.1.*
234///
235/// IPv4: Hide last segment (content after last `.`).
236/// IPv6: Hide last `:` group (content after last colon).
237/// Returns as-is if unrecognized. `.` and `:` are ASCII single bytes, `rfind` returns byte position
238/// that is always a character boundary, slice is safe.
239fn mask_ip(ip: &str) -> String {
240    if let Some(last_dot) = ip.rfind('.') {
241        format!("{}.*", &ip[..last_dot])
242    } else if let Some(last_colon) = ip.rfind(':') {
243        format!("{}:*", &ip[..last_colon])
244    } else {
245        ip.to_string()
246    }
247}
248
249/// IMEI masking: Keep first 6 digits and last 1 digit, replace middle with `****`
250///
251/// IMEI is 15 digits (3GPP TS 23.003), processed by Unicode characters for safety.
252fn mask_imei(imei: &str) -> String {
253    let chars: Vec<char> = imei.chars().collect();
254    if chars.len() < 7 {
255        return "*".repeat(chars.len());
256    }
257    let mut out = String::with_capacity(chars.len() + 4);
258    for &c in &chars[..6] {
259        out.push(c);
260    }
261    out.push_str("****");
262    out.push(chars[chars.len() - 1]);
263    out
264}
265
266/// License plate masking: 京A12345 → 京A12**45
267///
268/// Keep first (len-2) characters and last 2 characters, replace middle with `**`.
269/// Processed by Unicode characters, supports Chinese license plates (e.g. "京A12345").
270fn mask_plate(plate: &str) -> String {
271    let chars: Vec<char> = plate.chars().collect();
272    let len = chars.len();
273    if len < 4 {
274        return "*".repeat(len);
275    }
276    let mut out = String::with_capacity(len + 2);
277    for &c in &chars[..len - 2] {
278        out.push(c);
279    }
280    out.push_str("**");
281    for &c in &chars[len - 2..] {
282        out.push(c);
283    }
284    out
285}
286
287fn mask_custom(value: &str, spec: &str) -> String {
288    let (prefix, suffix) = match parse_custom_spec(spec) {
289        Some(parsed) => parsed,
290        None => return "***".to_string(),
291    };
292    mask_prefix_suffix(value, prefix, suffix)
293}
294
295/// Parses a `"prefix,suffix"` spec into `(prefix, suffix)`. Returns `None`
296/// on malformed input or negative/overflowing values.
297fn parse_custom_spec(spec: &str) -> Option<(usize, usize)> {
298    let parts: Vec<&str> = spec.split(',').collect();
299    if parts.len() != 2 {
300        return None;
301    }
302    let prefix: usize = parts[0].trim().parse().ok()?;
303    let suffix: usize = parts[1].trim().parse().ok()?;
304    Some((prefix, suffix))
305}
306
307// ---------------------------------------------------------------------------
308// 脱敏策略与审计工具
309// ---------------------------------------------------------------------------
310
311/// Masking strategy: define masking rules for multiple fields, applied in batch
312#[derive(Debug, Clone, Default)]
313pub struct MaskingPolicy {
314    rules: std::collections::HashMap<String, MaskingRule>,
315}
316
317impl MaskingPolicy {
318    /// Create empty strategy
319    pub fn new() -> Self {
320        Self::default()
321    }
322
323    /// Add field masking rule (chainable)
324    pub fn add_rule(&mut self, field: &str, rule: MaskingRule) -> &mut Self {
325        self.rules.insert(field.to_string(), rule);
326        self
327    }
328
329    /// Get masking rule for a field
330    pub fn get_rule(&self, field: &str) -> Option<&MaskingRule> {
331        self.rules.get(field)
332    }
333
334    /// Remove masking rule for a field
335    pub fn remove_rule(&mut self, field: &str) -> Option<MaskingRule> {
336        self.rules.remove(field)
337    }
338
339    /// Number of registered fields
340    pub fn field_count(&self) -> usize {
341        self.rules.len()
342    }
343
344    /// Get all rule references
345    pub fn rules(&self) -> &std::collections::HashMap<String, MaskingRule> {
346        &self.rules
347    }
348
349    /// Apply masking to HashMap data
350    pub fn apply_to_map(
351        &self,
352        data: &std::collections::HashMap<String, String>,
353    ) -> std::collections::HashMap<String, String> {
354        DataMasker::mask_map(&self.rules, data)
355    }
356
357    /// Apply masking to JSON string
358    pub fn apply_to_json(&self, json: &str) -> String {
359        DataMasker::mask_json(&self.rules, json)
360    }
361}
362
363/// Masking audit entry: records a single masking operation
364#[derive(Debug, Clone)]
365pub struct MaskAuditEntry {
366    field: String,
367    original_len: usize,
368    masked_len: usize,
369}
370
371impl MaskAuditEntry {
372    /// Field name
373    pub fn field(&self) -> &str {
374        &self.field
375    }
376
377    /// Original value length
378    pub fn original_len(&self) -> usize {
379        self.original_len
380    }
381
382    /// Masked length
383    pub fn masked_len(&self) -> usize {
384        self.masked_len
385    }
386
387    /// Whether truncated (masked is shorter than original)
388    pub fn was_truncated(&self) -> bool {
389        self.masked_len < self.original_len
390    }
391}
392
393/// Masking audit log: records all masking operations
394#[derive(Debug, Clone, Default)]
395pub struct MaskAuditLog {
396    entries: Vec<MaskAuditEntry>,
397}
398
399impl MaskAuditLog {
400    /// Create empty audit log
401    pub fn new() -> Self {
402        Self::default()
403    }
404
405    /// Record a masking operation
406    pub fn log(&mut self, field: &str, original_len: usize, masked_len: usize) {
407        self.entries.push(MaskAuditEntry {
408            field: field.to_string(),
409            original_len,
410            masked_len,
411        });
412    }
413
414    /// All audit entries
415    pub fn entries(&self) -> &[MaskAuditEntry] {
416        &self.entries
417    }
418
419    /// Entry count
420    pub fn entry_count(&self) -> usize {
421        self.entries.len()
422    }
423
424    /// Total masked characters (sum of original_len - masked_len)
425    pub fn total_masked_chars(&self) -> usize {
426        self.entries
427            .iter()
428            .map(|e| e.original_len.saturating_sub(e.masked_len))
429            .sum()
430    }
431
432    /// Clear log
433    pub fn clear(&mut self) {
434        self.entries.clear();
435    }
436}
437
438/// Masking config: custom mask character, minimum mask length, fallback value
439#[derive(Debug, Clone)]
440pub struct MaskingConfig {
441    mask_char: char,
442    min_mask_length: usize,
443    fallback: String,
444}
445
446impl Default for MaskingConfig {
447    fn default() -> Self {
448        Self {
449            mask_char: '*',
450            min_mask_length: 3,
451            fallback: "***".to_string(),
452        }
453    }
454}
455
456impl MaskingConfig {
457    /// Create default config
458    pub fn new() -> Self {
459        Self::default()
460    }
461
462    /// Mask character
463    pub fn mask_char(&self) -> char {
464        self.mask_char
465    }
466
467    /// Set mask character (chainable)
468    pub fn with_mask_char(mut self, c: char) -> Self {
469        self.mask_char = c;
470        self
471    }
472
473    /// Minimum mask length
474    pub fn min_mask_length(&self) -> usize {
475        self.min_mask_length
476    }
477
478    /// Set minimum mask length (chainable)
479    pub fn with_min_mask_length(mut self, min: usize) -> Self {
480        self.min_mask_length = min;
481        self
482    }
483
484    /// Fallback value
485    pub fn fallback_value(&self) -> &str {
486        &self.fallback
487    }
488
489    /// Set fallback value (chainable)
490    pub fn with_fallback_value(mut self, value: &str) -> Self {
491        self.fallback = value.to_string();
492        self
493    }
494}
495
496/// Masking statistics: tracks masking operation count per field
497#[derive(Debug, Clone, Default)]
498pub struct MaskingStats {
499    counts: std::collections::HashMap<String, u64>,
500}
501
502impl MaskingStats {
503    /// Create empty statistics
504    pub fn new() -> Self {
505        Self::default()
506    }
507
508    /// Record a masking operation
509    pub fn record(&mut self, field: &str) {
510        *self.counts.entry(field.to_string()).or_insert(0) += 1;
511    }
512
513    /// Total operation count
514    pub fn total_operations(&self) -> u64 {
515        self.counts.values().sum()
516    }
517
518    /// Operation count for a field
519    pub fn field_operations(&self, field: &str) -> u64 {
520        self.counts.get(field).copied().unwrap_or(0)
521    }
522
523    /// Field with most operations
524    pub fn most_masked_field(&self) -> Option<&str> {
525        self.counts
526            .iter()
527            .max_by_key(|(_, v)| *v)
528            .map(|(k, _)| k.as_str())
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    // ----- Phone -----
537    #[test]
538    fn test_phone_standard() {
539        let result = DataMasker::apply(&MaskingRule::Phone, "13812345678");
540        assert_eq!(result, "138****5678");
541    }
542
543    #[test]
544    fn test_phone_too_short() {
545        // Less than 3+4 chars -> cannot safely reveal structure -> "***"
546        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "12345"), "***");
547        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "1234567"), "***");
548    }
549
550    #[test]
551    fn test_phone_boundary_seven_plus_one() {
552        // 8 chars: prefix=3, suffix=4, hidden=1
553        assert_eq!(
554            DataMasker::apply(&MaskingRule::Phone, "12345678"),
555            "123*5678"
556        );
557    }
558
559    #[test]
560    fn test_phone_empty() {
561        assert_eq!(DataMasker::apply(&MaskingRule::Phone, ""), "***");
562    }
563
564    // ----- Email -----
565    #[test]
566    fn test_email_standard() {
567        assert_eq!(
568            DataMasker::apply(&MaskingRule::Email, "test@example.com"),
569            "t***@example.com"
570        );
571    }
572
573    #[test]
574    fn test_email_single_char_local() {
575        assert_eq!(
576            DataMasker::apply(&MaskingRule::Email, "a@example.com"),
577            "a@example.com"
578        );
579    }
580
581    #[test]
582    fn test_email_no_at() {
583        assert_eq!(DataMasker::apply(&MaskingRule::Email, "notanemail"), "***");
584    }
585
586    #[test]
587    fn test_email_empty_local() {
588        assert_eq!(
589            DataMasker::apply(&MaskingRule::Email, "@example.com"),
590            "***"
591        );
592    }
593
594    // ----- IdCard -----
595    #[test]
596    fn test_idcard_standard_18() {
597        let id = "110101199001012345";
598        let masked = DataMasker::apply(&MaskingRule::IdCard, id);
599        // First 4 + 10 stars + last 4 ("2345").
600        assert_eq!(masked, "1101**********2345");
601        assert_eq!(masked.len(), id.len());
602    }
603
604    #[test]
605    fn test_idcard_too_short() {
606        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "1234567"), "***");
607        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "12345678"), "***");
608    }
609
610    #[test]
611    fn test_idcard_empty() {
612        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, ""), "***");
613    }
614
615    // ----- BankCard -----
616    #[test]
617    fn test_bankcard_standard_16() {
618        let card = "6222020200112345";
619        let masked = DataMasker::apply(&MaskingRule::BankCard, card);
620        assert_eq!(masked, "6222********2345");
621    }
622
623    #[test]
624    fn test_bankcard_too_short() {
625        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, "1234567"), "***");
626    }
627
628    #[test]
629    fn test_bankcard_empty() {
630        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, ""), "***");
631    }
632
633    // ----- Name -----
634    #[test]
635    fn test_name_chinese_two_chars() {
636        assert_eq!(DataMasker::apply(&MaskingRule::Name, "张三"), "张*");
637    }
638
639    #[test]
640    fn test_name_chinese_three_chars() {
641        assert_eq!(DataMasker::apply(&MaskingRule::Name, "诸葛亮"), "诸**");
642    }
643
644    #[test]
645    fn test_name_single_char() {
646        assert_eq!(DataMasker::apply(&MaskingRule::Name, "李"), "李");
647    }
648
649    #[test]
650    fn test_name_empty() {
651        assert_eq!(DataMasker::apply(&MaskingRule::Name, ""), "");
652    }
653
654    #[test]
655    fn test_name_english() {
656        assert_eq!(DataMasker::apply(&MaskingRule::Name, "Alice"), "A****");
657    }
658
659    // ----- Address -----
660    #[test]
661    fn test_address_standard() {
662        let addr = "北京市海淀区中关村大街1号";
663        let masked = DataMasker::apply(&MaskingRule::Address, addr);
664        // First 6 chars kept ("北京市海淀区"), the rest replaced with one `*` per char.
665        let expected = "北京市海淀区*******";
666        assert_eq!(masked, expected);
667        assert_eq!(masked.chars().count(), addr.chars().count());
668    }
669
670    #[test]
671    fn test_address_exactly_six_chars() {
672        let addr = "北京市海淀区";
673        assert_eq!(DataMasker::apply(&MaskingRule::Address, addr), "******");
674    }
675
676    #[test]
677    fn test_address_short() {
678        assert_eq!(DataMasker::apply(&MaskingRule::Address, "北京"), "**");
679    }
680
681    #[test]
682    fn test_address_empty() {
683        assert_eq!(DataMasker::apply(&MaskingRule::Address, ""), "");
684    }
685
686    // ----- Custom -----
687    #[test]
688    fn test_custom_prefix_suffix() {
689        let rule = MaskingRule::Custom("3,2".to_string());
690        assert_eq!(DataMasker::apply(&rule, "ABCDEFGHIJ"), "ABC*****IJ");
691    }
692
693    #[test]
694    fn test_custom_too_short() {
695        let rule = MaskingRule::Custom("4,4".to_string());
696        assert_eq!(DataMasker::apply(&rule, "ABC"), "***");
697    }
698
699    #[test]
700    fn test_custom_invalid_spec() {
701        let rule = MaskingRule::Custom("not_a_number".to_string());
702        assert_eq!(DataMasker::apply(&rule, "ABCDEF"), "***");
703    }
704
705    #[test]
706    fn test_custom_invalid_spec_two_parts() {
707        let rule = MaskingRule::Custom("1,2,3".to_string());
708        assert_eq!(DataMasker::apply(&rule, "ABCDEF"), "***");
709    }
710
711    #[test]
712    fn test_custom_empty_value() {
713        let rule = MaskingRule::Custom("2,2".to_string());
714        assert_eq!(DataMasker::apply(&rule, ""), "***");
715    }
716
717    // ----- Unicode safety -----
718    #[test]
719    fn test_unicode_no_panic() {
720        // Mixing CJK + emoji + ascii - just verify no panic and contains stars.
721        let value = "你好🌍世界AB";
722        let masked = DataMasker::apply(&MaskingRule::Address, value);
723        assert!(masked.contains('*'));
724    }
725
726    #[test]
727    fn test_long_string() {
728        let value = "1".repeat(10000);
729        let masked = DataMasker::apply(&MaskingRule::Phone, &value);
730        // Should start with first 3, end with last 4, all stars in between.
731        assert!(masked.starts_with("111"));
732        assert!(masked.ends_with("1111"));
733        assert_eq!(masked.matches('*').count(), 10000 - 7);
734    }
735
736    #[test]
737    fn test_single_char_inputs() {
738        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "1"), "***");
739        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "1"), "***");
740        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, "1"), "***");
741        assert_eq!(DataMasker::apply(&MaskingRule::Name, "张"), "张");
742        assert_eq!(DataMasker::apply(&MaskingRule::Address, "张"), "*");
743    }
744
745    // ----- IP -----
746    #[test]
747    fn test_ip_v4_standard() {
748        assert_eq!(
749            DataMasker::apply(&MaskingRule::Ip, "192.168.1.100"),
750            "192.168.1.*"
751        );
752    }
753
754    #[test]
755    fn test_ip_v4_loopback() {
756        assert_eq!(
757            DataMasker::apply(&MaskingRule::Ip, "127.0.0.1"),
758            "127.0.0.*"
759        );
760    }
761
762    #[test]
763    fn test_ip_v6_standard() {
764        // IPv6:隐藏最后一个冒号后的内容
765        assert_eq!(
766            DataMasker::apply(&MaskingRule::Ip, "2001:db8::1"),
767            "2001:db8::*"
768        );
769    }
770
771    #[test]
772    fn test_ip_no_separator() {
773        assert_eq!(
774            DataMasker::apply(&MaskingRule::Ip, "localhost"),
775            "localhost"
776        );
777    }
778
779    #[test]
780    fn test_ip_empty() {
781        assert_eq!(DataMasker::apply(&MaskingRule::Ip, ""), "");
782    }
783
784    // ----- IMEI -----
785    #[test]
786    fn test_imei_standard_15() {
787        // 15 位 IMEI:保留前 6 + **** + 最后 1 位
788        assert_eq!(
789            DataMasker::apply(&MaskingRule::Imei, "123456789012345"),
790            "123456****5"
791        );
792    }
793
794    #[test]
795    fn test_imei_too_short() {
796        assert_eq!(DataMasker::apply(&MaskingRule::Imei, "123456"), "******");
797        assert_eq!(DataMasker::apply(&MaskingRule::Imei, "123"), "***");
798    }
799
800    #[test]
801    fn test_imei_empty() {
802        assert_eq!(DataMasker::apply(&MaskingRule::Imei, ""), "");
803    }
804
805    // ----- Plate -----
806    #[test]
807    fn test_plate_chinese_standard() {
808        // 京A12345(7 字符):前 5 + ** + 后 2
809        assert_eq!(
810            DataMasker::apply(&MaskingRule::Plate, "京A12345"),
811            "京A123**45"
812        );
813    }
814
815    #[test]
816    fn test_plate_with_separator() {
817        // 京A·12345(8 字符):前 6 + ** + 后 2
818        assert_eq!(
819            DataMasker::apply(&MaskingRule::Plate, "京A·12345"),
820            "京A·123**45"
821        );
822    }
823
824    #[test]
825    fn test_plate_too_short() {
826        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "京A"), "**");
827        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "京"), "*");
828        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "京A1"), "***");
829    }
830
831    #[test]
832    fn test_plate_empty() {
833        assert_eq!(DataMasker::apply(&MaskingRule::Plate, ""), "");
834    }
835
836    #[test]
837    fn test_plate_boundary_four_chars() {
838        // 4 字符:前 2 + ** + 后 2
839        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "ABCD"), "AB**CD");
840    }
841}
842
843#[cfg(test)]
844mod api_tests {
845    use super::*;
846    use std::collections::HashMap;
847
848    #[test]
849    fn test_apply_many_stacked_rules() {
850        // 先手机号脱敏,再自定义前缀后缀
851        let rules = vec![MaskingRule::Phone, MaskingRule::Custom("1,2".to_string())];
852        let out = DataMasker::apply_many(&rules, "13812345678");
853        assert!(
854            out.contains('*'),
855            "stacked masking should keep stars: {out}"
856        );
857    }
858
859    #[test]
860    fn test_mask_map_partial_rules() {
861        let mut rules = HashMap::new();
862        rules.insert("phone".to_string(), MaskingRule::Phone);
863        let mut data = HashMap::new();
864        data.insert("phone".to_string(), "13812345678".to_string());
865        data.insert("name".to_string(), "Alice".to_string());
866
867        let out = DataMasker::mask_map(&rules, &data);
868        assert!(
869            out["phone"].contains('*'),
870            "phone should be masked: {}",
871            out["phone"]
872        );
873        assert_eq!(out["name"], "Alice", "unlisted field passes through");
874    }
875
876    #[test]
877    fn test_mask_json_top_level_fields() {
878        let mut rules = HashMap::new();
879        rules.insert("phone".to_string(), MaskingRule::Phone);
880        rules.insert("password".to_string(), MaskingRule::Password);
881
882        let json = r#"{"id":1,"phone":"13812345678","password":"secret","name":"Bob"}"#;
883        let out = DataMasker::mask_json(&rules, json);
884        assert!(out.contains('*'), "masked json should contain stars: {out}");
885        assert!(!out.contains("13812345678"), "phone value must not leak");
886        assert!(!out.contains("secret"), "password value must not leak");
887        assert!(out.contains("Bob"), "unlisted field passes through");
888    }
889
890    #[test]
891    fn test_mask_json_invalid_input_unchanged() {
892        let rules = HashMap::new();
893        assert_eq!(DataMasker::mask_json(&rules, "not-json"), "not-json");
894    }
895
896    #[test]
897    fn test_mask_json_non_object_unchanged() {
898        let rules = HashMap::new();
899        assert_eq!(DataMasker::mask_json(&rules, "[1,2,3]"), "[1,2,3]");
900    }
901
902    #[test]
903    fn test_mask_map_empty_rules() {
904        let data = HashMap::new();
905        let out = DataMasker::mask_map(&HashMap::new(), &data);
906        assert!(out.is_empty());
907    }
908
909    #[test]
910    fn test_apply_many_empty_rules_identity() {
911        assert_eq!(DataMasker::apply_many(&[], "hello"), "hello");
912    }
913}
914
915#[cfg(test)]
916mod policy_tests {
917    use super::*;
918    use std::collections::HashMap;
919
920    // --- MaskingPolicy tests ---
921
922    #[test]
923    fn policy_new_empty() {
924        let p = MaskingPolicy::new();
925        assert_eq!(p.field_count(), 0);
926    }
927
928    #[test]
929    fn policy_add_and_get_rule() {
930        let mut p = MaskingPolicy::new();
931        p.add_rule("phone", MaskingRule::Phone);
932        assert_eq!(p.field_count(), 1);
933        assert!(p.get_rule("phone").is_some());
934        assert!(p.get_rule("email").is_none());
935    }
936
937    #[test]
938    fn policy_remove_rule() {
939        let mut p = MaskingPolicy::new();
940        p.add_rule("phone", MaskingRule::Phone);
941        let removed = p.remove_rule("phone");
942        assert!(removed.is_some());
943        assert_eq!(p.field_count(), 0);
944    }
945
946    #[test]
947    fn policy_apply_to_map() {
948        let mut p = MaskingPolicy::new();
949        p.add_rule("phone", MaskingRule::Phone);
950        let mut data = HashMap::new();
951        data.insert("phone".to_string(), "13812345678".to_string());
952        data.insert("name".to_string(), "Alice".to_string());
953        let out = p.apply_to_map(&data);
954        assert!(out["phone"].contains('*'));
955        assert_eq!(out["name"], "Alice");
956    }
957
958    #[test]
959    fn policy_apply_to_json() {
960        let mut p = MaskingPolicy::new();
961        p.add_rule("phone", MaskingRule::Phone);
962        let json = r#"{"phone":"13812345678","name":"Bob"}"#;
963        let out = p.apply_to_json(json);
964        assert!(out.contains('*'));
965        assert!(!out.contains("13812345678"));
966    }
967
968    #[test]
969    fn policy_rules_ref() {
970        let mut p = MaskingPolicy::new();
971        p.add_rule("a", MaskingRule::Name);
972        assert_eq!(p.rules().len(), 1);
973    }
974
975    // --- MaskAuditLog tests ---
976
977    #[test]
978    fn audit_log_new_empty() {
979        let log = MaskAuditLog::new();
980        assert_eq!(log.entry_count(), 0);
981        assert!(log.entries().is_empty());
982    }
983
984    #[test]
985    fn audit_log_record() {
986        let mut log = MaskAuditLog::new();
987        log.log("phone", 11, 11);
988        log.log("name", 5, 5);
989        assert_eq!(log.entry_count(), 2);
990    }
991
992    #[test]
993    fn audit_log_total_masked_chars() {
994        let mut log = MaskAuditLog::new();
995        log.log("phone", 11, 7);
996        log.log("name", 5, 3);
997        assert_eq!(log.total_masked_chars(), 6);
998    }
999
1000    #[test]
1001    fn audit_log_clear() {
1002        let mut log = MaskAuditLog::new();
1003        log.log("a", 1, 1);
1004        log.clear();
1005        assert_eq!(log.entry_count(), 0);
1006    }
1007
1008    #[test]
1009    fn audit_entry_was_truncated() {
1010        let mut log = MaskAuditLog::new();
1011        log.log("short", 10, 5);
1012        log.log("same", 5, 5);
1013        assert!(log.entries()[0].was_truncated());
1014        assert!(!log.entries()[1].was_truncated());
1015    }
1016
1017    // --- MaskingConfig tests ---
1018
1019    #[test]
1020    fn config_defaults() {
1021        let c = MaskingConfig::new();
1022        assert_eq!(c.mask_char(), '*');
1023        assert_eq!(c.min_mask_length(), 3);
1024        assert_eq!(c.fallback_value(), "***");
1025    }
1026
1027    #[test]
1028    fn config_builder() {
1029        let c = MaskingConfig::new()
1030            .with_mask_char('#')
1031            .with_min_mask_length(5)
1032            .with_fallback_value("UNK");
1033        assert_eq!(c.mask_char(), '#');
1034        assert_eq!(c.min_mask_length(), 5);
1035        assert_eq!(c.fallback_value(), "UNK");
1036    }
1037
1038    // --- MaskingStats tests ---
1039
1040    #[test]
1041    fn stats_new_empty() {
1042        let s = MaskingStats::new();
1043        assert_eq!(s.total_operations(), 0);
1044        assert_eq!(s.most_masked_field(), None);
1045    }
1046
1047    #[test]
1048    fn stats_record_and_total() {
1049        let mut s = MaskingStats::new();
1050        s.record("phone");
1051        s.record("phone");
1052        s.record("name");
1053        assert_eq!(s.total_operations(), 3);
1054        assert_eq!(s.field_operations("phone"), 2);
1055        assert_eq!(s.field_operations("name"), 1);
1056    }
1057
1058    #[test]
1059    fn stats_most_masked_field() {
1060        let mut s = MaskingStats::new();
1061        s.record("a");
1062        s.record("b");
1063        s.record("b");
1064        assert_eq!(s.most_masked_field(), Some("b"));
1065    }
1066
1067    #[test]
1068    fn stats_field_operations_zero_for_unknown() {
1069        let s = MaskingStats::new();
1070        assert_eq!(s.field_operations("nonexistent"), 0);
1071    }
1072}