pub fn redact_url_passwords(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if let Some((rewritten, advance)) = try_redact_at(bytes, i) {
out.push_str(&rewritten);
i = advance;
continue;
}
let b = bytes[i];
if b.is_ascii() {
out.push(b as char);
i += 1;
} else {
let start = i;
i += 1;
while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
i += 1;
}
out.push_str(&s[start..i]);
}
}
out
}
fn try_redact_at(bytes: &[u8], i: usize) -> Option<(String, usize)> {
if !bytes.get(i).is_some_and(|b| b.is_ascii_alphabetic()) {
return None;
}
let mut j = i + 1;
while j < bytes.len() {
let b = bytes[j];
if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'.' | b'-') {
j += 1;
} else {
break;
}
}
if !bytes[j..].starts_with(b"://") {
return None;
}
let userinfo_start = j + 3;
let mut k = userinfo_start;
let mut has_colon = false;
while k < bytes.len() {
let b = bytes[k];
if b == b'@' {
break;
}
if matches!(b, b'/' | b'?' | b'#') || b.is_ascii_whitespace() {
return None;
}
if b == b':' {
has_colon = true;
}
k += 1;
}
if !has_colon || k >= bytes.len() || bytes[k] != b'@' {
return None;
}
let scheme_part = std::str::from_utf8(&bytes[i..userinfo_start]).ok()?;
Some((format!("{scheme_part}REDACTED"), k))
}
pub fn redact_secrets(s: &str) -> String {
redact_url_passwords(s)
}
pub fn redact_error(e: &anyhow::Error) -> String {
redact_secrets(&format!("{e:#}"))
}
pub fn redacted_log_line(timestamp: &str, level: &str, target: &str, message: &str) -> String {
redact_secrets(&format!("[{timestamp} {level} {target}] {message}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrites_postgres_userinfo_with_password() {
let s = "connection failed to postgresql://alice:s3cret@db.prod:5432/orders: timeout";
let out = redact_url_passwords(s);
assert!(!out.contains("s3cret"), "password must be stripped: {out}");
assert!(
out.contains("postgresql://REDACTED@db.prod:5432/orders"),
"expected REDACTED@host, got: {out}",
);
}
#[test]
fn rewrites_mysql_userinfo_with_password() {
let s = "auth error: mysql://root:hunter2@10.0.0.5:3306/billing";
let out = redact_url_passwords(s);
assert!(!out.contains("hunter2"));
assert!(out.contains("mysql://REDACTED@10.0.0.5"));
}
#[test]
fn preserves_bare_user_at_host_without_password() {
let s = "connection: postgresql://alice@db.prod:5432/orders";
assert_eq!(redact_url_passwords(s), s);
}
#[test]
fn idempotent_on_already_redacted_string() {
let s = "postgresql://REDACTED@db.prod:5432/orders";
assert_eq!(redact_url_passwords(s), s);
}
#[test]
fn preserves_non_url_text_with_at_sign() {
let s = "user alice@example.com reported failure";
assert_eq!(redact_url_passwords(s), s);
}
#[test]
fn handles_multiple_urls_in_one_string() {
let s = "primary postgresql://a:b@h1/d failed, retrying mysql://c:d@h2/d";
let out = redact_url_passwords(s);
assert!(!out.contains("a:b@"));
assert!(!out.contains("c:d@"));
assert!(out.contains("postgresql://REDACTED@h1/d"));
assert!(out.contains("mysql://REDACTED@h2/d"));
}
#[test]
fn stops_at_whitespace_in_userinfo() {
let s = "scheme://broken token@host";
assert_eq!(redact_url_passwords(s), s);
}
#[test]
fn preserves_strings_without_urls() {
let s = "export 'orders' failed: relation does not exist";
assert_eq!(redact_url_passwords(s), s);
}
#[test]
fn redact_error_strips_password_from_anyhow_chain() {
let e = anyhow::anyhow!("connect failed to postgresql://alice:s3cret@db.prod/orders");
let out = redact_error(&e);
assert!(!out.contains("s3cret"));
assert!(out.contains("REDACTED@db.prod"));
}
#[test]
fn preserves_em_dash_and_other_multibyte_glyphs() {
let s = "export 'orders': --resume refused — destination prefix has _SUCCESS";
assert_eq!(
redact_url_passwords(s),
s,
"non-URL text containing an em-dash must pass through unchanged"
);
let s2 = "сообщение об ошибке: cannot connect";
assert_eq!(
redact_url_passwords(s2),
s2,
"Cyrillic text must pass through unchanged"
);
let s3 = "ошибка — postgresql://u:p@host/db: dropped";
let out = redact_url_passwords(s3);
assert!(out.contains("ошибка — postgresql://REDACTED@host/db"));
assert!(!out.contains("u:p@"));
}
}