use serde_json::Value;
pub const SENTINEL_PREFIX: &str = "\x00brain-vault:";
pub const SENTINEL_SUFFIX: &str = "\x00";
const SEPARATOR: char = ':';
pub fn mark(handle: &str, value: &str) -> String {
format!("{SENTINEL_PREFIX}{handle}{SEPARATOR}{value}{SENTINEL_SUFFIX}")
}
#[derive(Default, Clone, Debug)]
pub struct Redactor;
impl Redactor {
pub fn new() -> Self {
Self
}
pub fn redact(&self, value: &mut Value) {
match value {
Value::String(s) => {
if let Some(replaced) = redact_string(s) {
*s = replaced;
}
}
Value::Array(items) => {
for item in items {
self.redact(item);
}
}
Value::Object(map) => {
for (_k, v) in map.iter_mut() {
self.redact(v);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
}
pub fn redacted(&self, value: &Value) -> Value {
let mut v = value.clone();
self.redact(&mut v);
v
}
}
fn redact_string(s: &str) -> Option<String> {
if !s.contains(SENTINEL_PREFIX) {
return None;
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find(SENTINEL_PREFIX) {
out.push_str(&rest[..start]);
let after_prefix = &rest[start + SENTINEL_PREFIX.len()..];
match after_prefix.find(SENTINEL_SUFFIX) {
Some(end) => {
let body = &after_prefix[..end];
let handle = body.split(SEPARATOR).next().unwrap_or("");
out.push_str("<vault:");
out.push_str(handle);
out.push('>');
rest = &after_prefix[end + SENTINEL_SUFFIX.len()..];
}
None => {
out.push_str("<vault:malformed>");
rest = "";
break;
}
}
}
out.push_str(rest);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use serde_json::json;
#[test]
fn mark_then_redact_string_leaves_only_handle() {
let r = Redactor::new();
let mut v = Value::String(mark("api-key", "sk-secret-42"));
r.redact(&mut v);
assert_eq!(v.as_str().unwrap(), "<vault:api-key>");
}
#[test]
fn embedded_secret_in_larger_string() {
let r = Redactor::new();
let s = format!("prefix {} suffix", mark("tok", "abc123"));
let mut v = Value::String(s);
r.redact(&mut v);
assert_eq!(v.as_str().unwrap(), "prefix <vault:tok> suffix");
}
#[test]
fn multiple_secrets_per_string_all_replaced() {
let r = Redactor::new();
let s = format!("{} and {}", mark("a", "v1"), mark("b", "v2"));
let mut v = Value::String(s);
r.redact(&mut v);
assert_eq!(v.as_str().unwrap(), "<vault:a> and <vault:b>");
}
#[test]
fn redacts_nested_object_and_array() {
let r = Redactor::new();
let mut v = json!({
"headers": {
"authorization": mark("bearer", "TOKEN-VALUE"),
"x-trace": "no-secret-here",
},
"body": ["plain", mark("api", "SECRET-IN-ARRAY")],
"count": 3,
});
r.redact(&mut v);
assert_eq!(
v["headers"]["authorization"].as_str().unwrap(),
"<vault:bearer>"
);
assert_eq!(v["headers"]["x-trace"].as_str().unwrap(), "no-secret-here");
assert_eq!(v["body"][1].as_str().unwrap(), "<vault:api>");
assert_eq!(v["count"].as_i64().unwrap(), 3);
}
#[test]
fn malformed_sentinel_does_not_leak_tail() {
let r = Redactor::new();
let mut v = Value::String(format!("{SENTINEL_PREFIX}handle:LEAK"));
r.redact(&mut v);
let out = v.as_str().unwrap();
assert!(out.contains("<vault:malformed>"), "got: {out}");
assert!(!out.contains("LEAK"), "raw value leaked: {out}");
}
proptest! {
#[test]
fn no_raw_secret_survives_redaction(
handle in "[a-zA-Z0-9_-]{1,16}",
value in "[^\\x00:]{4,32}",
wrap in "[^\\x00]{0,32}",
) {
let raw = format!("{wrap}{}{wrap}", mark(&handle, &value));
let r = Redactor::new();
let mut v = json!({ "deep": [{ "field": raw }] });
r.redact(&mut v);
let serialized = v.to_string();
prop_assert!(!serialized.contains(&value),
"secret leaked through redaction: {serialized}");
prop_assert!(serialized.contains(&format!("<vault:{handle}>")),
"handle missing from redaction: {serialized}");
}
}
}