use std::borrow::Cow;
pub trait Redactor: Send + Sync + std::fmt::Debug {
fn redact<'a>(&self, text: &'a str) -> Cow<'a, str>;
fn is_empty(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Default)]
pub struct Secrets {
entries: Vec<(String, String)>,
}
impl Secrets {
#[must_use]
pub fn new(pairs: impl IntoIterator<Item = (String, String)>) -> Self {
let mut entries: Vec<(String, String)> = pairs.into_iter().filter(|(_, value)| !value.is_empty()).collect();
entries.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
Self { entries }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn name_for(&self, value: &str) -> Option<&str> {
self
.entries
.iter()
.find(|(_, secret)| secret == value)
.map(|(name, _)| name.as_str())
}
#[must_use]
pub fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> {
if self.entries.is_empty() {
return Cow::Borrowed(text);
}
let mut out = Cow::Borrowed(text);
for (name, value) in &self.entries {
if out.contains(value.as_str()) {
out = Cow::Owned(out.replace(value.as_str(), &format!("<secret>{name}</secret>")));
}
}
out
}
}
impl Redactor for Secrets {
fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> {
Self::redact(self, text)
}
fn is_empty(&self) -> bool {
Self::is_empty(self)
}
}
pub fn redact_json(redactor: &dyn Redactor, value: &mut serde_json::Value) {
if redactor.is_empty() {
return;
}
match value {
serde_json::Value::String(s) => {
if let Cow::Owned(redacted) = redactor.redact(s) {
*s = redacted;
}
},
serde_json::Value::Array(items) => {
for item in items {
redact_json(redactor, item);
}
},
serde_json::Value::Object(map) => {
let needs_key_rewrite = map.keys().any(|k| matches!(redactor.redact(k), Cow::Owned(_)));
if needs_key_rewrite {
let rewritten: serde_json::Map<String, serde_json::Value> = std::mem::take(map)
.into_iter()
.map(|(k, v)| (redactor.redact(&k).into_owned(), v))
.collect();
*map = rewritten;
}
for item in map.values_mut() {
redact_json(redactor, item);
}
},
_ => {},
}
}
pub fn redact_in_place(redactor: &dyn Redactor, text: &mut String) {
if let Cow::Owned(redacted) = redactor.redact(text) {
*text = redacted;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn longest_secret_wins() {
let s = Secrets::new([
("short".to_string(), "abc".to_string()),
("long".to_string(), "abcdef".to_string()),
]);
assert_eq!(
s.redact("xx abcdef yy abc"),
"xx <secret>long</secret> yy <secret>short</secret>"
);
}
#[test]
fn json_keys_and_values_are_redacted() {
let s = Secrets::new([("token".to_string(), "hunter2".to_string())]);
let mut v = serde_json::json!({ "hunter2": ["hunter2", 1], "ok": "fine" });
redact_json(&s, &mut v);
assert_eq!(
v,
serde_json::json!({ "<secret>token</secret>": ["<secret>token</secret>", 1], "ok": "fine" })
);
}
#[test]
fn empty_values_are_ignored() {
let s = Secrets::new([("unset".to_string(), String::new())]);
assert!(s.is_empty());
assert_eq!(s.redact("anything"), "anything");
}
}