const QUERY_MARK: &str = "?[redacted]";
pub(crate) fn redact_url(url: &str) -> String {
let (before_fragment, _) = url.split_once('#').unwrap_or((url, ""));
let (head, has_query) = match before_fragment.split_once('?') {
Some((head, _)) => (head, true),
None => (before_fragment, false),
};
let mut out = String::with_capacity(head.len() + QUERY_MARK.len());
match head.split_once("://") {
Some((scheme, rest)) => {
let end = rest.find('/').unwrap_or(rest.len());
let (authority, path) = rest.split_at(end);
let host_port = authority.rsplit('@').next().unwrap_or(authority);
out.push_str(scheme);
out.push_str("://");
out.push_str(host_port);
out.push_str(path);
}
None => out.push_str(head),
}
if has_query {
out.push_str(QUERY_MARK);
}
out
}
pub(crate) fn redact_urls_in_text(text: &str) -> String {
if !text.contains("://") {
return text.to_string();
}
let bytes = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut copied = 0usize;
let mut search = 0usize;
while let Some(rel) = text[search..].find("://") {
let sep = search + rel;
let mut start = sep;
while start > copied {
let b = bytes[start - 1];
if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'.' | b'-') {
start -= 1;
} else {
break;
}
}
if start == sep {
search = sep + 3;
continue;
}
let mut end = sep + 3;
while end < bytes.len() {
let b = bytes[end];
if b.is_ascii_whitespace() || matches!(b, b'"' | b'\'' | b'<' | b'>' | b'`') {
break;
}
end += 1;
}
while end > sep + 3
&& matches!(
bytes[end - 1],
b'.' | b',' | b';' | b':' | b')' | b']' | b'}' | b'!'
)
{
end -= 1;
}
out.push_str(&text[copied..start]);
out.push_str(&redact_url(&text[start..end]));
copied = end;
search = end;
}
out.push_str(&text[copied..]);
out
}
pub(crate) fn url_secret_parts(url: &str) -> Vec<String> {
let mut parts = Vec::new();
let no_fragment = url.split('#').next().unwrap_or(url);
match no_fragment.split_once('?') {
Some((head, query)) => {
if !query.is_empty() {
parts.push(query.to_string());
}
push_userinfo(head, &mut parts);
}
None => push_userinfo(no_fragment, &mut parts),
}
parts
}
fn push_userinfo(head: &str, parts: &mut Vec<String>) {
if let Some((_, rest)) = head.split_once("://") {
let end = rest.find('/').unwrap_or(rest.len());
let authority = &rest[..end];
if let Some((userinfo, _)) = authority.rsplit_once('@') {
if !userinfo.is_empty() {
parts.push(userinfo.to_string());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_drops_userinfo_query_and_fragment() {
assert_eq!(
redact_url("https://user:pass@api.example.com:8443/v1/x?key=KEYVAL&a=b#frag"),
"https://api.example.com:8443/v1/x?[redacted]"
);
assert_eq!(
redact_url("http://example.com/plain"),
"http://example.com/plain"
);
assert_eq!(
redact_url("http://example.com?key=1"),
"http://example.com?[redacted]"
);
assert_eq!(redact_url("not a url?key=KEYVAL"), "not a url?[redacted]");
}
#[test]
fn text_scrub_finds_embedded_urls() {
let text =
"error sending https://h.example/p?key=KEYVAL: refused (http://u:p@x.test/a?b=c)";
let out = redact_urls_in_text(text);
assert!(
!out.contains("KEYVAL") && !out.contains("u:p") && !out.contains("b=c"),
"{out}"
);
assert!(out.contains("https://h.example/p?[redacted]:"), "{out}");
assert_eq!(redact_urls_in_text("no urls here"), "no urls here");
}
#[test]
fn secret_parts_cover_query_and_userinfo() {
let parts = url_secret_parts("https://u:pass@h.example/p?key=KEYVAL#f");
assert!(parts.contains(&"key=KEYVAL".to_string()));
assert!(parts.contains(&"u:pass".to_string()));
}
}