use serde::Serialize;
pub trait DomainContext {
fn domain_kind(&self) -> &'static str;
fn grouping_key(&self) -> String;
fn to_json(&self) -> serde_json::Value;
}
pub struct Adhoc<T: Serialize> {
pub kind: &'static str,
pub key: String,
pub value: T,
}
impl<T: Serialize> DomainContext for Adhoc<T> {
fn domain_kind(&self) -> &'static str {
self.kind
}
fn grouping_key(&self) -> String {
self.key.clone()
}
fn to_json(&self) -> serde_json::Value {
serde_json::to_value(&self.value).unwrap_or(serde_json::Value::Null)
}
}
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) => {
for (_k, v) in map.iter_mut() {
self.redact_json(v);
}
}
_ => {}
}
}
}
pub struct NoopRedactor;
impl Redactor for NoopRedactor {
fn redact(&self, input: &str) -> String {
input.to_owned()
}
}
pub struct BasicRedactor {
home: Option<String>,
}
impl BasicRedactor {
#[must_use]
pub fn new() -> Self {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.filter(|h| h.len() > 1);
BasicRedactor { home }
}
#[must_use]
pub fn home(mut self, home: impl Into<String>) -> Self {
self.home = Some(home.into());
self
}
}
impl Default for BasicRedactor {
fn default() -> Self {
Self::new()
}
}
const REDACTED: &str = "[redacted]";
const SECRET_KEYS: &[&str] = &[
"password",
"passwd",
"secret",
"token",
"api_key",
"apikey",
"authorization",
"auth",
"credential",
"private_key",
];
impl Redactor for BasicRedactor {
fn redact(&self, input: &str) -> String {
let mut out = match &self.home {
Some(home) => input.replace(home.as_str(), "~"),
None => input.to_owned(),
};
out = mask_secret_assignments(&out);
mask_emails(&out)
}
}
fn mask_secret_assignments(input: &str) -> String {
let lower = input.to_ascii_lowercase();
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < bytes.len() {
let sep = bytes[i];
if (sep == b'=' || sep == b':')
&& SECRET_KEYS.iter().any(|k| {
i.checked_sub(k.len()).is_some_and(|start| {
lower.get(start..i) == Some(*k)
&& (start == 0 || !bytes[start - 1].is_ascii_alphanumeric())
})
})
{
out.push(sep as char);
i += 1;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'"') {
out.push(bytes[i] as char);
i += 1;
}
if input[i..].starts_with(REDACTED) {
out.push_str(REDACTED);
i += REDACTED.len();
continue;
}
let value_start = i;
while i < bytes.len() && !is_value_terminator(bytes[i]) {
i += 1;
}
if i > value_start {
out.push_str(REDACTED);
}
continue;
}
let start = i;
i += 1;
while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
i += 1;
}
out.push_str(&input[start..i]);
}
out
}
fn is_value_terminator(b: u8) -> bool {
matches!(b, b' ' | b'"' | b',' | b'}' | b')' | b']' | b'\n' | b'\t')
}
fn mask_emails(input: &str) -> String {
if !input.contains('@') {
return input.to_owned();
}
let mut out = String::with_capacity(input.len());
for (i, token) in input.split(' ').enumerate() {
if i > 0 {
out.push(' ');
}
if looks_like_email(token) {
out.push_str("[email]");
} else {
out.push_str(token);
}
}
out
}
fn looks_like_email(token: &str) -> bool {
let trimmed = token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '@' && c != '.');
let Some((local, domain)) = trimmed.split_once('@') else {
return false;
};
!local.is_empty()
&& domain.contains('.')
&& !domain.starts_with('.')
&& !domain.ends_with('.')
&& domain
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
}
#[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 adhoc_context_serializes_value() {
let ctx = Adhoc {
kind: "store.test",
key: "kind=0x09".to_owned(),
value: serde_json::json!({ "page_id": 6044 }),
};
assert_eq!(ctx.domain_kind(), "store.test");
assert_eq!(ctx.grouping_key(), "kind=0x09");
assert_eq!(ctx.to_json()["page_id"], 6044);
}
#[test]
fn basic_redactor_strips_the_home_directory_but_keeps_structure() {
let r = BasicRedactor::new().home("/home/ada");
assert_eq!(
r.redact("failed to open /home/ada/db/main.db"),
"failed to open ~/db/main.db",
"the path shape a maintainer needs survives; the username does not"
);
}
#[test]
fn basic_redactor_masks_secret_assignments() {
let r = BasicRedactor::new().home("/nonexistent");
assert_eq!(
r.redact("connect failed token=sk-abc123 retries=3"),
"connect failed token=[redacted] retries=3",
"only the secret is masked; the diagnostic field stays readable"
);
assert_eq!(
r.redact("Config { password: \"hunter2\", port: 5432 }"),
"Config { password: \"[redacted]\", port: 5432 }"
);
assert_eq!(r.redact("API_KEY=zzz"), "API_KEY=[redacted]");
}
#[test]
fn basic_redactor_leaves_lookalike_keys_alone() {
let r = BasicRedactor::new().home("/nonexistent");
assert_eq!(r.redact("broken_tokens=4"), "broken_tokens=4");
assert_eq!(r.redact("page_id=828"), "page_id=828");
}
#[test]
fn basic_redactor_masks_emails() {
let r = BasicRedactor::new().home("/nonexistent");
assert_eq!(
r.redact("owner ada@example.com lost the write"),
"owner [email] lost the write"
);
assert_eq!(r.redact("user@localhost"), "user@localhost");
}
#[test]
fn basic_redactor_is_idempotent() {
let r = BasicRedactor::new().home("/home/ada");
let once = r.redact("/home/ada/db token=abc ada@example.com");
assert_eq!(r.redact(&once), once, "re-redacting must not corrupt");
}
}