use std::collections::HashMap;
use std::fmt;
pub const REDACTED_PLACEHOLDER: &str = "<redacted>";
#[derive(Clone, Copy)]
pub struct RedactedMapValues<'a>(pub &'a HashMap<String, String>);
impl fmt::Debug for RedactedMapValues<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map()
.entries(self.0.keys().map(|key| (key, REDACTED_PLACEHOLDER)))
.finish()
}
}
#[derive(Clone, Copy)]
pub struct RedactedItems<'a>(pub &'a [String]);
impl fmt::Debug for RedactedItems<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list()
.entries(self.0.iter().map(|_| REDACTED_PLACEHOLDER))
.finish()
}
}
#[derive(Clone, Copy)]
pub struct RedactedUrl<'a>(pub &'a str);
impl fmt::Debug for RedactedUrl<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some((scheme, rest)) = self.0.split_once("://") else {
return f.write_str(REDACTED_PLACEHOLDER);
};
let scheme_is_valid = !scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if !scheme_is_valid {
return f.write_str(REDACTED_PLACEHOLDER);
}
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (authority, remainder) = rest.split_at(authority_end);
if remainder.contains('@') {
return f.write_str(REDACTED_PLACEHOLDER);
}
let authority = authority.rfind('@').map_or_else(
|| authority.to_string(),
|at| format!("{REDACTED_PLACEHOLDER}@{}", &authority[at + 1..]),
);
let separator_pos = remainder.find(['?', '#']);
let path = separator_pos.map_or(remainder, |pos| &remainder[..pos]);
write!(f, "{scheme}://{authority}{path}")?;
if let Some(pos) = separator_pos {
let separator = &remainder[pos..=pos];
write!(f, "{separator}{REDACTED_PLACEHOLDER}")?;
}
Ok(())
}
}
const fn is_scheme_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')
}
fn is_token_terminator(c: char) -> bool {
c.is_whitespace() || c.is_control() || matches!(c, '"' | '\'' | '`' | '(' | ')')
}
#[must_use]
pub fn redact_urls_in_text(text: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
while let Some(relative) = text[cursor..].find("://") {
let separator = cursor + relative;
let mut start = separator;
for (i, c) in text[cursor..separator].char_indices().rev() {
if is_scheme_char(c) {
start = cursor + i;
} else {
break;
}
}
if start == separator {
out.push_str(&text[cursor..separator + 3]);
cursor = separator + 3;
continue;
}
if let Some(preceding) = text[..start].chars().next_back()
&& !is_token_terminator(preceding)
{
start = text[cursor..start]
.rfind(is_token_terminator)
.and_then(|i| {
text[cursor + i..]
.chars()
.next()
.map(|c| cursor + i + c.len_utf8())
})
.unwrap_or(cursor);
}
let mut end = text.len();
for (i, c) in text[separator + 3..].char_indices() {
if is_token_terminator(c) {
end = separator + 3 + i;
break;
}
}
let token = text[start..end].trim_end_matches([',', '.', ';', ':']);
out.push_str(&text[cursor..start]);
let _ = write!(out, "{:?}", RedactedUrl(token));
out.push_str(&text[start + token.len()..end]);
cursor = end;
}
out.push_str(&text[cursor..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacted_map_values_keeps_keys_hides_values() {
let mut map = HashMap::new();
map.insert("Authorization".to_string(), "Bearer secret".to_string());
let debug_output = format!("{:?}", RedactedMapValues(&map));
assert!(debug_output.contains("Authorization"));
assert!(!debug_output.contains("Bearer secret"));
}
#[test]
fn redacted_items_hides_every_entry() {
let items = vec!["KEY=VALUE".to_string(), "just-a-secret".to_string()];
let debug_output = format!("{:?}", RedactedItems(&items));
assert!(!debug_output.contains("KEY=VALUE"));
assert!(!debug_output.contains("just-a-secret"));
assert_eq!(debug_output.matches(REDACTED_PLACEHOLDER).count(), 2);
}
#[test]
fn redacted_url_hides_userinfo() {
let debug_output = format!("{:?}", RedactedUrl("https://user:pass@host.com/path"));
assert_eq!(debug_output, "https://<redacted>@host.com/path");
}
#[test]
fn redacted_url_hides_query_string() {
let debug_output = format!(
"{:?}",
RedactedUrl("https://host.com/path?api_key=sk-secret")
);
assert_eq!(debug_output, "https://host.com/path?<redacted>");
assert!(!debug_output.contains("sk-secret"));
}
#[test]
fn redacted_url_hides_fragment() {
let debug_output = format!("{:?}", RedactedUrl("https://host.com/path#sk-secret"));
assert_eq!(debug_output, "https://host.com/path#<redacted>");
}
#[test]
fn redacted_url_hides_userinfo_and_query_together() {
let debug_output = format!(
"{:?}",
RedactedUrl("https://user:pass@host.com/path?token=secret")
);
assert_eq!(debug_output, "https://<redacted>@host.com/path?<redacted>");
}
#[test]
fn redacted_url_leaves_plain_url_unchanged() {
let debug_output = format!("{:?}", RedactedUrl("https://api.example.com/mcp"));
assert_eq!(debug_output, "https://api.example.com/mcp");
}
#[test]
fn redacted_url_no_path_with_userinfo() {
let debug_output = format!("{:?}", RedactedUrl("https://user:pass@host.com?q=secret"));
assert_eq!(debug_output, "https://<redacted>@host.com?<redacted>");
}
#[test]
fn redacted_url_redacts_unparseable_input_entirely() {
let debug_output = format!("{:?}", RedactedUrl("not-a-url"));
assert_eq!(debug_output, REDACTED_PLACEHOLDER);
}
#[test]
fn redacted_url_uses_last_at_sign_for_authority_split() {
let debug_output = format!("{:?}", RedactedUrl("https://a@b:pass@host.com/path"));
assert_eq!(debug_output, "https://<redacted>@host.com/path");
}
#[test]
fn redacted_url_redacts_entirely_when_userinfo_contains_slash() {
let secret = "p/assw0rd";
let debug_output = format!(
"{:?}",
RedactedUrl(&format!("https://user:{secret}@host.com/mcp"))
);
assert_eq!(debug_output, REDACTED_PLACEHOLDER);
assert!(!debug_output.contains(secret));
assert!(!debug_output.contains("host.com"));
}
#[test]
fn redacted_url_redacts_entirely_when_userinfo_contains_query_marker() {
let secret = "pa?ssw0rd";
let debug_output = format!(
"{:?}",
RedactedUrl(&format!("https://user:{secret}@host.com/mcp"))
);
assert_eq!(debug_output, REDACTED_PLACEHOLDER);
assert!(!debug_output.contains(secret));
}
#[test]
fn redacted_url_redacts_entirely_when_scheme_is_malformed() {
let secret = "ghp_leakedtoken";
let debug_output = format!("{:?}", RedactedUrl(&format!("{secret}://host.com/")));
assert_eq!(debug_output, REDACTED_PLACEHOLDER);
assert!(!debug_output.contains(secret));
}
#[test]
fn redact_urls_in_text_hides_query_string_keeps_surrounding_prose() {
let line = "error sending request for url (https://api.example.invalid/mcp?token=hunter2secret), when send initialize request";
let redacted = redact_urls_in_text(line);
assert!(!redacted.contains("hunter2secret"));
assert!(redacted.contains("error sending request for url ("));
assert!(redacted.contains("https://api.example.invalid/mcp?<redacted>"));
assert!(redacted.contains("when send initialize request"));
}
#[test]
fn redact_urls_in_text_leaves_plain_text_unchanged() {
let line = "connecting to 127.0.0.1:18801 with no scheme";
assert_eq!(redact_urls_in_text(line), line);
}
#[test]
fn redact_urls_in_text_leaves_urls_without_secrets_unchanged() {
let line = "see https://docs.rs/rmcp for details";
assert_eq!(redact_urls_in_text(line), line);
}
#[test]
fn redact_urls_in_text_redacts_multiple_urls_in_one_line() {
let line = "two urls http://a.com/p?x=1 and http://b.com/q?y=2 done";
let redacted = redact_urls_in_text(line);
assert!(!redacted.contains("x=1"));
assert!(!redacted.contains("y=2"));
assert!(redacted.contains("http://a.com/p?<redacted>"));
assert!(redacted.contains("http://b.com/q?<redacted>"));
assert!(redacted.starts_with("two urls "));
assert!(redacted.ends_with(" done"));
}
#[test]
fn redact_urls_in_text_hides_glued_prefix_that_breaks_a_naive_scheme_walk() {
let secret = "ghp_leakedtoken";
let line = format!("weird {secret}://host.com/ token");
let redacted = redact_urls_in_text(&line);
assert!(!redacted.contains(secret));
assert!(!redacted.contains("leakedtoken"));
assert_eq!(redacted, format!("weird {REDACTED_PLACEHOLDER} token"));
}
#[test]
fn redact_urls_in_text_quoted_and_paren_wrapped_shapes() {
let quoted = redact_urls_in_text(
r#"url "https://user:hunter2@api.example.com/mcp?token=s3cr3t" failed"#,
);
assert!(!quoted.contains("hunter2"));
assert!(!quoted.contains("s3cr3t"));
assert!(quoted.contains(r#""https://<redacted>@api.example.com/mcp?<redacted>""#));
let paren = redact_urls_in_text(
"error sending request for url (http://127.0.0.1:1/mcp?token=REFUSEDSECRET), when send initialize request",
);
assert!(!paren.contains("REFUSEDSECRET"));
assert!(paren.contains("(http://127.0.0.1:1/mcp?<redacted>)"));
}
#[test]
fn redact_urls_in_text_handles_non_ascii_prose() {
let line = "unicode ünïcode https://h.example.com/p?t=sec end";
let redacted = redact_urls_in_text(line);
assert!(!redacted.contains("t=sec"));
assert!(redacted.contains("unicode ünïcode "));
assert!(redacted.ends_with(" end"));
}
#[test]
fn redact_urls_in_text_documents_residual_scheme_legal_glue_limitation() {
let line = "Bearer sk-abc.https://h.example.com/p?t=1 tail";
let redacted = redact_urls_in_text(line);
assert!(redacted.contains("sk-abc.https://h.example.com/p?<redacted>"));
assert!(redacted.ends_with(" tail"));
}
#[test]
fn redact_urls_in_text_does_not_panic_on_adjacent_scheme_runs() {
for input in [
"_://a://b",
"msg: _://a://b",
"prefix ?://a_bc://x tail",
"ü://abc://x",
] {
let _ = redact_urls_in_text(input);
}
}
#[test]
fn redact_urls_in_text_redacts_ipv6_literal_authority_query_string() {
let line = "error sending request for url (http://[::1]:1/mcp?token=IPV6LEAKTEST), when send initialize request";
let redacted = redact_urls_in_text(line);
assert!(
!redacted.contains("IPV6LEAKTEST"),
"secret leaked: {redacted}"
);
assert!(redacted.contains("http://[::1]:1/mcp?<redacted>"));
assert!(redacted.contains("when send initialize request"));
}
#[test]
fn redact_urls_in_text_redacts_ipv6_literal_authority_at_end_of_text() {
let line = "connecting to http://[::1]:8080/mcp?token=IPV6LEAKTEST2";
let redacted = redact_urls_in_text(line);
assert!(
!redacted.contains("IPV6LEAKTEST2"),
"secret leaked: {redacted}"
);
assert!(redacted.contains("http://[::1]:8080/mcp?<redacted>"));
}
#[test]
fn redact_urls_in_text_redacts_query_value_containing_unsafe_chars() {
let line = "url http://host.example.com/p?token=abc|def^ghi\\jkl failed";
let redacted = redact_urls_in_text(line);
assert_eq!(redacted, "url http://host.example.com/p?<redacted> failed");
}
#[test]
fn redact_urls_in_text_residual_gap_raw_quote_inside_secret_still_truncates() {
let line = r#"url http://host.example.com/p?token=abc"def failed"#;
let redacted = redact_urls_in_text(line);
assert!(!redacted.contains("abc"));
assert!(redacted.contains("http://host.example.com/p?<redacted>"));
assert!(redacted.contains("def"));
}
#[test]
fn redact_urls_in_text_is_idempotent_on_already_redacted_url() {
let line = "url http://127.0.0.1:1/mcp?<redacted> failed";
let redacted = redact_urls_in_text(line);
assert_eq!(redacted, line);
}
}