use regex::{Regex, RegexBuilder};
use serde_json::Value;
use std::sync::Arc;
pub trait Redactor: Send + Sync {
fn redact(&self, v: &Value) -> Value;
}
pub struct DefaultRedactor {
patterns: Vec<(&'static str, Regex)>,
}
impl Default for DefaultRedactor {
fn default() -> Self {
Self::new()
}
}
impl DefaultRedactor {
#[must_use]
pub fn new() -> Self {
let patterns: Vec<(&'static str, Regex)> = klieo_pii_patterns::PATTERNS
.iter()
.map(|(label, src)| {
let re = RegexBuilder::new(src)
.case_insensitive(true)
.build()
.unwrap_or_else(|e| {
panic!("klieo-pii-patterns {label} failed to compile: {e}")
});
(*label, re)
})
.collect();
Self { patterns }
}
fn redact_string(&self, s: &str) -> String {
let mut out = s.to_string();
for (label, re) in &self.patterns {
out = re
.replace_all(&out, format!("[REDACTED:{label}]").as_str())
.into_owned();
}
out
}
}
impl Redactor for DefaultRedactor {
fn redact(&self, v: &Value) -> Value {
match v {
Value::String(s) => Value::String(self.redact_string(s)),
Value::Array(xs) => Value::Array(xs.iter().map(|x| self.redact(x)).collect()),
Value::Object(m) => {
let mut out = serde_json::Map::with_capacity(m.len());
for (k, val) in m {
out.insert(k.clone(), self.redact(val));
}
Value::Object(out)
}
_ => v.clone(),
}
}
}
impl klieo_core::redact::AuditRedactor for DefaultRedactor {
fn redact(&self, v: &Value) -> Value {
Redactor::redact(self, v)
}
}
pub type SharedRedactor = Arc<dyn Redactor>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_redactor_redacts_iban() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"note": "send to DE89370400440532013000"});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(!s.contains("DE89370400440532013000"));
assert!(s.contains("[REDACTED:IBAN]"));
}
#[test]
fn default_redactor_redacts_email() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"to": "alice@example.com"});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(!s.contains("alice@example.com"));
assert!(s.contains("[REDACTED:EMAIL]"));
}
#[test]
fn default_redactor_redacts_jwt() {
let r = DefaultRedactor::new();
let header = format!("ey{}", "JhbGciOiJIUzI1NiJ9");
let payload = format!("ey{}", "JzdWIiOiIxMjMifQ");
let jwt = format!("{header}.{payload}.abc");
let v = serde_json::json!({ "auth": jwt });
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(s.contains("[REDACTED:JWT]"));
}
#[test]
fn default_redactor_preserves_clean_strings() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"amount": 1234, "currency": "EUR"});
let out = r.redact(&v);
assert_eq!(out, v);
}
#[test]
fn default_redactor_does_not_redact_ordinary_prose_as_bic() {
let r = DefaultRedactor::new();
let v = serde_json::json!({
"justification": "High-risk: the system potentially affects individuals' employment decisions."
});
let s = serde_json::to_string(&r.redact(&v)).unwrap();
assert!(
!s.contains("[REDACTED:BIC]"),
"ordinary words must not redact as BIC: {s}"
);
assert!(
s.contains("individuals"),
"clean prose must be preserved verbatim: {s}"
);
let s2 =
serde_json::to_string(&r.redact(&serde_json::json!({"swift": "DEUTDEFF"}))).unwrap();
assert!(
s2.contains("[REDACTED:BIC]"),
"valid BIC must still redact: {s2}"
);
}
#[test]
fn default_redactor_redacts_lowercase_iban() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"note": "send to de89370400440532013000"});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(
!s.contains("de89370400440532013000"),
"lowercase IBAN must be redacted"
);
assert!(s.contains("[REDACTED:IBAN]"));
}
#[test]
fn default_redactor_does_not_redact_bare_11_digits() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"claim_id": "12345678901", "amount_cents": 12345678901u64});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(
s.contains("12345678901"),
"bare 11-digit values must NOT be redacted"
);
assert!(!s.contains("[REDACTED:TAXID_DE]"));
}
#[test]
fn default_redactor_masks_pii_through_audit_redactor_trait_object() {
let redactor: Arc<dyn klieo_core::AuditRedactor> = Arc::new(DefaultRedactor::new());
let v = serde_json::json!({"note": "send to DE89370400440532013000"});
let out = redactor.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(
!s.contains("DE89370400440532013000"),
"PII must not survive the AuditRedactor path"
);
assert!(s.contains("[REDACTED:IBAN]"));
}
#[test]
fn default_redactor_redacts_taxid_with_prefix() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"note": "Steuer-ID: 12345678901"});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(s.contains("[REDACTED:TAXID_DE]"));
assert!(!s.contains("12345678901"));
}
}