Skip to main content

mask_pii/
lib.rs

1/// A configurable masker for common PII such as emails and phone numbers.
2pub struct Masker {
3    mask_email: bool,
4    mask_phone: bool,
5    mask_char: char,
6}
7
8impl Default for Masker {
9    fn default() -> Self {
10        Self::new()
11    }
12}
13
14impl Masker {
15    /// Create a new masker with all masks disabled by default.
16    pub fn new() -> Self {
17        Self {
18            mask_email: false,
19            mask_phone: false,
20            mask_char: '*',
21        }
22    }
23
24    /// Enable email address masking.
25    pub fn mask_emails(mut self) -> Self {
26        self.mask_email = true;
27        self
28    }
29
30    /// Enable phone number masking.
31    pub fn mask_phones(mut self) -> Self {
32        self.mask_phone = true;
33        self
34    }
35
36    /// Set the character used for masking.
37    pub fn with_mask_char(mut self, c: char) -> Self {
38        self.mask_char = c;
39        self
40    }
41
42    /// Process input text and mask enabled PII patterns.
43    pub fn process(&self, input: &str) -> String {
44        let mut result = input.to_string();
45
46        if self.mask_email {
47            result = mask_emails_in_text(&result, self.mask_char);
48        }
49
50        if self.mask_phone {
51            result = mask_phones_in_text(&result, self.mask_char);
52        }
53
54        result
55    }
56}
57
58fn mask_emails_in_text(input: &str, mask_char: char) -> String {
59    let bytes = input.as_bytes();
60    let len = bytes.len();
61    let mut output = String::with_capacity(input.len());
62    let mut last = 0;
63    let mut i = 0;
64
65    while i < len {
66        if bytes[i] == b'@' {
67            let mut local_start = i;
68            while local_start > 0 && is_local_byte(bytes[local_start - 1]) {
69                local_start -= 1;
70            }
71            let local_end = i;
72
73            let domain_start = i + 1;
74            let mut domain_end = domain_start;
75            while domain_end < len && is_domain_byte(bytes[domain_end]) {
76                domain_end += 1;
77            }
78
79            if local_start < local_end && domain_start < domain_end {
80                let mut candidate_end = domain_end;
81                let mut matched_domain_end = None;
82                while candidate_end > domain_start {
83                    let domain = &input[domain_start..candidate_end];
84                    if is_valid_domain(domain) {
85                        matched_domain_end = Some(candidate_end);
86                        break;
87                    }
88                    candidate_end -= 1;
89                }
90
91                if let Some(valid_end) = matched_domain_end {
92                    let local = &input[local_start..local_end];
93                    let domain = &input[domain_start..valid_end];
94                    output.push_str(&input[last..local_start]);
95                    output.push_str(&mask_local(local, mask_char));
96                    output.push('@');
97                    output.push_str(domain);
98                    last = valid_end;
99                    i = valid_end;
100                    continue;
101                }
102            }
103        }
104
105        i += 1;
106    }
107
108    output.push_str(&input[last..]);
109    output
110}
111
112fn mask_phones_in_text(input: &str, mask_char: char) -> String {
113    let bytes = input.as_bytes();
114    let len = bytes.len();
115    let mut output = String::with_capacity(input.len());
116    let mut last = 0;
117    let mut i = 0;
118
119    while i < len {
120        if is_phone_start(bytes[i]) {
121            let mut end = i;
122            while end < len && is_phone_char(bytes[end]) {
123                end += 1;
124            }
125
126            let mut digit_count = 0;
127            let mut last_digit_index = None;
128            for idx in i..end {
129                if bytes[idx].is_ascii_digit() {
130                    digit_count += 1;
131                    last_digit_index = Some(idx);
132                }
133            }
134
135            if let Some(last_digit) = last_digit_index {
136                let candidate_end = last_digit + 1;
137                if digit_count >= 5 {
138                    let candidate = &input[i..candidate_end];
139                    output.push_str(&input[last..i]);
140                    output.push_str(&mask_phone_candidate(candidate, mask_char));
141                    last = candidate_end;
142                    i = candidate_end;
143                    continue;
144                }
145            }
146
147            i = end;
148            continue;
149        }
150
151        i += 1;
152    }
153
154    output.push_str(&input[last..]);
155    output
156}
157
158fn mask_local(local: &str, mask_char: char) -> String {
159    let len = local.len();
160    if len > 1 {
161        let mut result = String::with_capacity(len);
162        let first = local.as_bytes()[0] as char;
163        result.push(first);
164        for _ in 1..len {
165            result.push(mask_char);
166        }
167        result
168    } else {
169        mask_char.to_string()
170    }
171}
172
173fn mask_phone_candidate(candidate: &str, mask_char: char) -> String {
174    let bytes = candidate.as_bytes();
175    let digit_count = bytes.iter().filter(|b| b.is_ascii_digit()).count();
176    let mut current_index = 0;
177    let mut result = String::with_capacity(candidate.len());
178
179    for &b in bytes {
180        if b.is_ascii_digit() {
181            current_index += 1;
182            if digit_count > 4 && current_index <= digit_count - 4 {
183                result.push(mask_char);
184            } else {
185                result.push(b as char);
186            }
187        } else {
188            result.push(b as char);
189        }
190    }
191
192    result
193}
194
195fn is_local_byte(byte: u8) -> bool {
196    matches!(
197        byte,
198        b'a'..=b'z'
199            | b'A'..=b'Z'
200            | b'0'..=b'9'
201            | b'.'
202            | b'_'
203            | b'%'
204            | b'+'
205            | b'-'
206    )
207}
208
209fn is_domain_byte(byte: u8) -> bool {
210    matches!(
211        byte,
212        b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.'
213    )
214}
215
216fn is_valid_domain(domain: &str) -> bool {
217    if domain.starts_with('.') || domain.ends_with('.') {
218        return false;
219    }
220
221    let parts: Vec<&str> = domain.split('.').collect();
222    if parts.len() < 2 {
223        return false;
224    }
225
226    for part in &parts {
227        if part.is_empty() {
228            return false;
229        }
230        if part.starts_with('-') || part.ends_with('-') {
231            return false;
232        }
233        if !part
234            .as_bytes()
235            .iter()
236            .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
237        {
238            return false;
239        }
240    }
241
242    let tld = parts.last().unwrap();
243    if tld.len() < 2 || !tld.as_bytes().iter().all(|b| b.is_ascii_alphabetic()) {
244        return false;
245    }
246
247    true
248}
249
250fn is_phone_start(byte: u8) -> bool {
251    byte.is_ascii_digit() || byte == b'+' || byte == b'('
252}
253
254fn is_phone_char(byte: u8) -> bool {
255    byte.is_ascii_digit() || matches!(byte, b' ' | b'-' | b'(' | b')' | b'+')
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn assert_cases(masker: &Masker, cases: &[(&str, &str)]) {
263        for (input, expected) in cases {
264            assert_eq!(masker.process(input), *expected);
265        }
266    }
267
268    #[test]
269    fn test_email_basic_cases() {
270        let masker = Masker::new().mask_emails();
271        assert_cases(
272            &masker,
273            &[
274                ("alice@example.com", "a****@example.com"),
275                ("a@b.com", "*@b.com"),
276                ("ab@example.com", "a*@example.com"),
277                ("a.b+c_d@example.co.jp", "a******@example.co.jp"),
278            ],
279        );
280    }
281
282    #[test]
283    fn test_email_mixed_text() {
284        let masker = Masker::new().mask_emails();
285        assert_cases(
286            &masker,
287            &[
288                ("Contact: alice@example.com.", "Contact: a****@example.com."),
289                (
290                    "alice@example.com and bob@example.org",
291                    "a****@example.com and b**@example.org",
292                ),
293            ],
294        );
295    }
296
297    #[test]
298    fn test_email_edge_cases() {
299        let masker = Masker::new().mask_emails();
300        assert_cases(
301            &masker,
302            &[
303                ("alice@example", "alice@example"),
304                ("alice@localhost", "alice@localhost"),
305                ("alice@@example.com", "alice@@example.com"),
306                (
307                    "first.last+tag@sub.domain.com",
308                    "f*************@sub.domain.com",
309                ),
310            ],
311        );
312    }
313
314    #[test]
315    fn test_phone_basic_formats() {
316        let masker = Masker::new().mask_phones();
317        assert_cases(
318            &masker,
319            &[
320                ("090-1234-5678", "***-****-5678"),
321                ("Call (555) 123-4567", "Call (***) ***-4567"),
322                ("Intl: +81 3 1234 5678", "Intl: +** * **** 5678"),
323                ("+1 (800) 123-4567", "+* (***) ***-4567"),
324            ],
325        );
326    }
327
328    #[test]
329    fn test_phone_short_and_boundary_lengths() {
330        let masker = Masker::new().mask_phones();
331        assert_cases(
332            &masker,
333            &[("1234", "1234"), ("12345", "*2345"), ("12-3456", "**-3456")],
334        );
335    }
336
337    #[test]
338    fn test_phone_mixed_text() {
339        let masker = Masker::new().mask_phones();
340        assert_cases(
341            &masker,
342            &[
343                (
344                    "Tel: 090-1234-5678 ext. 99",
345                    "Tel: ***-****-5678 ext. 99",
346                ),
347                (
348                    "Numbers: 111-2222 and 333-4444",
349                    "Numbers: ***-2222 and ***-4444",
350                ),
351            ],
352        );
353    }
354
355    #[test]
356    fn test_phone_edge_cases() {
357        let masker = Masker::new().mask_phones();
358        assert_cases(
359            &masker,
360            &[("abcdef", "abcdef"), ("+", "+"), ("(12) 345 678", "(**) **5 678")],
361        );
362    }
363
364    #[test]
365    fn test_combined_masking() {
366        let masker = Masker::new().mask_emails().mask_phones();
367        assert_cases(
368            &masker,
369            &[
370                (
371                    "Contact: alice@example.com or 090-1234-5678.",
372                    "Contact: a****@example.com or ***-****-5678.",
373                ),
374                (
375                    "Email bob@example.org, phone +1 (800) 123-4567",
376                    "Email b**@example.org, phone +* (***) ***-4567",
377                ),
378            ],
379        );
380    }
381
382    #[test]
383    fn test_custom_mask_character() {
384        let email_masker = Masker::new().mask_emails().with_mask_char('#');
385        let phone_masker = Masker::new().mask_phones().with_mask_char('#');
386        let combined = Masker::new().mask_emails().mask_phones().with_mask_char('#');
387
388        assert_cases(&email_masker, &[("alice@example.com", "a####@example.com")]);
389        assert_cases(&phone_masker, &[("090-1234-5678", "###-####-5678")]);
390        assert_eq!(
391            combined.process("Contact: alice@example.com or 090-1234-5678."),
392            "Contact: a####@example.com or ###-####-5678."
393        );
394    }
395
396    #[test]
397    fn test_masker_configuration() {
398        let input = "alice@example.com 090-1234-5678";
399
400        let passthrough = Masker::new();
401        assert_eq!(passthrough.process(input), input);
402
403        let email_only = Masker::new().mask_emails();
404        assert_eq!(
405            email_only.process(input),
406            "a****@example.com 090-1234-5678"
407        );
408
409        let phone_only = Masker::new().mask_phones();
410        assert_eq!(
411            phone_only.process(input),
412            "alice@example.com ***-****-5678"
413        );
414
415        let both = Masker::new().mask_emails().mask_phones();
416        assert_eq!(both.process(input), "a****@example.com ***-****-5678");
417    }
418
419    #[test]
420    fn test_non_ascii_text_is_preserved() {
421        let masker = Masker::new().mask_emails().mask_phones();
422        let input = "連絡先: alice@example.com と 090-1234-5678";
423        let expected = "連絡先: a****@example.com と ***-****-5678";
424        assert_eq!(masker.process(input), expected);
425    }
426}