stateset_authz/
redaction.rs1use std::collections::HashSet;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RedactionConfig {
21 fields: HashSet<String>,
22 patterns: Vec<String>,
23}
24
25impl RedactionConfig {
26 #[must_use]
28 pub fn empty() -> Self {
29 Self { fields: HashSet::new(), patterns: Vec::new() }
30 }
31
32 #[must_use]
34 pub fn with_fields(fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
35 Self { fields: fields.into_iter().map(Into::into).collect(), patterns: Vec::new() }
36 }
37
38 pub fn add_field(&mut self, field: impl Into<String>) {
40 self.fields.insert(field.into());
41 }
42
43 pub fn add_pattern(&mut self, pattern: impl Into<String>) {
45 self.patterns.push(pattern.into());
46 }
47
48 #[must_use]
50 pub const fn fields(&self) -> &HashSet<String> {
51 &self.fields
52 }
53
54 #[must_use]
56 pub fn patterns(&self) -> &[String] {
57 &self.patterns
58 }
59
60 #[must_use]
62 pub fn should_redact(&self, field_name: &str) -> bool {
63 let lower = field_name.to_ascii_lowercase();
64
65 if self.fields.iter().any(|f| f.to_ascii_lowercase() == lower) {
67 return true;
68 }
69
70 for pattern in &self.patterns {
72 if lower.contains(&pattern.to_ascii_lowercase()) {
73 return true;
74 }
75 }
76
77 false
78 }
79}
80
81impl Default for RedactionConfig {
82 fn default() -> Self {
84 Self {
85 fields: [
86 "password",
87 "secret",
88 "token",
89 "api_key",
90 "authorization",
91 "credit_card",
92 "ssn",
93 ]
94 .into_iter()
95 .map(String::from)
96 .collect(),
97 patterns: Vec::new(),
98 }
99 }
100}
101
102pub fn redact_value(value: &mut serde_json::Value, config: &RedactionConfig) {
122 match value {
123 serde_json::Value::Object(map) => {
124 let keys_to_redact: Vec<String> =
125 map.keys().filter(|k| config.should_redact(k)).cloned().collect();
126
127 for key in keys_to_redact {
128 if let Some(v) = map.get_mut(&key) {
129 *v = serde_json::Value::String("[REDACTED]".to_owned());
130 }
131 }
132
133 for (key, v) in map.iter_mut() {
135 if !config.should_redact(key) {
136 redact_value(v, config);
137 }
138 }
139 }
140 serde_json::Value::Array(arr) => {
141 for item in arr {
142 redact_value(item, config);
143 }
144 }
145 _ => {}
146 }
147}
148
149#[must_use]
163pub fn redact_string(s: &str) -> String {
164 let len = s.chars().count();
165 if len <= 6 {
166 return "***".to_owned();
167 }
168
169 let prefix: String = s.chars().take(3).collect();
170 let suffix: String = s.chars().skip(len - 3).collect();
171 format!("{prefix}***{suffix}")
172}
173
174#[cfg(test)]
175mod tests {
176 use serde_json::json;
177
178 use super::*;
179
180 #[test]
183 fn default_includes_standard_fields() {
184 let config = RedactionConfig::default();
185 assert!(config.should_redact("password"));
186 assert!(config.should_redact("secret"));
187 assert!(config.should_redact("token"));
188 assert!(config.should_redact("api_key"));
189 assert!(config.should_redact("authorization"));
190 assert!(config.should_redact("credit_card"));
191 assert!(config.should_redact("ssn"));
192 }
193
194 #[test]
195 fn default_does_not_redact_normal_fields() {
196 let config = RedactionConfig::default();
197 assert!(!config.should_redact("name"));
198 assert!(!config.should_redact("email"));
199 assert!(!config.should_redact("order_id"));
200 }
201
202 #[test]
203 fn case_insensitive_matching() {
204 let config = RedactionConfig::default();
205 assert!(config.should_redact("PASSWORD"));
206 assert!(config.should_redact("Password"));
207 assert!(config.should_redact("API_KEY"));
208 }
209
210 #[test]
211 fn custom_fields() {
212 let config = RedactionConfig::with_fields(["custom_secret", "my_token"]);
213 assert!(config.should_redact("custom_secret"));
214 assert!(config.should_redact("my_token"));
215 assert!(!config.should_redact("password")); }
217
218 #[test]
219 fn pattern_matching() {
220 let mut config = RedactionConfig::empty();
221 config.add_pattern("key");
222 assert!(config.should_redact("api_key"));
223 assert!(config.should_redact("secret_key_value"));
224 assert!(config.should_redact("KEY_ID"));
225 assert!(!config.should_redact("name"));
226 }
227
228 #[test]
229 fn empty_config_redacts_nothing() {
230 let config = RedactionConfig::empty();
231 assert!(!config.should_redact("password"));
232 assert!(!config.should_redact("anything"));
233 }
234
235 #[test]
236 fn add_field() {
237 let mut config = RedactionConfig::empty();
238 config.add_field("custom");
239 assert!(config.should_redact("custom"));
240 }
241
242 #[test]
245 fn redact_flat_object() {
246 let config = RedactionConfig::default();
247 let mut value = json!({
248 "name": "Alice",
249 "password": "secret123"
250 });
251
252 redact_value(&mut value, &config);
253 assert_eq!(value["name"], "Alice");
254 assert_eq!(value["password"], "[REDACTED]");
255 }
256
257 #[test]
258 fn redact_nested_object() {
259 let config = RedactionConfig::default();
260 let mut value = json!({
261 "user": {
262 "name": "Bob",
263 "auth": {
264 "token": "abc123",
265 "level": "admin"
266 }
267 }
268 });
269
270 redact_value(&mut value, &config);
271 assert_eq!(value["user"]["name"], "Bob");
272 assert_eq!(value["user"]["auth"]["token"], "[REDACTED]");
273 assert_eq!(value["user"]["auth"]["level"], "admin");
274 }
275
276 #[test]
277 fn redact_array_of_objects() {
278 let config = RedactionConfig::default();
279 let mut value = json!([
280 { "name": "A", "token": "x" },
281 { "name": "B", "token": "y" }
282 ]);
283
284 redact_value(&mut value, &config);
285 assert_eq!(value[0]["name"], "A");
286 assert_eq!(value[0]["token"], "[REDACTED]");
287 assert_eq!(value[1]["token"], "[REDACTED]");
288 }
289
290 #[test]
291 fn redact_deeply_nested() {
292 let config = RedactionConfig::default();
293 let mut value = json!({
294 "a": { "b": { "c": { "secret": "deep" } } }
295 });
296
297 redact_value(&mut value, &config);
298 assert_eq!(value["a"]["b"]["c"]["secret"], "[REDACTED]");
299 }
300
301 #[test]
302 fn redact_preserves_non_string_redacted_values() {
303 let config = RedactionConfig::default();
304 let mut value = json!({
305 "token": 12345,
306 "name": "test"
307 });
308
309 redact_value(&mut value, &config);
310 assert_eq!(value["token"], "[REDACTED]");
311 }
312
313 #[test]
314 fn redact_non_object_is_noop() {
315 let config = RedactionConfig::default();
316 let mut value = json!("just a string");
317 redact_value(&mut value, &config);
318 assert_eq!(value, json!("just a string"));
319 }
320
321 #[test]
322 fn redact_null_is_noop() {
323 let config = RedactionConfig::default();
324 let mut value = json!(null);
325 redact_value(&mut value, &config);
326 assert_eq!(value, json!(null));
327 }
328
329 #[test]
332 fn redact_string_normal() {
333 assert_eq!(redact_string("secret123"), "sec***123");
334 }
335
336 #[test]
337 fn redact_string_long() {
338 assert_eq!(redact_string("abcdefghij"), "abc***hij");
339 }
340
341 #[test]
342 fn redact_string_exactly_seven() {
343 assert_eq!(redact_string("1234567"), "123***567");
344 }
345
346 #[test]
347 fn redact_string_six_chars() {
348 assert_eq!(redact_string("123456"), "***");
349 }
350
351 #[test]
352 fn redact_string_short() {
353 assert_eq!(redact_string("ab"), "***");
354 }
355
356 #[test]
357 fn redact_string_empty() {
358 assert_eq!(redact_string(""), "***");
359 }
360
361 #[test]
362 fn redact_string_single_char() {
363 assert_eq!(redact_string("x"), "***");
364 }
365
366 #[test]
367 fn redact_string_unicode_safe() {
368 assert_eq!(redact_string("абвгдеёж"), "абв***еёж");
369 }
370
371 #[test]
374 fn config_serde_roundtrip() {
375 let config = RedactionConfig::default();
376 let json = serde_json::to_string(&config).unwrap();
377 let parsed: RedactionConfig = serde_json::from_str(&json).unwrap();
378 assert_eq!(parsed.fields().len(), config.fields().len());
379 }
380}