pub const REDACTED: &str = "[redacted]";
pub const REDACTED_EMAIL: &str = "[email]";
pub const REDACTED_DEPTH: &str = "[redacted: nesting too deep]";
pub const MAX_JSON_DEPTH: u32 = 64;
pub trait Redactor: Send + Sync {
fn redact(&self, input: &str) -> String;
fn redact_json(&self, value: &mut serde_json::Value) {
let Some(_depth) = DepthGuard::enter() else {
*value = serde_json::Value::String(REDACTED_DEPTH.to_owned());
return;
};
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());
let mut next_suffix = 2u32;
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,
&mut next_suffix,
);
}
*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()
}
}
thread_local! {
static JSON_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
struct DepthGuard;
impl DepthGuard {
fn enter() -> Option<DepthGuard> {
JSON_DEPTH.with(|depth| {
if depth.get() >= MAX_JSON_DEPTH {
return None;
}
depth.set(depth.get() + 1);
Some(DepthGuard)
})
}
}
impl Drop for DepthGuard {
fn drop(&mut self) {
JSON_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
}
}
fn insert_without_clobbering(
map: &mut serde_json::Map<String, serde_json::Value>,
key: String,
value: serde_json::Value,
next_suffix: &mut u32,
) {
if !map.contains_key(&key) {
map.insert(key, value);
return;
}
while *next_suffix < u32::MAX {
let candidate = format!("{key}#{}", *next_suffix);
*next_suffix += 1;
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"));
}
}