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    Custom(String),
29}
30
31pub struct DataMasker;
32
33impl DataMasker {
34    /// Applies the given masking `rule` to `value`. The implementation is
35    /// Unicode-safe (works on `char` boundaries rather than byte slices) and
36    /// never panics: inputs shorter than the rule's required visible prefix
37    /// return a sensible fallback (the original value, or `"***"` when even
38    /// the original cannot be safely revealed).
39    pub fn apply(rule: &MaskingRule, value: &str) -> String {
40        match rule {
41            MaskingRule::Phone => mask_prefix_suffix(value, 3, 4),
42            MaskingRule::Email => mask_email(value),
43            MaskingRule::IdCard => mask_prefix_suffix(value, 4, 4),
44            MaskingRule::BankCard => mask_prefix_suffix(value, 4, 4),
45            MaskingRule::Name => mask_name(value),
46            MaskingRule::Address => mask_address(value, 6),
47            MaskingRule::Custom(spec) => mask_custom(value, spec),
48        }
49    }
50}
51
52/// Masks the middle of the input, keeping the first `prefix` and last
53/// `suffix` characters visible. Returns `"***"` when the input is too short
54/// to reveal `prefix + suffix` characters (or when `prefix`/`suffix` are
55/// zero, the rule degrades gracefully).
56fn mask_prefix_suffix(value: &str, prefix: usize, suffix: usize) -> String {
57    let chars: Vec<char> = value.chars().collect();
58    let len = chars.len();
59    if len == 0 {
60        return "***".to_string();
61    }
62    // Need at least one extra char beyond prefix+suffix to mask; otherwise
63    // the value has nothing to hide and we return the original.
64    if len <= prefix + suffix {
65        // Too short to safely mask without revealing the structure; return "***".
66        return "***".to_string();
67    }
68    let hidden = len - prefix - suffix;
69    let mut out = String::with_capacity(len);
70    for &c in &chars[..prefix] {
71        out.push(c);
72    }
73    for _ in 0..hidden {
74        out.push('*');
75    }
76    for &c in &chars[len - suffix..] {
77        out.push(c);
78    }
79    out
80}
81
82fn mask_email(value: &str) -> String {
83    let parts: Vec<&str> = value.splitn(2, '@').collect();
84    if parts.len() != 2 {
85        // Not a valid email; do not attempt to mask structurally.
86        return "***".to_string();
87    }
88    let local = parts[0];
89    let domain = parts[1];
90    let local_chars: Vec<char> = local.chars().collect();
91    if local_chars.is_empty() {
92        return "***".to_string();
93    }
94    let mut out = String::with_capacity(value.len());
95    out.push(local_chars[0]);
96    // Hide the rest of the local part with one `*` per hidden character.
97    for _ in 1..local_chars.len() {
98        out.push('*');
99    }
100    out.push('@');
101    out.push_str(domain);
102    out
103}
104
105fn mask_name(value: &str) -> String {
106    let chars: Vec<char> = value.chars().collect();
107    if chars.is_empty() {
108        return String::new();
109    }
110    let mut out = String::with_capacity(chars.len());
111    out.push(chars[0]);
112    for _ in 1..chars.len() {
113        out.push('*');
114    }
115    out
116}
117
118fn mask_address(value: &str, keep: usize) -> String {
119    let chars: Vec<char> = value.chars().collect();
120    if chars.is_empty() {
121        return String::new();
122    }
123    if chars.len() <= keep {
124        // Nothing meaningful to mask: hide everything to avoid leaking
125        // the structure of very short addresses.
126        return "*".repeat(chars.len());
127    }
128    let hidden = chars.len() - keep;
129    let mut out = String::with_capacity(chars.len());
130    for &c in &chars[..keep] {
131        out.push(c);
132    }
133    for _ in 0..hidden {
134        out.push('*');
135    }
136    out
137}
138
139fn mask_custom(value: &str, spec: &str) -> String {
140    let (prefix, suffix) = match parse_custom_spec(spec) {
141        Some(parsed) => parsed,
142        None => return "***".to_string(),
143    };
144    mask_prefix_suffix(value, prefix, suffix)
145}
146
147/// Parses a `"prefix,suffix"` spec into `(prefix, suffix)`. Returns `None`
148/// on malformed input or negative/overflowing values.
149fn parse_custom_spec(spec: &str) -> Option<(usize, usize)> {
150    let parts: Vec<&str> = spec.split(',').collect();
151    if parts.len() != 2 {
152        return None;
153    }
154    let prefix: usize = parts[0].trim().parse().ok()?;
155    let suffix: usize = parts[1].trim().parse().ok()?;
156    Some((prefix, suffix))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    // ----- Phone -----
164    #[test]
165    fn test_phone_standard() {
166        let result = DataMasker::apply(&MaskingRule::Phone, "13812345678");
167        assert_eq!(result, "138****5678");
168    }
169
170    #[test]
171    fn test_phone_too_short() {
172        // Less than 3+4 chars -> cannot safely reveal structure -> "***"
173        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "12345"), "***");
174        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "1234567"), "***");
175    }
176
177    #[test]
178    fn test_phone_boundary_seven_plus_one() {
179        // 8 chars: prefix=3, suffix=4, hidden=1
180        assert_eq!(
181            DataMasker::apply(&MaskingRule::Phone, "12345678"),
182            "123*5678"
183        );
184    }
185
186    #[test]
187    fn test_phone_empty() {
188        assert_eq!(DataMasker::apply(&MaskingRule::Phone, ""), "***");
189    }
190
191    // ----- Email -----
192    #[test]
193    fn test_email_standard() {
194        assert_eq!(
195            DataMasker::apply(&MaskingRule::Email, "test@example.com"),
196            "t***@example.com"
197        );
198    }
199
200    #[test]
201    fn test_email_single_char_local() {
202        assert_eq!(
203            DataMasker::apply(&MaskingRule::Email, "a@example.com"),
204            "a@example.com"
205        );
206    }
207
208    #[test]
209    fn test_email_no_at() {
210        assert_eq!(DataMasker::apply(&MaskingRule::Email, "notanemail"), "***");
211    }
212
213    #[test]
214    fn test_email_empty_local() {
215        assert_eq!(
216            DataMasker::apply(&MaskingRule::Email, "@example.com"),
217            "***"
218        );
219    }
220
221    // ----- IdCard -----
222    #[test]
223    fn test_idcard_standard_18() {
224        let id = "110101199001012345";
225        let masked = DataMasker::apply(&MaskingRule::IdCard, id);
226        // First 4 + 10 stars + last 4 ("2345").
227        assert_eq!(masked, "1101**********2345");
228        assert_eq!(masked.len(), id.len());
229    }
230
231    #[test]
232    fn test_idcard_too_short() {
233        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "1234567"), "***");
234        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "12345678"), "***");
235    }
236
237    #[test]
238    fn test_idcard_empty() {
239        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, ""), "***");
240    }
241
242    // ----- BankCard -----
243    #[test]
244    fn test_bankcard_standard_16() {
245        let card = "6222020200112345";
246        let masked = DataMasker::apply(&MaskingRule::BankCard, card);
247        assert_eq!(masked, "6222********2345");
248    }
249
250    #[test]
251    fn test_bankcard_too_short() {
252        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, "1234567"), "***");
253    }
254
255    #[test]
256    fn test_bankcard_empty() {
257        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, ""), "***");
258    }
259
260    // ----- Name -----
261    #[test]
262    fn test_name_chinese_two_chars() {
263        assert_eq!(DataMasker::apply(&MaskingRule::Name, "张三"), "张*");
264    }
265
266    #[test]
267    fn test_name_chinese_three_chars() {
268        assert_eq!(DataMasker::apply(&MaskingRule::Name, "诸葛亮"), "诸**");
269    }
270
271    #[test]
272    fn test_name_single_char() {
273        assert_eq!(DataMasker::apply(&MaskingRule::Name, "李"), "李");
274    }
275
276    #[test]
277    fn test_name_empty() {
278        assert_eq!(DataMasker::apply(&MaskingRule::Name, ""), "");
279    }
280
281    #[test]
282    fn test_name_english() {
283        assert_eq!(DataMasker::apply(&MaskingRule::Name, "Alice"), "A****");
284    }
285
286    // ----- Address -----
287    #[test]
288    fn test_address_standard() {
289        let addr = "北京市海淀区中关村大街1号";
290        let masked = DataMasker::apply(&MaskingRule::Address, addr);
291        // First 6 chars kept ("北京市海淀区"), the rest replaced with one `*` per char.
292        let expected = "北京市海淀区*******";
293        assert_eq!(masked, expected);
294        assert_eq!(masked.chars().count(), addr.chars().count());
295    }
296
297    #[test]
298    fn test_address_exactly_six_chars() {
299        let addr = "北京市海淀区";
300        assert_eq!(DataMasker::apply(&MaskingRule::Address, addr), "******");
301    }
302
303    #[test]
304    fn test_address_short() {
305        assert_eq!(DataMasker::apply(&MaskingRule::Address, "北京"), "**");
306    }
307
308    #[test]
309    fn test_address_empty() {
310        assert_eq!(DataMasker::apply(&MaskingRule::Address, ""), "");
311    }
312
313    // ----- Custom -----
314    #[test]
315    fn test_custom_prefix_suffix() {
316        let rule = MaskingRule::Custom("3,2".to_string());
317        assert_eq!(DataMasker::apply(&rule, "ABCDEFGHIJ"), "ABC*****IJ");
318    }
319
320    #[test]
321    fn test_custom_too_short() {
322        let rule = MaskingRule::Custom("4,4".to_string());
323        assert_eq!(DataMasker::apply(&rule, "ABC"), "***");
324    }
325
326    #[test]
327    fn test_custom_invalid_spec() {
328        let rule = MaskingRule::Custom("not_a_number".to_string());
329        assert_eq!(DataMasker::apply(&rule, "ABCDEF"), "***");
330    }
331
332    #[test]
333    fn test_custom_invalid_spec_two_parts() {
334        let rule = MaskingRule::Custom("1,2,3".to_string());
335        assert_eq!(DataMasker::apply(&rule, "ABCDEF"), "***");
336    }
337
338    #[test]
339    fn test_custom_empty_value() {
340        let rule = MaskingRule::Custom("2,2".to_string());
341        assert_eq!(DataMasker::apply(&rule, ""), "***");
342    }
343
344    // ----- Unicode safety -----
345    #[test]
346    fn test_unicode_no_panic() {
347        // Mixing CJK + emoji + ascii - just verify no panic and contains stars.
348        let value = "你好🌍世界AB";
349        let masked = DataMasker::apply(&MaskingRule::Address, value);
350        assert!(masked.contains('*'));
351    }
352
353    #[test]
354    fn test_long_string() {
355        let value = "1".repeat(10000);
356        let masked = DataMasker::apply(&MaskingRule::Phone, &value);
357        // Should start with first 3, end with last 4, all stars in between.
358        assert!(masked.starts_with("111"));
359        assert!(masked.ends_with("1111"));
360        assert_eq!(masked.matches('*').count(), 10000 - 7);
361    }
362
363    #[test]
364    fn test_single_char_inputs() {
365        assert_eq!(DataMasker::apply(&MaskingRule::Phone, "1"), "***");
366        assert_eq!(DataMasker::apply(&MaskingRule::IdCard, "1"), "***");
367        assert_eq!(DataMasker::apply(&MaskingRule::BankCard, "1"), "***");
368        assert_eq!(DataMasker::apply(&MaskingRule::Name, "张"), "张");
369        assert_eq!(DataMasker::apply(&MaskingRule::Address, "张"), "*");
370    }
371}