use std::fmt::{self, Debug, Display};
use std::sync::LazyLock;
use regex::Regex;
use url::Url;
const REDACTED: &str = "REDACTED";
#[derive(Clone, PartialEq, Eq)]
pub struct RedactedConnectionString(String);
impl RedactedConnectionString {
#[must_use]
pub fn new(conn_str: &str) -> Self {
Self(redact(conn_str))
}
}
impl From<&str> for RedactedConnectionString {
fn from(conn_str: &str) -> Self {
Self::new(conn_str)
}
}
impl Display for RedactedConnectionString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Debug for RedactedConnectionString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Debug::fmt(&self.0, f)
}
}
fn redact(conn_str: &str) -> String {
if let Ok(mut url) = Url::parse(conn_str) {
if url.password().is_some() {
let _ = url.set_password(Some(REDACTED));
}
return redact_keyword_password(url.as_str());
}
redact_keyword_password(&redact_url_userinfo(conn_str))
}
fn redact_url_userinfo(conn_str: &str) -> String {
static USERINFO: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)([a-z][a-z0-9+.\-]*://[^@/?#:\s]*:)[^@/?#\s]*(@)")
.expect("userinfo redaction regex is valid")
});
USERINFO
.replace_all(conn_str, |caps: ®ex::Captures| {
format!("{}{REDACTED}{}", &caps[1], &caps[2])
})
.into_owned()
}
fn redact_keyword_password(conn_str: &str) -> String {
static KEYWORD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(\bpassword\s*=\s*)('(?:[^'\\]|\\.)*'|[^\s&]*)")
.expect("password redaction regex is valid")
});
KEYWORD
.replace_all(conn_str, |caps: ®ex::Captures| {
format!("{}{REDACTED}", &caps[1])
})
.into_owned()
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::url_userinfo(
"postgres://user:secret@localhost:5432/db",
"postgres://user:REDACTED@localhost:5432/db"
)]
#[case::url_userinfo_with_colon(
"postgres://user:p@ss:word@localhost:5432/db",
"postgres://user:REDACTED@localhost:5432/db"
)]
#[case::url_userinfo_percent_encoded(
"postgres://user:p%26ss%3Aword@localhost:5432/db",
"postgres://user:REDACTED@localhost:5432/db"
)]
#[case::url_query_password(
"postgres://host/db?password=secret&sslmode=require",
"postgres://host/db?password=REDACTED&sslmode=require"
)]
#[case::url_query_password_percent_encoded(
"postgres://host/db?password=p%26w%3Drd&sslmode=require",
"postgres://host/db?password=REDACTED&sslmode=require"
)]
#[case::keyword_password(
"host=localhost password=secret sslmode=verify-full",
"host=localhost password=REDACTED sslmode=verify-full"
)]
#[case::keyword_password_quoted(
"host=localhost password='se cret' dbname=db",
"host=localhost password=REDACTED dbname=db"
)]
#[case::url_no_password(
"postgres://user@localhost:5432/db",
"postgres://user@localhost:5432/db"
)]
#[case::url_no_userinfo("postgres://localhost:5432/db", "postgres://localhost:5432/db")]
#[case::keyword_password_substring(
"host=localhost mypassword=keep",
"host=localhost mypassword=keep"
)]
#[case::keyword_no_password(
"host=localhost dbname=db sslmode=verify-full",
"host=localhost dbname=db sslmode=verify-full"
)]
fn redacts(#[case] conn_str: &str, #[case] expected: &str) {
assert_eq!(
RedactedConnectionString::new(conn_str).to_string(),
expected
);
}
#[rstest]
#[case::bad_port(
"postgres://postgres:testpassword@host:notaport/db",
"postgres://postgres:REDACTED@host:notaport/db"
)]
#[case::bad_port_colon_password(
"postgres://postgres:test:password@host:notaport/db",
"postgres://postgres:REDACTED@host:notaport/db"
)]
fn redacts_unparseable_url(#[case] conn_str: &str, #[case] expected: &str) {
assert!(
Url::parse(conn_str).is_err(),
"test needs the regex fallback path"
);
assert_eq!(
RedactedConnectionString::new(conn_str).to_string(),
expected
);
}
}