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