use crate::config::DatabaseBackend;
const SQLITE_DIAGNOSTIC_PARAMS: [&str; 5] = ["mode", "cache", "immutable", "vfs", "nolock"];
const IDENTIFYING_KEYWORDS: [&str; 6] = ["host", "hostaddr", "port", "dbname", "user", "sslmode"];
const PG_DIAGNOSTIC_PARAMS: [&str; 8] = [
"sslmode",
"sslrootcert",
"sslcert",
"application_name",
"connect_timeout",
"target_session_attrs",
"host",
"port",
];
pub fn is_bare_in_memory_sqlite(url: &str) -> bool {
url.is_empty() || url == ":memory:"
}
pub fn redact_target(url: &str) -> String {
let backend = DatabaseBackend::detect(url);
if backend == Some(DatabaseBackend::Sqlite) {
return redact_sqlite_target(url);
}
let is_postgres = backend == Some(DatabaseBackend::Postgres);
if is_bare_in_memory_sqlite(url) {
return url.to_owned();
}
if let Ok(mut parsed) = url::Url::parse(url) {
if parsed.path().contains('@')
|| parsed
.fragment()
.is_some_and(|fragment| fragment.contains('@'))
{
return "****".to_owned();
}
let has_password = parsed.password().is_some();
if has_password {
let _ = parsed.set_password(Some("****"));
}
let has_fragment = parsed.fragment().is_some();
if has_fragment {
parsed.set_fragment(Some("****"));
}
let redacted_query = parsed
.query()
.map(|query| filter_query(query, &PG_DIAGNOSTIC_PARAMS, is_postgres));
let query_changed = redacted_query.as_deref().is_some_and(|redacted| {
let changed = Some(redacted) != parsed.query();
parsed.set_query(Some(redacted));
changed
});
if has_password || query_changed || has_fragment {
return parsed.to_string();
}
return url.to_owned();
}
if let Some(pairs) = crate::pg_conn_str::keyword_value_pairs(url) {
let kept: Vec<String> = pairs
.iter()
.filter(|(key, _)| IDENTIFYING_KEYWORDS.contains(&key.as_str()))
.map(|(key, value)| format!("{key}={}", identifying_value(value)))
.collect();
return if kept.is_empty() {
"****".to_owned()
} else {
format!("{} ****", kept.join(" "))
};
}
if !url.is_empty() && !url.contains(['=', '@', '?']) && !url.contains(char::is_whitespace) {
return url.to_owned();
}
"****".to_owned()
}
fn redact_sqlite_target(url: &str) -> String {
let (url, fragment) = match url.split_once('#') {
Some((target, _)) => (target, "#****"),
None => (url, ""),
};
let (scheme, rest) = split_sqlite_scheme(url);
let (userinfo, rest) = match rest.rsplit_once('@') {
Some((_, after)) => ("****@", after),
None => ("", rest),
};
let (target, query) = match rest.split_once('?') {
Some((target, query)) => (target, Some(query)),
None => (rest, None),
};
let head = format!("{scheme}{userinfo}{target}");
query.map_or_else(
|| format!("{head}{fragment}"),
|query| {
format!(
"{head}?{}{fragment}",
filter_query(query, &SQLITE_DIAGNOSTIC_PARAMS, true)
)
},
)
}
fn split_sqlite_scheme(url: &str) -> (&str, &str) {
for scheme in ["sqlite://", "sqlite:", "file:"] {
if let Some(rest) = url.strip_prefix(scheme) {
return (scheme, rest);
}
}
("", url)
}
fn filter_query(query: &str, allowed: &[&str], enumerable: bool) -> String {
if !enumerable {
return "****".to_owned();
}
let mut kept: Vec<&str> = Vec::new();
let mut dropped = false;
for pair in query.split(['&', ';']) {
let key = pair.split_once('=').map_or(pair, |(key, _)| key).trim();
if allowed.iter().any(|a| key.eq_ignore_ascii_case(a)) {
kept.push(pair);
} else {
dropped = true;
}
}
if dropped {
kept.push("****");
}
kept.join("&")
}
fn identifying_value(value: &str) -> String {
if value.contains("://") || value.contains('@') {
return "****".to_owned();
}
if value.contains(char::is_whitespace) {
return format!("'{value}'");
}
value.to_owned()
}
pub fn redact_targets_in_message(msg: &str) -> String {
msg.split_inclusive(char::is_whitespace)
.map(|chunk| {
let trimmed = chunk.trim_end();
let trailing = &chunk[trimmed.len()..];
target_start(trimmed).map_or_else(
|| chunk.to_owned(),
|at| {
let (target, closer) = split_closing_delimiters(&trimmed[at..]);
format!(
"{}{}{closer}{trailing}",
&trimmed[..at],
redact_target(target)
)
},
)
})
.collect()
}
fn split_closing_delimiters(token: &str) -> (&str, &str) {
let end = token.trim_end_matches(['"', '\'', ')']).len();
(&token[..end], &token[end..])
}
fn target_start(token: &str) -> Option<usize> {
if let Some(sep) = token.find("://") {
return Some(scheme_start(token, sep));
}
["sqlite:", "file:"].iter().find_map(|scheme| {
let at = token.find(scheme)?;
let starts_cleanly = at == 0
|| token[..at]
.chars()
.next_back()
.is_some_and(|c| !is_scheme_char(c));
starts_cleanly.then_some(at)
})
}
fn scheme_start(token: &str, sep: usize) -> usize {
let mut start = sep;
for (idx, ch) in token[..sep].char_indices().rev() {
if is_scheme_char(ch) {
start = idx;
} else {
break;
}
}
start
}
const fn is_scheme_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn masks_credentials_but_keeps_plain_targets_legible() {
assert_eq!(
redact_target("mysql://user:secret@host/db"),
"mysql://user:****@host/db"
);
assert_eq!(
redact_target("postgres://user:secret@localhost:5432/app"),
"postgres://user:****@localhost:5432/app"
);
assert_eq!(
redact_target("mysql://host/db?password=hunter2"),
"mysql://host/db?****"
);
assert_eq!(
redact_target("postgres://host/app?sslpassword=hunter2&sslmode=require"),
"postgres://host/app?sslmode=require&****"
);
assert_eq!(
redact_target(
"postgres://app@db/app?sslmode=verify-full&application_name=web&connect_timeout=5"
),
"postgres://app@db/app?sslmode=verify-full&application_name=web&connect_timeout=5"
);
assert_eq!(
redact_target("mysql://user:secret@host/db?api_key=hunter2"),
"mysql://user:****@host/db?****"
);
assert_eq!(redact_target("/var/lib/app.db"), "/var/lib/app.db");
assert_eq!(
redact_target("sqlite:///var/lib/app.db"),
"sqlite:///var/lib/app.db"
);
assert_eq!(redact_target("sqlite::memory:"), "sqlite::memory:");
assert_eq!(
redact_target("sqlite://file:app.db?mode=ro"),
"sqlite://file:app.db?mode=ro"
);
assert_eq!(
redact_target("file::memory:?cache=shared"),
"file::memory:?cache=shared"
);
assert_eq!(redact_target(":memory:"), ":memory:");
}
#[test]
fn sqlite_targets_do_not_carry_credentials_through() {
assert_eq!(
redact_target("sqlite://user:hunter2@host/app.db"),
"sqlite://****@host/app.db"
);
assert_eq!(
redact_target("sqlite:///var/lib/app.db?password=hunter2"),
"sqlite:///var/lib/app.db?****"
);
assert_eq!(
redact_target("file:app.db?mode=ro&password=hunter2"),
"file:app.db?mode=ro&****"
);
}
#[test]
fn keyword_value_targets_keep_only_identifying_keys() {
assert_eq!(
redact_target("host=db user=app password=hunter2"),
"host=db user=app ****"
);
assert_eq!(
redact_target("host=db port=5432 dbname=app sslmode=require password=hunter2"),
"host=db port=5432 dbname=app sslmode=require ****"
);
assert_eq!(redact_target("password=hunter2"), "****");
}
#[test]
fn unclassifiable_targets_are_masked() {
assert_eq!(redact_target("host=db user=app password='hunter2"), "****");
assert_eq!(redact_target("://user:secret@host/db"), "****");
assert_eq!(redact_target("not-a-url?password=hunter2"), "****");
assert_eq!(redact_target("garbage password=hunter2 more"), "****");
}
#[test]
fn userinfo_outside_an_authority_fails_closed() {
assert_eq!(
redact_target("postgres:/user:hunter2@db.internal/app"),
"****"
);
assert_eq!(
redact_target("jdbc:postgresql://user:hunter2@db/app"),
"****"
);
assert_eq!(redact_target("SQLITE:user:hunter2@host/app.db"), "****");
assert_eq!(
redact_target("postgres://app@db.internal/app"),
"postgres://app@db.internal/app"
);
}
#[test]
fn a_url_fragment_cannot_carry_a_secret_out() {
assert_eq!(
redact_target("mysql://host/db#password=hunter2"),
"mysql://host/db#****"
);
assert_eq!(
redact_target("postgres://host/db#password=hunter2"),
"postgres://host/db#****"
);
assert_eq!(
redact_target("sqlite:///var/lib/app.db#password=hunter2"),
"sqlite:///var/lib/app.db#****"
);
assert_eq!(
redact_target("sqlite://file:app.db?mode=ro#password=hunter2"),
"sqlite://file:app.db?mode=ro#****"
);
}
#[test]
fn a_semicolon_joined_tail_does_not_ride_through() {
assert_eq!(
redact_target("postgres://db.internal/app?sslmode=require;password=hunter2"),
"postgres://db.internal/app?sslmode=require&****"
);
assert_eq!(
redact_target("file:app.db?mode=ro;password=hunter2"),
"file:app.db?mode=ro&****"
);
}
#[test]
fn an_identifying_keyword_value_that_is_itself_a_target_is_masked() {
assert_eq!(
redact_target("host=db dbname=postgres://app:hunter2@h/x"),
"host=db dbname=**** ****"
);
assert_eq!(
redact_target("host=db user='app x' password=hunter2"),
"host=db user='app x' ****"
);
}
#[test]
fn message_redaction_covers_every_scheme_and_keeps_the_prose() {
assert_eq!(
redact_targets_in_message("failed: postgres://user:secret@host:5432/db"),
"failed: postgres://user:****@host:5432/db"
);
assert_eq!(
redact_targets_in_message("failed: mysql://user:secret@host/db"),
"failed: mysql://user:****@host/db"
);
assert_eq!(
redact_targets_in_message("connection refused at postgres://host:5432/db"),
"connection refused at postgres://host:5432/db"
);
assert_eq!(
redact_targets_in_message("note: retrying in 500ms"),
"note: retrying in 500ms"
);
assert_eq!(
redact_targets_in_message(
r#"invalid connection string: "postgres://user:hunter2@host/db""#
),
r#"invalid connection string: "postgres://user:****@host/db""#
);
assert_eq!(
redact_targets_in_message("connect failed (postgres://user:hunter2@host/db)"),
"connect failed (postgres://user:****@host/db)"
);
assert_eq!(
redact_targets_in_message("url=postgres://user:hunter2@host/db"),
"url=postgres://user:****@host/db"
);
assert_eq!(
redact_targets_in_message("'postgres://user:hunter2@host/db'"),
"'postgres://user:****@host/db'"
);
assert_eq!(
redact_targets_in_message(
r#"end of string reached when looking for matching "]" in URI: "postgres://app:hunter2@[::1/db""#
),
r#"end of string reached when looking for matching "]" in URI: "****""#
);
}
}