use std::fmt;
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MaskedUrl(String);
impl MaskedUrl {
pub fn new(url: impl Into<String>) -> Self {
Self(url.into())
}
pub fn as_raw_str(&self) -> &str {
&self.0
}
pub fn masked(&self) -> String {
mask_url_password(&self.0)
}
}
impl fmt::Display for MaskedUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.masked())
}
}
impl fmt::Debug for MaskedUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "MaskedUrl(\"{}\")", self.masked())
}
}
impl Serialize for MaskedUrl {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.masked())
}
}
impl<'de> Deserialize<'de> for MaskedUrl {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self(s))
}
}
#[expect(
clippy::string_slice,
reason = "all indices come from str::find() which guarantees char-boundary alignment; slicing is safe"
)]
fn mask_url_password(url: &str) -> String {
let Some(after_scheme) = url.find("://").map(|i| i + 3) else {
return url.to_string();
};
let authority = &url[after_scheme..];
let Some(at_pos) = authority.find('@') else {
return url.to_string();
};
let user_info = &authority[..at_pos];
let Some(colon_pos) = user_info.find(':') else {
return url.to_string();
};
let scheme_and_user = &url[..after_scheme + colon_pos + 1]; let rest = &url[after_scheme + at_pos..];
format!("{scheme_and_user}***{rest}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mask_url_with_password() {
let url = MaskedUrl::new("nats://user:secret@host:4222");
assert_eq!(url.masked(), "nats://user:***@host:4222");
assert_eq!(url.to_string(), "nats://user:***@host:4222");
assert_eq!(url.as_raw_str(), "nats://user:secret@host:4222");
}
#[test]
fn mask_url_no_password() {
let url = MaskedUrl::new("nats://host:4222");
assert_eq!(url.masked(), "nats://host:4222");
}
#[test]
fn mask_url_user_no_password() {
let url = MaskedUrl::new("nats://user@host:4222");
assert_eq!(url.masked(), "nats://user@host:4222");
}
#[test]
fn mask_url_no_scheme() {
let url = MaskedUrl::new("host:4222");
assert_eq!(url.masked(), "host:4222");
}
#[test]
fn debug_redacts_password() {
let url = MaskedUrl::new("nats://admin:hunter2@nats.internal:4222");
let debug = format!("{url:?}");
assert!(!debug.contains("hunter2"), "debug must not expose password");
assert!(debug.contains("***"));
}
#[test]
fn serialize_redacts_password() {
let url = MaskedUrl::new("nats://admin:hunter2@nats.internal:4222");
let json = serde_json::to_string(&url).expect("serialize");
assert!(!json.contains("hunter2"));
assert!(json.contains("***"));
}
#[test]
fn deserialize_roundtrip_preserves_raw() {
let raw = "nats://admin:hunter2@nats.internal:4222";
let url = MaskedUrl::new(raw);
let from_json: MaskedUrl =
serde_json::from_str(&format!("\"{raw}\"")).expect("deserialize");
assert_eq!(from_json.as_raw_str(), raw);
assert_eq!(url.as_raw_str(), raw);
}
#[test]
fn equality() {
let a = MaskedUrl::new("nats://user:pw@host:4222");
let b = MaskedUrl::new("nats://user:pw@host:4222");
let c = MaskedUrl::new("nats://host:4222");
assert_eq!(a, b);
assert_ne!(a, c);
}
}