use super::edit::chain;
use super::keys::{KeyKind, classify};
use super::redactor::{MAX_JSON_DEPTH, REDACTED, Redactor};
use super::values::is_credential_shaped;
use super::{assignments, credentials, emails, home::Home};
use std::borrow::Cow;
pub struct BasicRedactor {
home: Option<Home>,
}
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)
.map(Home::new);
BasicRedactor { home }
}
#[must_use]
pub fn home(mut self, home: impl Into<String>) -> Self {
self.home = Some(Home::new(home.into()));
self
}
fn mask_subtree(value: &mut serde_json::Value) {
Self::mask_subtree_at(value, 0);
}
fn mask_subtree_at(value: &mut serde_json::Value, depth: u32) {
if depth >= MAX_JSON_DEPTH {
*value = serde_json::Value::String(REDACTED.to_owned());
return;
}
match value {
serde_json::Value::Array(items) => items
.iter_mut()
.for_each(|item| Self::mask_subtree_at(item, depth + 1)),
serde_json::Value::Object(map) => {
map.iter_mut()
.for_each(|(_k, v)| Self::mask_subtree_at(v, depth + 1));
}
serde_json::Value::Null => {}
other => *other = serde_json::Value::String(REDACTED.to_owned()),
}
}
}
impl Default for BasicRedactor {
fn default() -> Self {
Self::new()
}
}
impl Redactor for BasicRedactor {
fn redact(&self, input: &str) -> String {
let out = match &self.home {
Some(home) => home.strip(input),
None => Cow::Borrowed(input),
};
let out = chain(out, assignments::mask);
let out = chain(out, credentials::mask_standalone);
chain(out, emails::mask).into_owned()
}
fn redact_value_for_key(&self, key: &str, value: &mut serde_json::Value) {
match classify(key) {
KeyKind::Strong => Self::mask_subtree(value),
KeyKind::Weak if credential_shaped_leaf(value) => Self::mask_subtree(value),
_ => self.redact_json(value),
}
}
fn redact_key(&self, key: &str) -> String {
emails::mask(key).into_owned()
}
}
fn credential_shaped_leaf(value: &serde_json::Value) -> bool {
credential_shaped_leaf_at(value, 0)
}
fn credential_shaped_leaf_at(value: &serde_json::Value, depth: u32) -> bool {
if depth >= MAX_JSON_DEPTH {
return true;
}
match value {
serde_json::Value::String(s) => is_credential_shaped(s),
serde_json::Value::Array(items) => items
.iter()
.any(|item| credential_shaped_leaf_at(item, depth + 1)),
serde_json::Value::Object(map) => map
.values()
.any(|v| credential_shaped_leaf_at(v, depth + 1)),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn redactor() -> BasicRedactor {
BasicRedactor::new().home("/home/ada")
}
#[test]
fn the_passes_compose_without_fighting_each_other() {
let r = redactor();
assert_eq!(
r.redact("/home/ada/db token=sk-abc123 owner ada@example.com"),
"~/db token=[redacted] owner [email]"
);
}
#[test]
fn a_weak_key_over_a_credential_shaped_json_value_still_masks() {
let r = redactor();
let mut v = serde_json::json!({
"grouping_key": "kind=0x09",
"openai_key": "sk-azure-abcdef123456",
});
r.redact_json(&mut v);
assert_eq!(v["grouping_key"], "kind=0x09");
assert_eq!(v["openai_key"], REDACTED);
}
#[test]
fn a_credential_under_a_strong_key_is_masked_however_deep_it_sits() {
let r = redactor();
let mut v = serde_json::json!({
"authorization": ["Bearer sk-live-1", { "raw": "sk-live-2" }],
"page_id": 828,
});
r.redact_json(&mut v);
let rendered = v.to_string();
assert!(!rendered.contains("sk-live-1"), "{rendered}");
assert!(!rendered.contains("sk-live-2"), "{rendered}");
assert_eq!(v["page_id"], 828);
}
#[test]
fn map_keys_that_are_addresses_are_masked() {
let r = redactor();
let mut v = serde_json::json!({ "ada@example.com": 3, "grace@example.com": 4 });
r.redact_json(&mut v);
let obj = v.as_object().unwrap();
assert_eq!(obj.len(), 2, "no member is dropped by the collision");
assert!(obj.contains_key("[email]"));
assert!(obj.contains_key("[email]#2"));
}
#[test]
fn a_redactor_without_a_home_still_masks_credentials() {
let r = BasicRedactor { home: None };
assert_eq!(r.redact("token=sk-abc123"), "token=[redacted]");
}
}