Skip to main content

alun_utils/
mask.rs

1//! 敏感信息脱敏工具
2
3use regex::Regex;
4use serde_json::{Map, Value};
5use std::sync::LazyLock;
6
7static MOBILE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^1[3-9]\d{9}$").unwrap());
8static ID_CARD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d{17}[\dXx]$").unwrap());
9static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\w.\-]+@[\w.\-]+\.\w+$").unwrap());
10
11/// 递归对 JSON 进行脱敏
12///
13/// 遍历 JSON 对象的所有字段,对匹配 `sensitive_fields` 的字段名、
14/// 或字段值内容匹配手机号/身份证/邮箱格式的值进行脱敏。
15///
16/// # 参数
17///
18/// * `value` - 待脱敏的 JSON
19/// * `sensitive_fields` - 敏感字段名列表
20pub fn mask_json_value(value: Value, sensitive_fields: &[&str]) -> Value {
21    match value {
22        Value::Object(map) => mask_object(map, sensitive_fields),
23        Value::Array(arr) => Value::Array(arr.into_iter().map(|v| mask_json_value(v, sensitive_fields)).collect()),
24        other => mask_scalar_if_needed(other),
25    }
26}
27
28fn mask_object(map: Map<String, Value>, sensitive_fields: &[&str]) -> Value {
29    let mut masked = Map::new();
30    for (key, val) in map {
31        let is_sensitive = sensitive_fields.iter().any(|f| {
32            f.eq_ignore_ascii_case(&key)
33        });
34        if is_sensitive {
35            masked.insert(key, Value::String("****".into()));
36        } else {
37            masked.insert(key, mask_json_value(val, sensitive_fields));
38        }
39    }
40    Value::Object(masked)
41}
42
43fn mask_scalar_if_needed(val: Value) -> Value {
44    match &val {
45        Value::String(s) => {
46            let s = s.trim();
47            if s.is_empty() { return val; }
48            // 手机号:正则已校验 11 位纯数字 1[3-9]\d{9},不再依赖 starts_with('1') 避免误匹配多字节字符
49            if MOBILE_RE.is_match(s) {
50                return Value::String(mask_mobile(s));
51            }
52            if s.len() >= 15 && s.len() <= 20 && contains_alpha_numeric(s) {
53                if ID_CARD_RE.is_match(s) {
54                    return Value::String(mask_id_card(s));
55                }
56            }
57            if EMAIL_RE.is_match(s) {
58                return Value::String(mask_email(s));
59            }
60            val
61        }
62        _ => val,
63    }
64}
65
66fn contains_alpha_numeric(s: &str) -> bool {
67    s.chars().any(|c| c.is_ascii_digit())
68}
69
70fn mask_mobile(s: &str) -> String {
71    // 使用字符切片避免在多字节字符上越界(防御性编程)
72    let chars: Vec<char> = s.chars().collect();
73    if chars.len() < 7 { return s.to_string(); }
74    let prefix: String = chars[..3].iter().collect();
75    let suffix: String = chars[chars.len() - 4..].iter().collect();
76    format!("{}****{}", prefix, suffix)
77}
78
79fn mask_id_card(s: &str) -> String {
80    let chars: Vec<char> = s.chars().collect();
81    if chars.len() < 8 { return s.to_string(); }
82    let prefix: String = chars[..4].iter().collect();
83    let suffix: String = chars[chars.len() - 4..].iter().collect();
84    format!("{}****{}", prefix, suffix)
85}
86
87fn mask_email(s: &str) -> String {
88    if let Some(at) = s.find('@') {
89        let prefix = &s[..at];
90        if prefix.len() <= 2 { format!("*{}", &s[at..]) }
91        else { format!("{}***{}", &prefix[..1], &s[at..]) }
92    } else { s.to_string() }
93}
94
95/// 敏感信息脱敏工具 —— 对手机号、邮箱、身份证、银行卡、人名等进行部分遮盖
96///
97/// # 示例
98///
99/// ```ignore
100/// use alun_utils::Mask;
101/// assert_eq!(Mask::mobile("13812345678"), "138****5678");
102/// assert_eq!(Mask::email("alice@mail.com"), "a***@mail.com");
103/// ```
104pub struct Mask;
105
106impl Mask {
107    /// 手机号脱敏:保留前3后4位
108    pub fn mobile(phone: &str) -> String { mask_mobile(phone) }
109    /// 邮箱脱敏:保留首字符和域名部分
110    pub fn email(email: &str) -> String { mask_email(email) }
111    /// 身份证脱敏:保留前4后4位
112    pub fn id_card(id: &str) -> String { mask_id_card(id) }
113    /// 银行卡脱敏:保留前4后4位,中间用 ` **** ` 分隔
114    pub fn bank_card(card: &str) -> String {
115        if card.len() < 8 { return card.to_string(); }
116        format!("{} **** {}", &card[..4], &card[card.len()-4..])
117    }
118    /// 姓名脱敏:保留首字符,其余用 `*` 代替
119    pub fn name(name: &str) -> String {
120        let chars: Vec<char> = name.chars().collect();
121        if chars.len() <= 1 { return name.to_string(); }
122        let mut result = String::new();
123        result.push(chars[0]);
124        for _ in 1..chars.len() { result.push('*'); }
125        result
126    }
127    /// 用户ID脱敏:保留前2后2字符
128    pub fn user_id(id: &str) -> String {
129        if id.len() <= 4 { return "****".to_string(); }
130        format!("{}****{}", &id[..2], &id[id.len()-2..])
131    }
132    /// 密码脱敏:固定返回 `******`
133    pub fn password(_password: &str) -> String {
134        "******".to_string()
135    }
136    /// 地址脱敏:保留前6个字符(省市区),其余用 `****` 代替
137    pub fn address(addr: &str) -> String {
138        if addr.len() <= 6 { return "****".to_string(); }
139        format!("{}****", &addr[..addr.len().min(6)])
140    }
141    /// 车牌号脱敏:保留首字符(省份简称)和末位,中间用 `****` 代替
142    pub fn license_plate(plate: &str) -> String {
143        let chars: Vec<char> = plate.chars().collect();
144        if chars.len() < 3 { return "****".to_string(); }
145        format!("{}****{}", chars[0], chars[chars.len()-1])
146    }
147    /// 根据数据类型自动选择脱敏方式
148    ///
149    /// # 参数
150    /// - `data_type`: 数据类型(user_id/mobile/password/address/bank_card/license_plate/email/id_card/name)
151    /// - `value`: 待脱敏的值
152    pub fn mask_by_type(data_type: &str, value: &str) -> String {
153        match data_type {
154            "user_id" => Self::user_id(value),
155            "mobile" => Self::mobile(value),
156            "password" => Self::password(value),
157            "address" => Self::address(value),
158            "bank_card" => Self::bank_card(value),
159            "license_plate" => Self::license_plate(value),
160            "email" => Self::email(value),
161            "id_card" => Self::id_card(value),
162            "name" => Self::name(value),
163            _ => format!("{}***{}", &value[..value.len().min(2)], &value[value.len()-value.len().min(2)..]),
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    #[test]
172    fn test_mobile() { assert_eq!(Mask::mobile("13812345678"), "138****5678"); }
173    #[test]
174    fn test_email() { assert_eq!(Mask::email("alice@mail.com"), "a***@mail.com"); }
175}