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; 9] = [
"sslmode",
"sslrootcert",
"sslcert",
"application_name",
"connect_timeout",
"target_session_attrs",
"host",
"hostaddr",
"port",
];
pub fn is_bare_in_memory_sqlite(url: &str) -> bool {
url.is_empty() || url == ":memory:"
}
#[cfg_attr(not(feature = "postgres"), allow(dead_code))]
pub fn require_postgres_target(url: &str, store: &str) -> Result<(), String> {
if url.trim().is_empty() || DatabaseBackend::detect(url) == Some(DatabaseBackend::Postgres) {
return Ok(());
}
Err(format!(
"{store} needs a Postgres database target, got {:?}; on another backend, \
use the in-memory store",
redact_target(url)
))
}
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();
}
if parsed.scheme().len() == 1 {
return if is_bare_path(url) {
url.to_owned()
} else {
"****".to_owned()
};
}
if parsed
.host_str()
.is_some_and(|host| host.contains([';', '=']))
|| (!parsed.cannot_be_a_base() && parsed.path().contains([';', '=']))
{
return format!("{}://****", parsed.scheme());
}
if parsed.cannot_be_a_base() {
let opaque = url.split_once(':').map_or("", |(_, rest)| rest);
if opaque.is_empty()
|| opaque.contains(['=', '@', '?', ':', '%', '#'])
|| opaque.contains(char::is_whitespace)
{
return format!("{}:****", parsed.scheme());
}
}
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 is_bare_path(url) {
return url.to_owned();
}
"****".to_owned()
}
fn is_bare_path(url: &str) -> bool {
!url.is_empty() && !url.contains(['=', '@', '?', '#']) && !url.contains(char::is_whitespace)
}
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<String> = Vec::new();
let mut dropped = false;
for pair in query.split(['&', ';']) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
if !allowed.iter().any(|a| key.trim().eq_ignore_ascii_case(a)) {
dropped = true;
} else if is_safe_value(key, value) {
kept.push(pair.to_owned());
} else {
kept.push(format!("{key}=****"));
}
}
if dropped {
kept.push("****".to_owned());
}
kept.join("&")
}
fn is_safe_value(key: &str, value: &str) -> bool {
let key = key.trim();
if key.eq_ignore_ascii_case("hostaddr") {
return value.split(',').all(|item| parse_ip(item).is_some());
}
is_simple_token(value) || (key.eq_ignore_ascii_case("host") && is_host_token(value))
}
fn parse_ip(item: &str) -> Option<std::net::IpAddr> {
item.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(item)
.parse()
.ok()
}
fn is_host_token(value: &str) -> bool {
value
.split(',')
.all(|item| parse_ip(item).is_some() || is_simple_token(item))
}
fn is_simple_token(value: &str) -> bool {
value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~' | ',' | '/' | '+'))
}
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_driver_error(msg: &str, url: &str) -> String {
let safe = redact_target(url);
let mut masked = msg.to_owned();
for secret in &secrets_in(url) {
masked = masked.replace(secret.as_str(), "****");
}
let mut out = String::with_capacity(masked.len());
let mut rest = masked.as_str();
while let Some(open) = rest.find('"') {
let after = &rest[open + 1..];
let Some(close) = after.find('"') else {
break;
};
let span = &after[..close];
out.push_str(&rest[..=open]);
if span.is_empty() || safe.contains(span) {
out.push_str(span);
} else {
out.push_str("****");
}
out.push('"');
rest = &after[close + 1..];
}
out.push_str(rest);
redact_targets_in_message(&out)
}
fn secrets_in(url: &str) -> Vec<String> {
let mut raw: Vec<String> = Vec::new();
if let Some((_, rest)) = url.split_once("://") {
if let Some((userinfo, _)) = rest.rsplit_once('@')
&& let Some((_, password)) = userinfo.split_once(':')
{
raw.push(password.to_owned());
}
if let Some((_, query)) = rest.split_once('?') {
for pair in query.split(['&', ';']) {
if let Some((key, value)) = pair.split_once('=')
&& is_password_key(key)
{
raw.push(value.to_owned());
}
}
}
} else if let Some(pairs) = crate::pg_conn_str::keyword_value_pairs(url) {
raw.extend(
pairs
.into_iter()
.filter(|(key, _)| is_password_key(key))
.map(|(_, value)| value),
);
}
let mut secrets = Vec::new();
for secret in raw.into_iter().filter(|secret| !secret.is_empty()) {
let decoded = percent_encoding::percent_decode_str(&secret)
.decode_utf8_lossy()
.into_owned();
if decoded != secret && !decoded.is_empty() {
secrets.push(decoded);
}
secrets.push(secret);
}
secrets.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
secrets.dedup();
secrets
}
fn is_password_key(key: &str) -> bool {
let key = key.trim();
key.eq_ignore_ascii_case("password") || key.eq_ignore_ascii_case("sslpassword")
}
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 an_opaque_url_keeps_only_its_scheme() {
assert_eq!(redact_target("postgres:password=hunter2"), "postgres:****");
assert_eq!(
redact_target("postgresql:host=db password=hunter2"),
"postgresql:****"
);
assert_eq!(redact_target("postgres:app:hunter2"), "postgres:****");
assert_eq!(redact_target("mysql:pass%77ord"), "mysql:****");
assert_eq!(redact_target("mysql:app.db"), "mysql:app.db");
assert_eq!(redact_target(r"C:\data\app.db"), r"C:\data\app.db");
}
#[test]
fn an_allowlisted_query_value_must_be_a_simple_token() {
assert_eq!(
redact_target("postgres://db/app?application_name=postgres://app:hunter2@other/db"),
"postgres://db/app?application_name=****"
);
assert_eq!(
redact_target("postgres://db/app?sslmode=require&application_name=app:hunter2@other"),
"postgres://db/app?sslmode=require&application_name=****"
);
assert_eq!(
redact_target(
"postgres://db/app?host=/var/run/postgresql&sslrootcert=/etc/ssl/root.crt"
),
"postgres://db/app?host=/var/run/postgresql&sslrootcert=/etc/ssl/root.crt"
);
let out = redact_target("sqlite://file:app.db?mode=ro&vfs=postgres://app:hunter2@other/db");
assert!(!out.contains("hunter2"), "leaked: {out}");
assert_eq!(
redact_target("file:app.db?mode=ro&vfs=unix dotfile"),
"file:app.db?mode=ro&vfs=****"
);
}
#[test]
fn a_driver_error_does_not_echo_a_part_of_the_target() {
let url = "postgres://app:p%ss@localhost:1/db";
let out = redact_driver_error(r#"invalid percent-encoded token: "p%ss""#, url);
assert_eq!(out, r#"invalid percent-encoded token: "****""#);
let url = "postgres://app:hun ter2@[::1/db";
let out = redact_driver_error(
r#"unexpected spaces found in "hun ter2", use percent-encoded spaces (%20) instead"#,
url,
);
assert!(!out.contains("ter2"), "{out}");
let out = redact_driver_error(r#"invalid URI: "postgres://app:hun ter2@[::1/db""#, url);
assert!(!out.contains("ter2"), "{out}");
let out = redact_driver_error("failed near hunter2", "host=db password=hunter2");
assert!(!out.contains("hunter2"), "{out}");
let url = r#"postgres://app:p"secret@host/db"#;
let out = redact_driver_error(r#"invalid: "postgres://app:p"secret@host/db""#, url);
assert!(!out.contains("secret"), "{out}");
let out = redact_driver_error(
"failed near hunter2a",
"host=db password=a sslpassword=hunter2a",
);
assert!(!out.contains("hunter2"), "{out}");
assert_eq!(
redact_driver_error(
r#"could not translate host name "db" to address"#,
"postgres://app:pw@db/app"
),
r#"could not translate host name "db" to address"#
);
}
#[test]
fn key_value_material_in_a_host_or_path_is_masked() {
assert_eq!(
redact_target("postgres://db;password=hunter2/app"),
"postgres://****"
);
assert_eq!(
redact_target("postgres://host/db;password=hunter2"),
"postgres://****"
);
let refusal = require_postgres_target("mysql://host/db;password=hunter2", "Store")
.expect_err("not Postgres");
assert!(!refusal.contains("hunter2"), "{refusal}");
}
#[test]
fn a_one_letter_scheme_gets_the_bare_path_test() {
assert_eq!(redact_target("x:password=hunter2"), "****");
assert_eq!(redact_target(r"C:\data\pw=hunter2.db"), "****");
assert_eq!(redact_target(r"C:\data\app.db"), r"C:\data\app.db");
assert_eq!(redact_target("x:app#hunter2"), "****");
assert_eq!(redact_target("/var/lib/app.db#hunter2"), "****");
}
#[test]
fn an_ipv6_host_value_stays_legible() {
assert_eq!(
redact_target("postgres://db/app?host=[::1]&hostaddr=::1"),
"postgres://db/app?host=[::1]&hostaddr=::1"
);
assert_eq!(
redact_target("postgres://db/app?application_name=a:b"),
"postgres://db/app?application_name=****"
);
assert_eq!(
redact_target("postgres://db/app?hostaddr=token:hunter2"),
"postgres://db/app?hostaddr=****"
);
assert_eq!(
redact_target("postgres://db/app?host=db1:x,[::1]"),
"postgres://db/app?host=****"
);
assert_eq!(
redact_target("postgres://db/app?host=[::1],10.0.0.1"),
"postgres://db/app?host=[::1],10.0.0.1"
);
assert_eq!(
redact_target("postgres://db/app?hostaddr=hunter2"),
"postgres://db/app?hostaddr=****"
);
assert_eq!(
redact_target("postgres://db/app?hostaddr=10.0.0.1,::1"),
"postgres://db/app?hostaddr=10.0.0.1,::1"
);
}
#[test]
fn an_empty_conninfo_is_a_postgres_target_for_the_stores() {
assert!(require_postgres_target("", "Store").is_ok());
assert!(require_postgres_target(" ", "Store").is_ok());
assert!(require_postgres_target("sqlite::memory:", "Store").is_err());
}
#[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: "****""#
);
}
}