sql_splitter/redactor/strategy/
mask.rs

1//! Mask strategy - partial masking with pattern.
2
3use super::{RedactValue, Strategy, StrategyKind};
4use rand::Rng;
5
6/// Strategy that partially masks values using a pattern.
7///
8/// Pattern syntax:
9/// - `*` = replace with asterisk
10/// - `X` = keep original character
11/// - `#` = replace with random digit
12/// - Any other character = literal (e.g., `-`, `.`, `@`)
13#[derive(Debug, Clone)]
14pub struct MaskStrategy {
15    pattern: String,
16}
17
18impl MaskStrategy {
19    pub fn new(pattern: String) -> Self {
20        Self { pattern }
21    }
22
23    /// Apply the mask pattern to a value
24    fn mask_value(&self, value: &str, rng: &mut dyn rand::RngCore) -> String {
25        let chars: Vec<char> = value.chars().collect();
26        let mut result = String::with_capacity(self.pattern.len());
27
28        let mut value_idx = 0;
29
30        for pattern_char in self.pattern.chars() {
31            match pattern_char {
32                '*' => {
33                    result.push('*');
34                    value_idx += 1;
35                }
36                'X' => {
37                    if value_idx < chars.len() {
38                        result.push(chars[value_idx]);
39                    }
40                    value_idx += 1;
41                }
42                '#' => {
43                    result.push(char::from_digit(rng.gen_range(0..10), 10).unwrap());
44                    value_idx += 1;
45                }
46                c => {
47                    // Literal character (separator like -, space, etc.)
48                    result.push(c);
49                    // Advance value index if the original char matches
50                    if value_idx < chars.len() && chars[value_idx] == c {
51                        value_idx += 1;
52                    }
53                }
54            }
55        }
56
57        result
58    }
59}
60
61impl Strategy for MaskStrategy {
62    fn apply(&self, value: &RedactValue, rng: &mut dyn rand::RngCore) -> RedactValue {
63        match value {
64            RedactValue::Null => RedactValue::Null,
65            RedactValue::String(s) => RedactValue::String(self.mask_value(s, rng)),
66            RedactValue::Integer(i) => RedactValue::String(self.mask_value(&i.to_string(), rng)),
67            RedactValue::Bytes(b) => {
68                let s = String::from_utf8_lossy(b);
69                RedactValue::String(self.mask_value(&s, rng))
70            }
71        }
72    }
73
74    fn kind(&self) -> StrategyKind {
75        StrategyKind::Mask {
76            pattern: self.pattern.clone(),
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use rand::SeedableRng;
85
86    #[test]
87    fn test_mask_credit_card() {
88        // Keep last 4 digits
89        let strategy = MaskStrategy::new("****-****-****-XXXX".to_string());
90        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
91
92        let result = strategy.apply(
93            &RedactValue::String("4532-0151-1283-0366".to_string()),
94            &mut rng,
95        );
96        match result {
97            RedactValue::String(s) => {
98                assert!(s.starts_with("****-****-****-"));
99                assert!(s.ends_with("0366"));
100            }
101            _ => panic!("Expected String"),
102        }
103    }
104
105    #[test]
106    fn test_mask_email_first_char() {
107        // Keep first character
108        let strategy = MaskStrategy::new("X***@*****".to_string());
109        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
110
111        let result = strategy.apply(
112            &RedactValue::String("john@example.com".to_string()),
113            &mut rng,
114        );
115        match result {
116            RedactValue::String(s) => {
117                assert!(s.starts_with('j'));
118                assert!(s.contains('@'));
119            }
120            _ => panic!("Expected String"),
121        }
122    }
123
124    #[test]
125    fn test_mask_random_digits() {
126        let strategy = MaskStrategy::new("###-##-####".to_string());
127        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
128
129        let result = strategy.apply(&RedactValue::String("123-45-6789".to_string()), &mut rng);
130        match result {
131            RedactValue::String(s) => {
132                assert_eq!(s.len(), 11);
133                assert!(s.chars().nth(3) == Some('-'));
134                assert!(s.chars().nth(6) == Some('-'));
135            }
136            _ => panic!("Expected String"),
137        }
138    }
139
140    #[test]
141    fn test_mask_null() {
142        let strategy = MaskStrategy::new("****".to_string());
143        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
144
145        let result = strategy.apply(&RedactValue::Null, &mut rng);
146        assert!(matches!(result, RedactValue::Null));
147    }
148}