use std::borrow::Cow;
use std::collections::HashSet;
use std::io::{self, Write};
use std::sync::{OnceLock, RwLock};
const MIN_REDACT_LEN: usize = 4;
fn registry() -> &'static RwLock<HashSet<String>> {
static REG: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
REG.get_or_init(|| RwLock::new(HashSet::new()))
}
pub fn register(secret: &str) {
if secret.len() >= MIN_REDACT_LEN {
registry()
.write()
.expect("secret registry lock poisoned")
.insert(secret.to_owned());
}
}
pub fn redact(input: &str) -> Cow<'_, str> {
let reg = registry().read().expect("secret registry lock poisoned");
if reg.is_empty() {
return Cow::Borrowed(input);
}
let mut secrets: Vec<&str> = reg.iter().map(String::as_str).collect();
secrets.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
let mut out: Option<String> = None;
for secret in secrets {
let current = out.as_deref().unwrap_or(input);
if current.contains(secret) {
out = Some(current.replace(secret, "***"));
}
}
match out {
Some(s) => Cow::Owned(s),
None => Cow::Borrowed(input),
}
}
fn max_secret_len() -> usize {
registry()
.read()
.expect("secret registry lock poisoned")
.iter()
.map(String::len)
.max()
.unwrap_or(0)
}
pub struct RedactingWriter<W: Write> {
inner: W,
pending: Vec<u8>,
}
impl<W: Write> RedactingWriter<W> {
pub fn new(inner: W) -> Self {
Self {
inner,
pending: Vec::new(),
}
}
}
impl<W: Write> Write for RedactingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.pending.extend_from_slice(buf);
let keep = max_secret_len().saturating_sub(1);
if self.pending.len() > keep {
let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
let mut split = scrubbed.len().saturating_sub(keep);
while split > 0 && !scrubbed.is_char_boundary(split) {
split -= 1;
}
let bytes = scrubbed.as_bytes();
self.inner.write_all(&bytes[..split])?;
self.pending.clear();
self.pending.extend_from_slice(&bytes[split..]);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
if !self.pending.is_empty() {
let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
self.inner.write_all(scrubbed.as_bytes())?;
self.pending.clear();
}
self.inner.flush()
}
}
impl<W: Write> Drop for RedactingWriter<W> {
fn drop(&mut self) {
let _ = self.flush();
}
}
#[cfg(feature = "observability")]
pub struct RedactingMakeWriter;
#[cfg(feature = "observability")]
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingMakeWriter {
type Writer = RedactingWriter<std::io::Stderr>;
fn make_writer(&'a self) -> Self::Writer {
RedactingWriter::new(std::io::stderr())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
fn clear() {
registry().write().unwrap().clear();
}
#[test]
#[serial]
fn redacts_registered_value() {
clear();
register("supersecrettoken");
assert_eq!(
redact("Authorization: supersecrettoken"),
"Authorization: ***"
);
}
#[test]
#[serial]
fn leaves_unregistered_text_untouched() {
clear();
register("supersecrettoken");
assert_eq!(redact("nothing to see"), "nothing to see");
}
#[test]
#[serial]
fn does_not_register_short_values() {
clear();
register("abc"); assert_eq!(redact("abc def"), "abc def");
}
#[test]
#[serial]
fn redact_handles_overlapping_secrets_longest_first() {
clear();
register("abcd");
register("abcdXYZW");
let out = redact("value=abcdXYZW end");
assert!(
!out.contains("XYZW"),
"longer secret partially leaked: {out}"
);
assert_eq!(out, "value=*** end");
}
#[test]
#[serial]
fn writer_scrubs_secret_split_across_writes() {
clear();
register("supersecretvalue");
let mut buf: Vec<u8> = Vec::new();
{
let mut w = RedactingWriter::new(&mut buf);
w.write_all(b"token=supersec").unwrap();
w.write_all(b"retvalue done").unwrap();
w.flush().unwrap();
}
let out = String::from_utf8(buf).unwrap();
assert!(
!out.contains("supersecretvalue"),
"secret leaked across write boundary: {out}"
);
assert_eq!(out, "token=*** done");
}
#[test]
#[serial]
fn writer_scrubs_secret_on_write() {
clear();
let secret = "hunter2pass";
register(secret);
let mut buf: Vec<u8> = Vec::new();
{
let mut w = RedactingWriter::new(&mut buf);
write!(w, "token={secret} done").unwrap();
w.flush().unwrap();
}
assert_eq!(String::from_utf8(buf).unwrap(), "token=*** done");
}
}