use crate::RedactionStrategy;
use std::fmt;
use url::Url;
#[derive(Default, Clone)]
pub struct UrlPasswordRedactor;
impl<T: AsRef<str>> RedactionStrategy<T> for UrlPasswordRedactor {
fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
redact_url_password(value.as_ref(), f)
}
}
fn redact_url_password(input: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Ok(mut parsed) = Url::parse(input) else {
return write!(f, "[REDACTED: invalid URL]");
};
if parsed.password().is_some() {
let _ = parsed.set_password(Some("***"));
}
write!(f, "{}", parsed)
}
#[derive(Default, Clone)]
pub struct UrlHostRedactor;
impl<T: AsRef<str>> RedactionStrategy<T> for UrlHostRedactor {
fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
redact_url(value.as_ref(), f)
}
}
fn redact_url(input: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Ok(parsed) = Url::parse(input) else {
return write!(f, "[REDACTED: invalid URL]");
};
write!(f, "****")?;
let path = parsed.path();
if path != "/" {
write!(f, "{}", path)?;
}
if parsed.query().is_some() {
write!(f, "?")?;
let mut first = true;
for (key, _value) in parsed.query_pairs() {
if !first {
write!(f, "&")?;
}
first = false;
write!(f, "{}=****", key)?;
}
}
if parsed.fragment().is_some() {
write!(f, "#****")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Redacted;
fn url(input: &str) -> String {
Redacted::<_, UrlHostRedactor>::new(input).to_string()
}
#[test]
fn test_https_with_path() {
assert_eq!(url("https://example.com/api/secret"), "****/api/secret");
}
#[test]
fn test_no_path() {
assert_eq!(url("https://example.com"), "****");
}
#[test]
fn test_with_credentials() {
assert_eq!(url("postgres://user:pass@host/db"), "****/db");
}
#[test]
fn test_invalid_url() {
assert_eq!(url("not a url"), "[REDACTED: invalid URL]");
}
#[test]
fn test_with_query_string() {
assert_eq!(
url("https://example.com/api?key=secret"),
"****/api?key=****"
);
}
#[test]
fn test_with_multiple_query_params() {
assert_eq!(
url("https://example.com/api?key=secret&token=abc123"),
"****/api?key=****&token=****"
);
}
#[test]
fn test_with_query_flag_no_value() {
assert_eq!(
url("https://example.com/api?debug&key=secret"),
"****/api?debug=****&key=****"
);
}
#[test]
fn test_with_fragment() {
assert_eq!(url("https://example.com/page#section"), "****/page#****");
}
#[test]
fn test_with_query_and_fragment() {
assert_eq!(
url("https://example.com/api?key=secret#section"),
"****/api?key=****#****"
);
}
#[test]
fn test_query_without_path() {
assert_eq!(url("https://example.com?key=secret"), "****?key=****");
}
#[test]
fn test_fragment_without_path() {
assert_eq!(url("https://example.com#section"), "****#****");
}
fn mask(input: &str) -> String {
Redacted::<_, UrlPasswordRedactor>::new(input).to_string()
}
#[test]
fn password_is_replaced_with_stars() {
assert_eq!(
mask("http://alice:s3cr3t@proxy.corp.example.com:3128/"),
"http://alice:***@proxy.corp.example.com:3128/"
);
}
#[test]
fn url_without_explicit_path_is_normalised_by_url_crate() {
assert_eq!(
mask("http://alice:s3cr3t@proxy.corp.example.com:3128"),
"http://alice:***@proxy.corp.example.com:3128/"
);
}
#[test]
fn postgres_url_password_is_replaced() {
assert_eq!(
mask("postgres://user:hunter2@db.internal/app"),
"postgres://user:***@db.internal/app"
);
}
#[test]
fn url_without_password_is_normalised() {
assert_eq!(
mask("http://proxy.example.com:3128"),
"http://proxy.example.com:3128/"
);
}
#[test]
fn url_with_username_but_no_password_is_normalised() {
assert_eq!(
mask("http://alice@proxy.example.com"),
"http://alice@proxy.example.com/"
);
}
#[test]
fn empty_password_is_normalised_away() {
assert_eq!(
mask("postgres://user:@db.internal/app"),
"postgres://user@db.internal/app"
);
}
#[test]
fn invalid_url_produces_redacted_placeholder() {
assert_eq!(mask("not a url"), "[REDACTED: invalid URL]");
}
#[test]
fn works_with_url_type() {
let url: Url = "postgres://user:hunter2@db.internal/app".parse().unwrap();
let redacted = Redacted::<_, UrlPasswordRedactor>::new(url);
assert_eq!(redacted.to_string(), "postgres://user:***@db.internal/app");
}
}