pub const REDACTED: &str = "[redacted]";
pub const REDACTED_EMAIL: &str = "[email]";
pub trait Redactor: Send + Sync {
fn redact(&self, input: &str) -> String;
fn redact_json(&self, value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
let red = self.redact(s);
if red != *s {
*s = red;
}
}
serde_json::Value::Array(items) => {
for item in items {
self.redact_json(item);
}
}
serde_json::Value::Object(map) => {
let mut rebuilt = serde_json::Map::with_capacity(map.len());
for (key, mut v) in std::mem::take(map) {
self.redact_value_for_key(&key, &mut v);
insert_without_clobbering(&mut rebuilt, self.redact_key(&key), v);
}
*map = rebuilt;
}
_ => {}
}
}
fn redact_value_for_key(&self, _key: &str, value: &mut serde_json::Value) {
self.redact_json(value);
}
fn redact_key(&self, key: &str) -> String {
key.to_owned()
}
}
fn insert_without_clobbering(
map: &mut serde_json::Map<String, serde_json::Value>,
key: String,
value: serde_json::Value,
) {
if !map.contains_key(&key) {
map.insert(key, value);
return;
}
for n in 2u32.. {
let candidate = format!("{key}#{n}");
if !map.contains_key(&candidate) {
map.insert(candidate, value);
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MaskDigits;
impl Redactor for MaskDigits {
fn redact(&self, input: &str) -> String {
input
.chars()
.map(|c| if c.is_ascii_digit() { '#' } else { c })
.collect()
}
}
#[test]
fn redact_json_walks_nested_strings_only() {
let mut v = serde_json::json!({
"path": "/home/u42/secret",
"page_id": 828,
"nested": ["tok-99", { "k": "v7" }],
});
MaskDigits.redact_json(&mut v);
assert_eq!(v["path"], "/home/u##/secret");
assert_eq!(v["page_id"], 828);
assert_eq!(v["nested"][0], "tok-##");
assert_eq!(v["nested"][1]["k"], "v#");
}
#[test]
fn redact_json_preserves_key_order_and_membership() {
let mut v = serde_json::json!({ "b": "2", "a": "1" });
MaskDigits.redact_json(&mut v);
assert_eq!(v["a"], "#");
assert_eq!(v["b"], "#");
assert_eq!(v.as_object().unwrap().len(), 2);
}
struct KeyCollider;
impl Redactor for KeyCollider {
fn redact(&self, input: &str) -> String {
input.to_owned()
}
fn redact_key(&self, _key: &str) -> String {
"same".to_owned()
}
}
#[test]
fn colliding_redacted_keys_are_suffixed_rather_than_dropped() {
let mut v = serde_json::json!({ "a": 1, "b": 2, "c": 3 });
KeyCollider.redact_json(&mut v);
let obj = v.as_object().unwrap();
assert_eq!(obj.len(), 3, "no member may be silently discarded");
assert!(obj.contains_key("same"));
assert!(obj.contains_key("same#2"));
assert!(obj.contains_key("same#3"));
}
}