1const MASK_CHAR: char = '*';
9
10fn star_string(n: usize) -> String {
11 MASK_CHAR.to_string().repeat(n)
12}
13
14pub fn mask_phone(phone: &str) -> String {
19 if phone.len() == 11 && phone.chars().all(|c| c.is_ascii_digit()) {
20 format!("{}****{}", &phone[..3], &phone[7..])
21 } else {
22 mask_middle(phone, 1, 1)
23 }
24}
25
26pub fn mask_email(email: &str) -> String {
30 let Some(at) = email.find('@') else {
31 return mask_middle(email, 1, 0);
32 };
33 let (local, domain) = email.split_at(at);
34 if local.is_empty() {
35 return email.to_string();
36 }
37 let first = local.chars().next().unwrap();
38 format!("{first}***{domain}")
39}
40
41pub fn mask_id_card(id: &str) -> String {
44 let len = id.chars().count();
45 if !(len == 15 || len == 18) {
46 return mask_middle(id, 1, 1);
47 }
48 mask_middle(id, 6, 4)
49}
50
51pub fn mask_bank_card(card: &str) -> String {
55 if card.chars().count() < 9 {
56 return mask_middle(card, 1, 1);
57 }
58 mask_middle(card, 4, 4)
59}
60
61pub fn mask_token(token: &str) -> String {
66 if token.chars().count() < 9 {
67 return star_string(token.chars().count());
68 }
69 mask_middle(token, 4, 4)
70}
71
72pub fn mask_name(name: &str) -> String {
78 let chars: Vec<char> = name.chars().collect();
79 match chars.len() {
80 0 | 1 => name.to_string(),
81 2 => format!("{}{MASK_CHAR}", chars[0]),
82 n => {
83 let middle = star_string(n - 2);
84 format!("{}{middle}{}", chars[0], chars[n - 1])
85 }
86 }
87}
88
89pub fn mask_secret(value: Option<&str>, keep_chars: usize) -> String {
109 let s = match value {
110 None => return "<none>".to_string(),
111 Some("") => return "<empty>".to_string(),
112 Some(v) => v,
113 };
114 let chars: Vec<char> = s.chars().collect();
115 let len = chars.len();
116 if len <= keep_chars * 2 {
117 return s.to_string();
118 }
119 let head: String = chars[..keep_chars].iter().collect();
120 let tail: String = chars[len - keep_chars..].iter().collect();
121 format!("{head}***{tail}")
122}
123
124pub fn mask_middle(s: &str, prefix: usize, suffix: usize) -> String {
130 let chars: Vec<char> = s.chars().collect();
131 let total = chars.len();
132 if total <= prefix + suffix {
133 return star_string(total);
134 }
135 let head: String = chars[..prefix].iter().collect();
136 let tail: String = chars[total - suffix..].iter().collect();
137 let middle = star_string(total - prefix - suffix);
138 format!("{head}{middle}{tail}")
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn phone_well_formed() {
147 assert_eq!(mask_phone("13812345678"), "138****5678");
148 }
149
150 #[test]
151 fn phone_malformed_falls_back_to_generic() {
152 assert_eq!(mask_phone("12345"), "1***5");
154 assert_eq!(mask_phone("138-1234-5678"), "1***********8");
155 }
156
157 #[test]
158 fn phone_empty_returns_empty() {
159 assert_eq!(mask_phone(""), "");
160 }
161
162 #[test]
163 fn email_typical() {
164 assert_eq!(mask_email("alice@example.com"), "a***@example.com");
165 }
166
167 #[test]
168 fn email_single_char_local() {
169 assert_eq!(mask_email("a@b.com"), "a***@b.com");
170 }
171
172 #[test]
173 fn email_no_at_sign_falls_back() {
174 assert_eq!(mask_email("not-an-email"), "n***********");
176 }
177
178 #[test]
179 fn email_starts_with_at_returned_as_is() {
180 assert_eq!(mask_email("@example.com"), "@example.com");
181 }
182
183 #[test]
184 fn id_card_18_digits() {
185 assert_eq!(mask_id_card("110101199001011234"), "110101********1234");
186 }
187
188 #[test]
189 fn id_card_15_digits() {
190 assert_eq!(mask_id_card("123456789012345"), "123456*****2345");
192 }
193
194 #[test]
195 fn id_card_unexpected_length_falls_back() {
196 assert_eq!(mask_id_card("123"), "1*3");
198 assert_eq!(mask_id_card("12345"), "1***5");
199 }
200
201 #[test]
202 fn bank_card_typical() {
203 assert_eq!(mask_bank_card("6225881234567890"), "6225********7890");
204 }
205
206 #[test]
207 fn bank_card_short() {
208 assert_eq!(mask_bank_card("12345678"), "1******8");
210 }
211
212 #[test]
213 fn token_typical() {
214 assert_eq!(mask_token("sk-1234567890abcdef"), "sk-1***********cdef");
215 }
216
217 #[test]
218 fn token_short_is_fully_starred() {
219 assert_eq!(mask_token("abc12345"), "********");
220 assert_eq!(mask_token(""), "");
221 }
222
223 #[test]
224 fn name_one_char_unchanged() {
225 assert_eq!(mask_name("张"), "张");
226 }
227
228 #[test]
229 fn name_two_chars() {
230 assert_eq!(mask_name("张三"), "张*");
231 }
232
233 #[test]
234 fn name_three_chars() {
235 assert_eq!(mask_name("张三丰"), "张*丰");
236 }
237
238 #[test]
239 fn name_four_chars() {
240 assert_eq!(mask_name("欧阳修远"), "欧**远");
241 }
242
243 #[test]
244 fn mask_middle_too_short_stars_everything() {
245 assert_eq!(mask_middle("abc", 2, 2), "***");
246 assert_eq!(mask_middle("ab", 1, 1), "**");
247 }
248
249 #[test]
250 fn mask_middle_unicode_is_char_based_not_byte_based() {
251 assert_eq!(mask_middle("一二三四五", 1, 1), "一***五");
253 }
254
255 #[test]
256 fn mask_secret_typical() {
257 assert_eq!(mask_secret(Some("vault:AES256:abcXYZ"), 3), "vau***XYZ");
258 }
259
260 #[test]
261 fn mask_secret_uses_three_stars_regardless_of_length() {
262 let long_secret = "a".repeat(50) + "END12345";
264 let masked = mask_secret(Some(&long_secret), 4);
265 assert_eq!(masked, "aaaa***2345");
266 }
267
268 #[test]
269 fn mask_secret_short_returned_as_is() {
270 assert_eq!(mask_secret(Some("abcdef"), 3), "abcdef");
272 assert_eq!(mask_secret(Some("ab"), 3), "ab");
273 }
274
275 #[test]
276 fn mask_secret_sentinels_for_none_and_empty() {
277 assert_eq!(mask_secret(None, 3), "<none>");
278 assert_eq!(mask_secret(Some(""), 3), "<empty>");
279 }
280
281 #[test]
282 fn mask_secret_keep_zero_stars_everything() {
283 assert_eq!(mask_secret(Some("hello"), 0), "***");
284 }
285
286 #[test]
287 fn mask_secret_unicode_safe() {
288 assert_eq!(mask_secret(Some("一二三四五六七八"), 2), "一二***七八");
290 }
291}