Skip to main content

sz_orm_masking/
lib.rs

1//! # SZ-ORM Masking — 数据脱敏
2//!
3//! 提供手机号、邮箱、身份证、银行卡、姓名、地址等敏感字段脱敏,并支持自定义
4//! 前缀/后缀保留规则。实现 Unicode 安全,对短输入有合理兜底,不会 panic。
5//!
6//! ## 主要类型
7//!
8//! - [`MaskingRule`] — 脱敏规则枚举
9//! - [`DataMasker`] — 脱敏执行器
10
11use serde::{Deserialize, Serialize};
12
13/// Masking rules supported by [`DataMasker`].
14///
15/// `Custom(String)` expects a configuration of the form `"prefix,suffix"`
16/// where `prefix` and `suffix` are the number of characters (Unicode scalar
17/// values) to retain from the start and end of the input. Example:
18/// `Custom("3,2".to_string())` keeps the first 3 and last 2 characters and
19/// replaces everything in between with `*`.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub enum MaskingRule {
22    Phone,
23    Email,
24    IdCard,
25    BankCard,
26    Name,
27    Address,
28    Ip,
29    Imei,
30    Plate,
31    Custom(String),
32    Password,
33    ApiKey,
34}
35
36pub struct DataMasker;
37
38impl DataMasker {
39    /// Applies the given masking `rule` to `value`. The implementation is
40    /// Unicode-safe (works on `char` boundaries rather than byte slices) and
41    /// never panics: inputs shorter than the rule's required visible prefix
42    /// return a sensible fallback (the original value, or `"***"` when even
43    /// the original cannot be safely revealed).
44    pub fn apply(rule: &MaskingRule, value: &str) -> String {
45        match rule {
46            MaskingRule::Phone => mask_prefix_suffix(value, 3, 4),
47            MaskingRule::Email => mask_email(value),
48            MaskingRule::IdCard => mask_prefix_suffix(value, 4, 4),
49            MaskingRule::BankCard => mask_prefix_suffix(value, 4, 4),
50            MaskingRule::Name => mask_name(value),
51            MaskingRule::Address => mask_address(value, 6),
52            MaskingRule::Ip => mask_ip(value),
53            MaskingRule::Imei => mask_imei(value),
54            MaskingRule::Plate => mask_plate(value),
55            MaskingRule::Custom(spec) => mask_custom(value, spec),
56            MaskingRule::Password => "***".to_string(),
57            MaskingRule::ApiKey => mask_api_key(value),
58        }
59    }
60}
61
62/// Masks the middle of the input, keeping the first `prefix` and last
63/// `suffix` characters visible. Returns `"***"` when the input is too short
64/// to reveal `prefix + suffix` characters (or when `prefix`/`suffix` are
65/// zero, the rule degrades gracefully).
66fn mask_prefix_suffix(value: &str, prefix: usize, suffix: usize) -> String {
67    let chars: Vec<char> = value.chars().collect();
68    let len = chars.len();
69    if len == 0 {
70        return "***".to_string();
71    }
72    // Need at least one extra char beyond prefix+suffix to mask; otherwise
73    // the value has nothing to hide and we return the original.
74    if len <= prefix + suffix {
75        // Too short to safely mask without revealing the structure; return "***".
76        return "***".to_string();
77    }
78    let hidden = len - prefix - suffix;
79    let mut out = String::with_capacity(len);
80    for &c in &chars[..prefix] {
81        out.push(c);
82    }
83    for _ in 0..hidden {
84        out.push('*');
85    }
86    for &c in &chars[len - suffix..] {
87        out.push(c);
88    }
89    out
90}
91
92/// Masks an API key by keeping the first 4 and last 4 characters visible
93/// and replacing everything in between with `*`. Returns `"***"` for inputs
94/// too short to safely mask (≤ 8 characters).
95fn mask_api_key(value: &str) -> String {
96    mask_prefix_suffix(value, 4, 4)
97}
98
99fn mask_email(value: &str) -> String {
100    let parts: Vec<&str> = value.splitn(2, '@').collect();
101    if parts.len() != 2 {
102        // Not a valid email; do not attempt to mask structurally.
103        return "***".to_string();
104    }
105    let local = parts[0];
106    let domain = parts[1];
107    let local_chars: Vec<char> = local.chars().collect();
108    if local_chars.is_empty() {
109        return "***".to_string();
110    }
111    let mut out = String::with_capacity(value.len());
112    out.push(local_chars[0]);
113    // Hide the rest of the local part with one `*` per hidden character.
114    for _ in 1..local_chars.len() {
115        out.push('*');
116    }
117    out.push('@');
118    out.push_str(domain);
119    out
120}
121
122fn mask_name(value: &str) -> String {
123    let chars: Vec<char> = value.chars().collect();
124    if chars.is_empty() {
125        return String::new();
126    }
127    let mut out = String::with_capacity(chars.len());
128    out.push(chars[0]);
129    for _ in 1..chars.len() {
130        out.push('*');
131    }
132    out
133}
134
135fn mask_address(value: &str, keep: usize) -> String {
136    let chars: Vec<char> = value.chars().collect();
137    if chars.is_empty() {
138        return String::new();
139    }
140    if chars.len() <= keep {
141        // Nothing meaningful to mask: hide everything to avoid leaking
142        // the structure of very short addresses.
143        return "*".repeat(chars.len());
144    }
145    let hidden = chars.len() - keep;
146    let mut out = String::with_capacity(chars.len());
147    for &c in &chars[..keep] {
148        out.push(c);
149    }
150    for _ in 0..hidden {
151        out.push('*');
152    }
153    out
154}
155
156/// IP 地址脱敏:192.168.1.100 → 192.168.1.*
157///
158/// IPv4:隐藏最后一段(最后一个 `.` 之后的内容)。
159/// IPv6:隐藏最后一个 `:` 组(最后一个冒号之后的内容)。
160/// 无法识别时原样返回。`.` 和 `:` 为 ASCII 单字节,`rfind` 返回的字节位置
161/// 一定是字符边界,切片安全。
162fn mask_ip(ip: &str) -> String {
163    if let Some(last_dot) = ip.rfind('.') {
164        format!("{}.*", &ip[..last_dot])
165    } else if let Some(last_colon) = ip.rfind(':') {
166        format!("{}:*", &ip[..last_colon])
167    } else {
168        ip.to_string()
169    }
170}
171
172/// IMEI 脱敏:保留前 6 位和最后 1 位,中间用 `****` 替代
173///
174/// IMEI 为 15 位数字(3GPP TS 23.003),按 Unicode 字符处理以保证安全。
175fn mask_imei(imei: &str) -> String {
176    let chars: Vec<char> = imei.chars().collect();
177    if chars.len() < 7 {
178        return "*".repeat(chars.len());
179    }
180    let mut out = String::with_capacity(chars.len() + 4);
181    for &c in &chars[..6] {
182        out.push(c);
183    }
184    out.push_str("****");
185    out.push(chars[chars.len() - 1]);
186    out
187}
188
189/// 车牌号脱敏:京A12345 → 京A12**45
190///
191/// 保留前 (len-2) 个字符和最后 2 个字符,中间用 `**` 替代。
192/// 按 Unicode 字符处理,支持中文车牌(如"京A12345")。
193fn mask_plate(plate: &str) -> String {
194    let chars: Vec<char> = plate.chars().collect();
195    let len = chars.len();
196    if len < 4 {
197        return "*".repeat(len);
198    }
199    let mut out = String::with_capacity(len + 2);
200    for &c in &chars[..len - 2] {
201        out.push(c);
202    }
203    out.push_str("**");
204    for &c in &chars[len - 2..] {
205        out.push(c);
206    }
207    out
208}
209
210fn mask_custom(value: &str, spec: &str) -> String {
211    let (prefix, suffix) = match parse_custom_spec(spec) {
212        Some(parsed) => parsed,
213        None => return "***".to_string(),
214    };
215    mask_prefix_suffix(value, prefix, suffix)
216}
217
218/// Parses a `"prefix,suffix"` spec into `(prefix, suffix)`. Returns `None`
219/// on malformed input or negative/overflowing values.
220fn parse_custom_spec(spec: &str) -> Option<(usize, usize)> {
221    let parts: Vec<&str> = spec.split(',').collect();
222    if parts.len() != 2 {
223        return None;
224    }
225    let prefix: usize = parts[0].trim().parse().ok()?;
226    let suffix: usize = parts[1].trim().parse().ok()?;
227    Some((prefix, suffix))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    // ----- Phone -----
235    #[test]
236    fn test_phone_standard() {
237        let result = DataMasker::apply(&MaskingRule::Phone, "13812345678");
238        assert_eq!(result, "138****5678");
239    }
240
241    #[test]
242    fn test_phone_too_short() {
243        // Less than 3+4 chars -> cannot safely reveal structure -> "***"
244        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "12345"), "***");
245        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "1234567"), "***");
246    }
247
248    #[test]
249    fn test_phone_boundary_seven_plus_one() {
250        // 8 chars: prefix=3, suffix=4, hidden=1
251        assert_eq!(
252            DataMasker::apply(&MaskingRule::Phone, "12345678"),
253            "123*5678"
254        );
255    }
256
257    #[test]
258    fn test_phone_empty() {
259        assert_eq!(DataMasker::apply(&MaskingRule::Phone, ""), "***");
260    }
261
262    // ----- Email -----
263    #[test]
264    fn test_email_standard() {
265        assert_eq!(
266            DataMasker::apply(&MaskingRule::Email, "test@example.com"),
267            "t***@example.com"
268        );
269    }
270
271    #[test]
272    fn test_email_single_char_local() {
273        assert_eq!(
274            DataMasker::apply(&MaskingRule::Email, "a@example.com"),
275            "a@example.com"
276        );
277    }
278
279    #[test]
280    fn test_email_no_at() {
281        assert_eq!(DataMasker::apply(&MaskingRule::Email, "notanemail"), "***");
282    }
283
284    #[test]
285    fn test_email_empty_local() {
286        assert_eq!(
287            DataMasker::apply(&MaskingRule::Email, "@example.com"),
288            "***"
289        );
290    }
291
292    // ----- IdCard -----
293    #[test]
294    fn test_idcard_standard_18() {
295        let id = "110101199001012345";
296        let masked = DataMasker::apply(&MaskingRule::IdCard, id);
297        // First 4 + 10 stars + last 4 ("2345").
298        assert_eq!(masked, "1101**********2345");
299        assert_eq!(masked.len(), id.len());
300    }
301
302    #[test]
303    fn test_idcard_too_short() {
304        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "1234567"), "***");
305        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "12345678"), "***");
306    }
307
308    #[test]
309    fn test_idcard_empty() {
310        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, ""), "***");
311    }
312
313    // ----- BankCard -----
314    #[test]
315    fn test_bankcard_standard_16() {
316        let card = "6222020200112345";
317        let masked = DataMasker::apply(&MaskingRule::BankCard, card);
318        assert_eq!(masked, "6222********2345");
319    }
320
321    #[test]
322    fn test_bankcard_too_short() {
323        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, "1234567"), "***");
324    }
325
326    #[test]
327    fn test_bankcard_empty() {
328        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, ""), "***");
329    }
330
331    // ----- Name -----
332    #[test]
333    fn test_name_chinese_two_chars() {
334        assert_eq!(DataMasker::apply(&MaskingRule::Name, "张三"), "张*");
335    }
336
337    #[test]
338    fn test_name_chinese_three_chars() {
339        assert_eq!(DataMasker::apply(&MaskingRule::Name, "诸葛亮"), "诸**");
340    }
341
342    #[test]
343    fn test_name_single_char() {
344        assert_eq!(DataMasker::apply(&MaskingRule::Name, "李"), "李");
345    }
346
347    #[test]
348    fn test_name_empty() {
349        assert_eq!(DataMasker::apply(&MaskingRule::Name, ""), "");
350    }
351
352    #[test]
353    fn test_name_english() {
354        assert_eq!(DataMasker::apply(&MaskingRule::Name, "Alice"), "A****");
355    }
356
357    // ----- Address -----
358    #[test]
359    fn test_address_standard() {
360        let addr = "北京市海淀区中关村大街1号";
361        let masked = DataMasker::apply(&MaskingRule::Address, addr);
362        // First 6 chars kept ("北京市海淀区"), the rest replaced with one `*` per char.
363        let expected = "北京市海淀区*******";
364        assert_eq!(masked, expected);
365        assert_eq!(masked.chars().count(), addr.chars().count());
366    }
367
368    #[test]
369    fn test_address_exactly_six_chars() {
370        let addr = "北京市海淀区";
371        assert_eq!(DataMasker::apply(&MaskingRule::Address, addr), "******");
372    }
373
374    #[test]
375    fn test_address_short() {
376        assert_eq!(DataMasker::apply(&MaskingRule::Address, "北京"), "**");
377    }
378
379    #[test]
380    fn test_address_empty() {
381        assert_eq!(DataMasker::apply(&MaskingRule::Address, ""), "");
382    }
383
384    // ----- Custom -----
385    #[test]
386    fn test_custom_prefix_suffix() {
387        let rule = MaskingRule::Custom("3,2".to_string());
388        assert_eq!(DataMasker::apply(&rule, "ABCDEFGHIJ"), "ABC*****IJ");
389    }
390
391    #[test]
392    fn test_custom_too_short() {
393        let rule = MaskingRule::Custom("4,4".to_string());
394        assert_eq!(DataMasker::apply(&rule, "ABC"), "***");
395    }
396
397    #[test]
398    fn test_custom_invalid_spec() {
399        let rule = MaskingRule::Custom("not_a_number".to_string());
400        assert_eq!(DataMasker::apply(&rule, "ABCDEF"), "***");
401    }
402
403    #[test]
404    fn test_custom_invalid_spec_two_parts() {
405        let rule = MaskingRule::Custom("1,2,3".to_string());
406        assert_eq!(DataMasker::apply(&rule, "ABCDEF"), "***");
407    }
408
409    #[test]
410    fn test_custom_empty_value() {
411        let rule = MaskingRule::Custom("2,2".to_string());
412        assert_eq!(DataMasker::apply(&rule, ""), "***");
413    }
414
415    // ----- Unicode safety -----
416    #[test]
417    fn test_unicode_no_panic() {
418        // Mixing CJK + emoji + ascii - just verify no panic and contains stars.
419        let value = "你好🌍世界AB";
420        let masked = DataMasker::apply(&MaskingRule::Address, value);
421        assert!(masked.contains('*'));
422    }
423
424    #[test]
425    fn test_long_string() {
426        let value = "1".repeat(10000);
427        let masked = DataMasker::apply(&MaskingRule::Phone, &value);
428        // Should start with first 3, end with last 4, all stars in between.
429        assert!(masked.starts_with("111"));
430        assert!(masked.ends_with("1111"));
431        assert_eq!(masked.matches('*').count(), 10000 - 7);
432    }
433
434    #[test]
435    fn test_single_char_inputs() {
436        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "1"), "***");
437        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "1"), "***");
438        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, "1"), "***");
439        assert_eq!(DataMasker::apply(&MaskingRule::Name, "张"), "张");
440        assert_eq!(DataMasker::apply(&MaskingRule::Address, "张"), "*");
441    }
442
443    // ----- IP -----
444    #[test]
445    fn test_ip_v4_standard() {
446        assert_eq!(
447            DataMasker::apply(&MaskingRule::Ip, "192.168.1.100"),
448            "192.168.1.*"
449        );
450    }
451
452    #[test]
453    fn test_ip_v4_loopback() {
454        assert_eq!(
455            DataMasker::apply(&MaskingRule::Ip, "127.0.0.1"),
456            "127.0.0.*"
457        );
458    }
459
460    #[test]
461    fn test_ip_v6_standard() {
462        // IPv6:隐藏最后一个冒号后的内容
463        assert_eq!(
464            DataMasker::apply(&MaskingRule::Ip, "2001:db8::1"),
465            "2001:db8::*"
466        );
467    }
468
469    #[test]
470    fn test_ip_no_separator() {
471        assert_eq!(
472            DataMasker::apply(&MaskingRule::Ip, "localhost"),
473            "localhost"
474        );
475    }
476
477    #[test]
478    fn test_ip_empty() {
479        assert_eq!(DataMasker::apply(&MaskingRule::Ip, ""), "");
480    }
481
482    // ----- IMEI -----
483    #[test]
484    fn test_imei_standard_15() {
485        // 15 位 IMEI:保留前 6 + **** + 最后 1 位
486        assert_eq!(
487            DataMasker::apply(&MaskingRule::Imei, "123456789012345"),
488            "123456****5"
489        );
490    }
491
492    #[test]
493    fn test_imei_too_short() {
494        assert_eq!(DataMasker::apply(&MaskingRule::Imei, "123456"), "******");
495        assert_eq!(DataMasker::apply(&MaskingRule::Imei, "123"), "***");
496    }
497
498    #[test]
499    fn test_imei_empty() {
500        assert_eq!(DataMasker::apply(&MaskingRule::Imei, ""), "");
501    }
502
503    // ----- Plate -----
504    #[test]
505    fn test_plate_chinese_standard() {
506        // 京A12345(7 字符):前 5 + ** + 后 2
507        assert_eq!(
508            DataMasker::apply(&MaskingRule::Plate, "京A12345"),
509            "京A123**45"
510        );
511    }
512
513    #[test]
514    fn test_plate_with_separator() {
515        // 京A·12345(8 字符):前 6 + ** + 后 2
516        assert_eq!(
517            DataMasker::apply(&MaskingRule::Plate, "京A·12345"),
518            "京A·123**45"
519        );
520    }
521
522    #[test]
523    fn test_plate_too_short() {
524        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "京A"), "**");
525        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "京"), "*");
526        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "京A1"), "***");
527    }
528
529    #[test]
530    fn test_plate_empty() {
531        assert_eq!(DataMasker::apply(&MaskingRule::Plate, ""), "");
532    }
533
534    #[test]
535    fn test_plate_boundary_four_chars() {
536        // 4 字符:前 2 + ** + 后 2
537        assert_eq!(DataMasker::apply(&MaskingRule::Plate, "ABCD"), "AB**CD");
538    }
539}