use crate::json::JsonValue;
use crate::sha256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Redaction {
Mask,
Hash,
Remove,
}
pub fn redact(packet: &JsonValue, fields: &[&str], how: Redaction) -> JsonValue {
let mut result = packet.clone();
if let JsonValue::Object(members) = &mut result {
for (k, v) in members.iter_mut() {
if k == "fields" {
*v = redact_fields(v, fields, how);
}
}
}
result
}
fn redact_fields(node: &JsonValue, names: &[&str], how: Redaction) -> JsonValue {
let members = match node {
JsonValue::Object(m) => m,
_ => return node.clone(),
};
let mut out: Vec<(String, JsonValue)> = Vec::with_capacity(members.len());
for (key, val) in members {
let is_group = matches!(val, JsonValue::Object(_)) && val.get("fields").is_some();
let target = names.iter().any(|n| n == key);
if target {
match how {
Redaction::Remove => continue, Redaction::Mask | Redaction::Hash => {
out.push((key.clone(), redact_member(val, how)));
}
}
continue;
}
if is_group {
if let JsonValue::Object(gm) = val {
let mut new_gm = Vec::with_capacity(gm.len());
for (gk, gv) in gm {
if gk == "fields" {
new_gm.push((gk.clone(), redact_fields(gv, names, how)));
} else {
new_gm.push((gk.clone(), gv.clone()));
}
}
out.push((key.clone(), JsonValue::Object(new_gm)));
continue;
}
} else if let JsonValue::Object(inner) = val {
if val.get("value").is_none() && val.get("raw_hex").is_none() {
let _ = inner; out.push((key.clone(), redact_fields(val, names, how)));
continue;
}
}
out.push((key.clone(), val.clone()));
}
JsonValue::Object(out)
}
fn redact_member(val: &JsonValue, how: Redaction) -> JsonValue {
if let JsonValue::Object(members) = val {
if val.get("value").is_some() || val.get("raw_hex").is_some() {
let mut out = Vec::with_capacity(members.len());
for (k, v) in members {
match k.as_str() {
"value" => out.push((k.clone(), apply(v, how))),
"raw_hex" => out.push((k.clone(), JsonValue::str("REDACTED"))),
_ => out.push((k.clone(), v.clone())),
}
}
return JsonValue::Object(out);
}
}
apply(val, how)
}
fn apply(v: &JsonValue, how: Redaction) -> JsonValue {
match how {
Redaction::Mask => JsonValue::str("****"),
Redaction::Hash => {
let bytes = match v {
JsonValue::String(s) => s.clone().into_bytes(),
JsonValue::Number(n) => n.clone().into_bytes(),
other => crate::json::to_string(other).into_bytes(),
};
JsonValue::str(format!("sha256:{}", sha256::hex_digest(&bytes)))
}
Redaction::Remove => JsonValue::Null, }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json::to_string;
fn compact() -> JsonValue {
JsonValue::Object(vec![
("record".into(), JsonValue::str("CUST")),
(
"fields".into(),
JsonValue::Object(vec![
("NAME".into(), JsonValue::str("JOHN")),
("SSN".into(), JsonValue::str("123456789")),
]),
),
])
}
#[test]
fn mask_replaces_value() {
let r = redact(&compact(), &["SSN"], Redaction::Mask);
assert_eq!(r.get("fields").unwrap().get("SSN").unwrap().as_str(), Some("****"));
assert_eq!(r.get("fields").unwrap().get("NAME").unwrap().as_str(), Some("JOHN"));
}
#[test]
fn hash_is_stable_token() {
let r = redact(&compact(), &["SSN"], Redaction::Hash);
let t = r.get("fields").unwrap().get("SSN").unwrap().as_str().unwrap();
assert!(t.starts_with("sha256:"));
let r2 = redact(&compact(), &["SSN"], Redaction::Hash);
assert_eq!(to_string(&r), to_string(&r2));
}
#[test]
fn remove_drops_member() {
let r = redact(&compact(), &["SSN"], Redaction::Remove);
assert!(r.get("fields").unwrap().get("SSN").is_none());
assert!(r.get("fields").unwrap().get("NAME").is_some());
}
#[test]
fn redacts_audit_detail_value_and_raw_hex() {
let packet = JsonValue::Object(vec![
("record".into(), JsonValue::str("R")),
(
"fields".into(),
JsonValue::Object(vec![(
"SSN".into(),
JsonValue::Object(vec![
("value".into(), JsonValue::str("123456789")),
("raw_hex".into(), JsonValue::str("313233")),
]),
)]),
),
]);
let r = redact(&packet, &["SSN"], Redaction::Mask);
let ssn = r.get("fields").unwrap().get("SSN").unwrap();
assert_eq!(ssn.get("value").unwrap().as_str(), Some("****"));
assert_eq!(ssn.get("raw_hex").unwrap().as_str(), Some("REDACTED"));
}
}