Skip to main content

bento_kit/
mask.rs

1//! Sensitive-data masking helpers.
2//!
3//! Each function takes a `&str` and returns a `String`. Inputs that don't
4//! match the expected shape are returned with a best-effort mask rather
5//! than panicking — these functions are intended for log output, where
6//! crashing is worse than over-masking.
7
8const MASK_CHAR: char = '*';
9
10fn star_string(n: usize) -> String {
11    MASK_CHAR.to_string().repeat(n)
12}
13
14/// Mask a Chinese mainland mobile number: `13812345678` → `138****5678`.
15///
16/// If the input is not exactly 11 digits, every char except the first and
17/// last is masked.
18pub 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
26/// Mask an email address: `alice@example.com` → `a***@example.com`.
27///
28/// If `@` is missing the whole input is masked except the first character.
29pub 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
41/// Mask a Chinese ID card number (15 or 18 digits/X):
42/// `110101199001011234` → `110101********1234`.
43pub 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
51/// Mask a bank card number, keeping the first 4 and last 4 digits.
52///
53/// `6225881234567890` → `6225********7890`.
54pub 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
61/// Mask an API token / secret. Shows the first 4 and last 4 characters,
62/// or fully stars out short inputs.
63///
64/// `sk-1234567890abcdef` → `sk-1**********cdef`.
65pub 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
72/// Mask a Chinese name.
73///
74/// - 1 char  → unchanged (nothing to mask).
75/// - 2 chars → keep the first, star the second: `张三` → `张*`.
76/// - 3+      → keep first and last, star the middle: `欧阳修远` → `欧**远`.
77pub 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
89/// Generic credential masking — port of `du-node-utils.maskSecret`.
90///
91/// Behavior:
92/// - `None`        → `"<none>"`
93/// - `Some("")`    → `"<empty>"`
94/// - Length ≤ `keep_chars * 2` → returned as-is (too short to safely mask)
95/// - Otherwise     → `first(keep_chars) + "***" + last(keep_chars)` —
96///   note the middle is **always exactly three stars**, regardless of
97///   the masked length, matching the Node implementation.
98///
99/// `keep_chars = 3` is a sensible default for API tokens / secrets.
100///
101/// ```
102/// use bento_kit::mask::mask_secret;
103/// assert_eq!(mask_secret(Some("vault:AES256:abcXYZ"), 3), "vau***XYZ");
104/// assert_eq!(mask_secret(Some("ab"), 3), "ab"); // too short to mask
105/// assert_eq!(mask_secret(None, 3), "<none>");
106/// assert_eq!(mask_secret(Some(""), 3), "<empty>");
107/// ```
108pub 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
124/// Generic helper: keep `prefix` chars at the start and `suffix` chars at
125/// the end, replacing the middle with stars (one star per masked char).
126///
127/// If the string is too short to satisfy `prefix + suffix`, the entire
128/// string is returned starred.
129pub 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        // Not 11 digits — generic masking.
153        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        // No @, treat as opaque string and mask everything but first.
175        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        // 6 prefix + 5 stars + 4 suffix = 15 chars.
191        assert_eq!(mask_id_card("123456789012345"), "123456*****2345");
192    }
193
194    #[test]
195    fn id_card_unexpected_length_falls_back() {
196        // Generic fallback keeps first + last, stars the middle.
197        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        // < 9 chars hits the fallback path.
209        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        // Each Chinese char is 3 bytes in UTF-8 — confirm we're counting chars.
252        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        // A long input still gets exactly "***" in the middle.
263        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        // len <= keep*2 → no masking
271        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        // 8 Chinese chars, keep 2 → "一二***七八"
289        assert_eq!(mask_secret(Some("一二三四五六七八"), 2), "一二***七八");
290    }
291}