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