use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectFault {
Unreachable,
UnexpectedServer,
TlsRejected,
AuthFailed,
NoSuchDatabase,
PermissionDenied,
Other,
}
impl ConnectFault {
#[must_use]
pub fn advice(self) -> &'static str {
match self {
Self::Unreachable => {
"nothing is listening there — check the host and port, that the server is \
running, and that a firewall or container network is not in the way"
}
Self::UnexpectedServer => {
"something other than the expected database answered on this port — often a \
local PostgreSQL or MySQL install shadowing a Docker container. Check what \
is actually listening, and that the URL's scheme matches that server"
}
Self::TlsRejected => {
"TLS could not be negotiated — check the `sslmode`/`ssl-mode` in the URL and \
whether the server requires (or refuses) encryption"
}
Self::AuthFailed => {
"the server refused these credentials — check the username and password"
}
Self::NoSuchDatabase => {
"the server is reachable but this user sees no such database — create it, \
grant this user access to it, or fix the name in the URL"
}
Self::PermissionDenied => {
"connected, but this role cannot create tables — grant it schema-level CREATE, \
or migrations will fail partway through"
}
Self::Other => "the connection attempt failed",
}
}
}
#[derive(Debug, Clone)]
pub struct ConnectDiagnosis {
pub fault: ConnectFault,
pub endpoint: String,
pub detail: String,
}
impl ConnectDiagnosis {
#[must_use]
pub fn of(url: &str, err: &sqlx::Error) -> Self {
Self {
fault: classify(err),
endpoint: redact(url),
detail: err.to_string(),
}
}
#[must_use]
pub fn new(fault: ConnectFault, url: &str, detail: impl Into<String>) -> Self {
Self {
fault,
endpoint: redact(url),
detail: detail.into(),
}
}
}
impl fmt::Display for ConnectDiagnosis {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} (tried {}) — driver said: {}",
self.fault.advice(),
self.endpoint,
self.detail
)
}
}
fn classify(err: &sqlx::Error) -> ConnectFault {
match err {
sqlx::Error::Protocol(_) => ConnectFault::UnexpectedServer,
sqlx::Error::Tls(_) => ConnectFault::TlsRejected,
sqlx::Error::Io(_) | sqlx::Error::PoolTimedOut => ConnectFault::Unreachable,
sqlx::Error::Database(db) => classify_db(db.as_ref()),
_ => ConnectFault::Other,
}
}
fn classify_db(db: &dyn sqlx::error::DatabaseError) -> ConnectFault {
#[cfg(feature = "mysql")]
if let Some(my) = db.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>() {
return match my.number() {
1045 => ConnectFault::AuthFailed,
1049 | 1044 => ConnectFault::NoSuchDatabase,
1142 => ConnectFault::PermissionDenied,
_ => ConnectFault::Other,
};
}
let Some(code) = db.code() else {
return ConnectFault::Other;
};
#[allow(clippy::match_same_arms)]
match code.as_ref() {
"28P01" | "28000" => ConnectFault::AuthFailed,
"3D000" => ConnectFault::NoSuchDatabase,
"42501" => ConnectFault::PermissionDenied,
"14" => ConnectFault::Unreachable,
"8" | "3" | "1032" => ConnectFault::PermissionDenied,
_ => ConnectFault::Other,
}
}
#[must_use]
pub fn redact(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let Some((userinfo, hostpart)) = rest.split_once('@') else {
return url.to_owned();
};
let user = userinfo.split_once(':').map_or(userinfo, |(u, _)| u);
if userinfo.contains(':') {
format!("{scheme}://{user}:***@{hostpart}")
} else {
format!("{scheme}://{userinfo}@{hostpart}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_removes_the_password_and_keeps_everything_useful() {
assert_eq!(
redact("postgres://app:s3cret@db.internal:5432/acme"),
"postgres://app:***@db.internal:5432/acme"
);
}
#[test]
fn redact_leaves_a_url_with_no_password_alone() {
assert_eq!(
redact("postgres://app@localhost:5432/acme"),
"postgres://app@localhost:5432/acme"
);
assert_eq!(
redact("postgres://localhost:5432/acme"),
"postgres://localhost:5432/acme"
);
}
#[test]
fn redact_leaves_sqlite_paths_alone() {
assert_eq!(
redact("sqlite:./dev.db?mode=rwc"),
"sqlite:./dev.db?mode=rwc"
);
assert_eq!(redact("sqlite://./dev.db"), "sqlite://./dev.db");
}
#[test]
fn redact_handles_an_at_sign_in_the_password() {
let out = redact("mysql://root:p@ss@127.0.0.1:3306/app");
assert!(!out.contains("p@ss"), "password leaked: {out}");
assert!(out.starts_with("mysql://root:***@"), "{out}");
}
#[test]
fn a_protocol_error_reads_as_a_port_conflict_not_a_locale_problem() {
let err = sqlx::Error::Protocol(
"Postgres returned a non-UTF-8 string for its error message. This is most likely \
due to an error that occurred during authentication and the default lc_messages \
locale is not binary-compatible with UTF-8."
.into(),
);
let d = ConnectDiagnosis::of(
"postgres://rustango:rustango@localhost:5432/backend_dev",
&err,
);
assert_eq!(d.fault, ConnectFault::UnexpectedServer);
let rendered = d.to_string();
assert!(
rendered.contains("something other than the expected database answered"),
"{rendered}"
);
assert!(rendered.contains("localhost:5432"), "{rendered}");
assert!(
!rendered.contains("rustango:rustango"),
"credentials leaked: {rendered}"
);
assert!(rendered.contains("lc_messages"), "{rendered}");
}
#[test]
fn a_refused_connection_reads_as_unreachable() {
let err = sqlx::Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"Connection refused (os error 61)",
));
let d = ConnectDiagnosis::of("postgres://app:pw@127.0.0.1:5999/x", &err);
assert_eq!(d.fault, ConnectFault::Unreachable);
assert!(d.to_string().contains("nothing is listening"), "{d}");
}
#[test]
fn advice_is_distinct_for_every_fault() {
let all = [
ConnectFault::Unreachable,
ConnectFault::UnexpectedServer,
ConnectFault::TlsRejected,
ConnectFault::AuthFailed,
ConnectFault::NoSuchDatabase,
ConnectFault::PermissionDenied,
ConnectFault::Other,
];
let mut seen: Vec<&str> = all.iter().map(|f| f.advice()).collect();
seen.sort_unstable();
let before = seen.len();
seen.dedup();
assert_eq!(before, seen.len(), "two faults share advice text");
}
}