use crate::error::ReplicationError;
#[derive(Clone)]
pub struct ConnInfo {
pub host: String,
pub port: u16,
pub user: String,
pub password: Option<String>,
pub dbname: String,
pub sslmode: SslMode,
pub sslrootcert: Option<String>,
pub sslnegotiation: SslNegotiation,
pub replication: ReplicationMode,
pub connect_timeout: u64,
pub keepalives: bool,
pub keepalives_idle: u64,
pub keepalives_interval: u64,
pub keepalives_count: u32,
pub tcp_user_timeout: u64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SslMode {
Disable,
Allow,
Prefer,
Require,
VerifyCa,
VerifyFull,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ReplicationMode {
Database,
Physical,
None,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SslNegotiation {
Postgres,
Direct,
}
impl std::fmt::Debug for ConnInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConnInfo")
.field("host", &self.host)
.field("port", &self.port)
.field("user", &self.user)
.field(
"password",
if self.password.is_some() {
&"<REDACTED>"
} else {
&"None"
},
)
.field("dbname", &self.dbname)
.field("sslmode", &self.sslmode)
.field("sslrootcert", &self.sslrootcert)
.field("sslnegotiation", &self.sslnegotiation)
.field("replication", &self.replication)
.field("connect_timeout", &self.connect_timeout)
.field("keepalives", &self.keepalives)
.field("keepalives_idle", &self.keepalives_idle)
.field("keepalives_interval", &self.keepalives_interval)
.field("keepalives_count", &self.keepalives_count)
.field("tcp_user_timeout", &self.tcp_user_timeout)
.finish()
}
}
impl ConnInfo {
pub fn parse(conninfo: &str) -> Result<Self, ReplicationError> {
let info = if conninfo.starts_with("postgresql://") || conninfo.starts_with("postgres://") {
Self::parse_uri(conninfo)?
} else {
Self::parse_key_value(conninfo)?
};
if info.sslnegotiation == SslNegotiation::Direct
&& !matches!(
info.sslmode,
SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull
)
{
return Err(ReplicationError::config(
"sslnegotiation=direct requires sslmode=require, verify-ca or verify-full; \
a weaker mode may silently fall back to plaintext"
.to_string(),
));
}
Ok(info)
}
fn parse_uri(uri: &str) -> Result<Self, ReplicationError> {
let stripped = uri
.trim_start_matches("postgresql://")
.trim_start_matches("postgres://");
let (creds, rest) = match stripped.find(['@', '/']) {
Some(i) if stripped.as_bytes()[i] == b'@' => (&stripped[..i], &stripped[i + 1..]),
_ => ("", stripped),
};
let (mut user, mut password) = if creds.is_empty() {
("postgres".to_string(), None)
} else if let Some((u, p)) = creds.split_once(':') {
(
url_decode(u),
if p.is_empty() {
None
} else {
Some(url_decode(p))
},
)
} else {
(url_decode(creds), None)
};
let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
let host_port = &rest[..authority_end];
let db_params = match rest.as_bytes().get(authority_end) {
Some(b'/') => &rest[authority_end + 1..],
Some(b'?') => &rest[authority_end..],
_ => "",
};
let (mut host, mut port) = if let Some(rest_after_bracket) = host_port.strip_prefix('[') {
let (addr, tail) = rest_after_bracket.split_once(']').ok_or_else(|| {
ReplicationError::config(format!(
"unterminated IPv6 address in connection URI: \"{host_port}\""
))
})?;
let port = match tail.strip_prefix(':') {
Some(p) => parse_port(p)?,
None if tail.is_empty() => 5432,
None => {
return Err(ReplicationError::config(format!(
"unexpected character in connection URI after IPv6 address \
(expected \":\" or \"/\"): \"{tail}\""
)))
}
};
if addr.is_empty() {
return Err(ReplicationError::config(
"IPv6 host address may not be empty in connection URI".to_string(),
));
}
(addr.to_string(), port)
} else if let Some((h, p)) = host_port.rsplit_once(':') {
(h.to_string(), parse_port(p)?)
} else {
(host_port.to_string(), 5432)
};
let (db, params_str) = db_params.split_once('?').unwrap_or((db_params, ""));
let mut dbname = if db.is_empty() {
String::new()
} else {
url_decode(db)
};
let mut sslmode = SslMode::Prefer;
let mut sslmode_explicit = false;
let mut replication = ReplicationMode::None;
let mut sslrootcert: Option<String> = None;
let mut sslnegotiation = SslNegotiation::Postgres;
let mut connect_timeout: u64 = 0;
let mut keepalives = true;
let mut keepalives_idle: u64 = 120;
let mut keepalives_interval: u64 = 10;
let mut keepalives_count: u32 = 3;
let mut tcp_user_timeout: u64 = 0;
for param in params_str.split('&') {
if param.is_empty() {
continue;
}
if let Some((key, val)) = param.split_once('=') {
let val = url_decode(val);
let val = val.as_str();
match key {
"sslmode" => {
sslmode = parse_sslmode(val)?;
sslmode_explicit = true;
}
"sslrootcert" => sslrootcert = Some(val.to_string()),
"sslnegotiation" => sslnegotiation = parse_ssl_negotiation(val)?,
"replication" => replication = parse_replication_mode(val)?,
"connect_timeout" => {
connect_timeout = parse_connect_timeout("connect_timeout", val)?;
}
"keepalives" => keepalives = parse_num::<i32>("keepalives", val)? != 0,
"keepalives_idle" => {
keepalives_idle = parse_num("keepalives_idle", val)?;
}
"keepalives_interval" => {
keepalives_interval = parse_num("keepalives_interval", val)?;
}
"keepalives_count" => {
keepalives_count = parse_num("keepalives_count", val)?;
}
"tcp_user_timeout" => {
tcp_user_timeout = parse_connect_timeout("tcp_user_timeout", val)?;
}
"host" | "hostaddr" => host = val.to_string(),
"port" => port = parse_port(val)?,
"user" => user = val.to_string(),
"password" => password = Some(val.to_string()),
"dbname" => dbname = val.to_string(),
"requiressl" => {
sslmode = if val.starts_with('1') {
SslMode::Require
} else {
SslMode::Prefer
};
sslmode_explicit = true;
}
"ssl" if val == "true" => {
sslmode = SslMode::Require;
sslmode_explicit = true;
}
k => check_unhandled(k, val)?,
}
} else {
return Err(ReplicationError::config(format!(
"missing key/value separator \"=\" in URI query parameter: \"{param}\""
)));
}
}
if dbname.is_empty() {
dbname = user.clone();
}
if host.is_empty() {
host = "localhost".to_string();
}
let password = password.or_else(|| std::env::var("PGPASSWORD").ok());
let sslmode =
resolve_sslrootcert_system(sslmode, sslmode_explicit, sslrootcert.as_deref())?;
Ok(ConnInfo {
host,
port,
user,
password,
dbname,
sslmode,
sslrootcert,
sslnegotiation,
replication,
connect_timeout,
keepalives,
keepalives_idle,
keepalives_interval,
keepalives_count,
tcp_user_timeout,
})
}
fn parse_key_value(input: &str) -> Result<Self, ReplicationError> {
let mut host = "localhost".to_string();
let mut port: u16 = 5432;
let mut user = "postgres".to_string();
let mut password: Option<String> = None;
let mut dbname: Option<String> = None;
let mut sslmode = SslMode::Prefer;
let mut sslmode_explicit = false;
let mut replication = ReplicationMode::None;
let mut sslrootcert: Option<String> = None;
let mut sslnegotiation = SslNegotiation::Postgres;
let mut connect_timeout: u64 = 0;
let mut keepalives = true;
let mut keepalives_idle: u64 = 120;
let mut keepalives_interval: u64 = 10;
let mut keepalives_count: u32 = 3;
let mut tcp_user_timeout: u64 = 0;
let mut chars = input.chars().peekable();
while chars.peek().is_some() {
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek().is_none() {
break;
}
let key: String = chars.by_ref().take_while(|c| *c != '=').collect();
let key = key.trim();
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
let value = if chars.peek() == Some(&'\'') {
chars.next(); let mut v = String::new();
loop {
match chars.next() {
Some('\'') => {
if chars.peek() == Some(&'\'') {
chars.next(); v.push('\'');
} else {
break; }
}
Some(c) => v.push(c),
None => break, }
}
v
} else {
let v: String = chars.by_ref().take_while(|c| !c.is_whitespace()).collect();
v
};
match key {
"host" | "hostaddr" => host = value,
"port" => port = parse_port(&value)?,
"user" => user = value,
"password" => password = Some(value),
"dbname" | "database" => dbname = Some(value),
"sslmode" => {
sslmode = parse_sslmode(&value)?;
sslmode_explicit = true;
}
"sslrootcert" => sslrootcert = Some(value),
"sslnegotiation" => sslnegotiation = parse_ssl_negotiation(&value)?,
"replication" => replication = parse_replication_mode(&value)?,
"connect_timeout" => {
connect_timeout = parse_connect_timeout("connect_timeout", &value)?
}
"keepalives" => keepalives = parse_num::<i32>("keepalives", &value)? != 0,
"keepalives_idle" => keepalives_idle = parse_num("keepalives_idle", &value)?,
"keepalives_interval" => {
keepalives_interval = parse_num("keepalives_interval", &value)?
}
"keepalives_count" => keepalives_count = parse_num("keepalives_count", &value)?,
"tcp_user_timeout" => {
tcp_user_timeout = parse_connect_timeout("tcp_user_timeout", &value)?
}
"requiressl" => {
sslmode = if value.starts_with('1') {
SslMode::Require
} else {
SslMode::Prefer
};
sslmode_explicit = true;
}
k => check_unhandled(k, &value)?,
}
}
let password = password.or_else(|| std::env::var("PGPASSWORD").ok());
let dbname = dbname.unwrap_or_else(|| user.clone());
let sslmode =
resolve_sslrootcert_system(sslmode, sslmode_explicit, sslrootcert.as_deref())?;
Ok(ConnInfo {
host,
port,
user,
password,
dbname,
sslmode,
sslrootcert,
sslnegotiation,
replication,
connect_timeout,
keepalives,
keepalives_idle,
keepalives_interval,
keepalives_count,
tcp_user_timeout,
})
}
}
pub(crate) const SSLROOTCERT_SYSTEM: &str = "system";
fn resolve_sslrootcert_system(
sslmode: SslMode,
sslmode_explicit: bool,
sslrootcert: Option<&str>,
) -> Result<SslMode, ReplicationError> {
if sslrootcert != Some(SSLROOTCERT_SYSTEM) {
return Ok(sslmode);
}
match sslmode {
_ if !sslmode_explicit => Ok(SslMode::VerifyFull),
SslMode::VerifyFull => Ok(sslmode),
weak => Err(ReplicationError::config(format!(
"weak sslmode \"{}\" may not be used with sslrootcert=system \
(use verify-full)",
sslmode_name(weak)
))),
}
}
fn sslmode_name(mode: SslMode) -> &'static str {
match mode {
SslMode::Disable => "disable",
SslMode::Allow => "allow",
SslMode::Prefer => "prefer",
SslMode::Require => "require",
SslMode::VerifyCa => "verify-ca",
SslMode::VerifyFull => "verify-full",
}
}
fn parse_sslmode(s: &str) -> Result<SslMode, ReplicationError> {
match s {
"disable" => Ok(SslMode::Disable),
"allow" => Ok(SslMode::Allow),
"prefer" => Ok(SslMode::Prefer),
"require" => Ok(SslMode::Require),
"verify-ca" => Ok(SslMode::VerifyCa),
"verify-full" => Ok(SslMode::VerifyFull),
other => Err(ReplicationError::config(format!(
"invalid sslmode value: \"{other}\" \
(expected disable, allow, prefer, require, verify-ca or verify-full)"
))),
}
}
const IGNORED_OPTIONS: &[&str] = &[
"application_name",
"channel_binding",
"client_encoding",
"fallback_application_name",
"gssdelegation",
"gssencmode", "gsslib",
"krbsrvname",
"load_balance_hosts",
"max_protocol_version",
"min_protocol_version",
"options",
"passfile",
"servicefile",
"ssl_max_protocol_version",
"ssl_min_protocol_version", "sslcert",
"sslcertmode", "sslcompression",
"sslkey",
"sslkeylogfile",
"sslpassword",
"sslsni",
"target_session_attrs",
];
const UNSUPPORTED_OPTIONS: &[&str] = &[
"oauth_ca_file",
"oauth_client_id",
"oauth_client_secret",
"oauth_issuer",
"oauth_scope",
"require_auth",
"requirepeer",
"scram_client_key",
"scram_server_key",
"service",
"sslcrl",
"sslcrldir",
];
fn check_unhandled(key: &str, value: &str) -> Result<(), ReplicationError> {
let strict_form = match key {
"channel_binding" | "gssencmode" | "sslcertmode" => value == "require",
"ssl_min_protocol_version" => value.eq_ignore_ascii_case("TLSv1.3"),
_ => false,
};
if strict_form {
return Err(ReplicationError::config(format!(
"connection option \"{key}={value}\" is not supported by the native backend; \
ignoring it would silently weaken the connection \
(use the `libpq` backend, or remove it)"
)));
}
if UNSUPPORTED_OPTIONS.contains(&key) {
return Err(ReplicationError::config(format!(
"connection option \"{key}\" is not supported by the native backend; \
ignoring it would silently weaken the connection \
(use the `libpq` backend, or remove it)"
)));
}
if IGNORED_OPTIONS.contains(&key) {
tracing::debug!("ignoring unimplemented connection option \"{key}\"");
return Ok(());
}
Err(ReplicationError::config(format!(
"invalid connection option \"{key}\""
)))
}
fn parse_connect_timeout(key: &str, value: &str) -> Result<u64, ReplicationError> {
Ok(parse_num::<i64>(key, value)?.max(0) as u64)
}
fn parse_port(value: &str) -> Result<u16, ReplicationError> {
if value.is_empty() {
return Ok(5432);
}
let port = parse_num::<u16>("port", value)?;
if port == 0 {
return Err(ReplicationError::config(format!(
"invalid port number: \"{value}\""
)));
}
Ok(port)
}
fn parse_num<T: core::str::FromStr>(key: &str, value: &str) -> Result<T, ReplicationError> {
value.parse::<T>().map_err(|_| {
ReplicationError::config(format!(
"invalid integer value \"{value}\" for connection option \"{key}\""
))
})
}
fn parse_replication_mode(s: &str) -> Result<ReplicationMode, ReplicationError> {
match s {
"database" => Ok(ReplicationMode::Database),
"true" | "yes" | "on" | "1" => Ok(ReplicationMode::Physical),
"false" | "no" | "off" | "0" => Ok(ReplicationMode::None),
other => Err(ReplicationError::config(format!(
"invalid replication value: \"{other}\" \
(expected database, or a boolean: true/false/yes/no/on/off/1/0)"
))),
}
}
fn parse_ssl_negotiation(s: &str) -> Result<SslNegotiation, ReplicationError> {
match s {
"direct" => Ok(SslNegotiation::Direct),
"postgres" => Ok(SslNegotiation::Postgres),
other => Err(ReplicationError::config(format!(
"invalid sslnegotiation value: \"{other}\" (expected postgres or direct)"
))),
}
}
fn url_decode(s: &str) -> String {
#[inline]
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push((hi << 4) | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_uri_full() {
let ci = ConnInfo::parse(
"postgresql://repl:s3cret@db.example.com:5433/mydb?sslmode=require&replication=database",
)
.unwrap();
assert_eq!(ci.host, "db.example.com");
assert_eq!(ci.port, 5433);
assert_eq!(ci.user, "repl");
assert_eq!(ci.password, Some("s3cret".to_string()));
assert_eq!(ci.dbname, "mydb");
assert_eq!(ci.sslmode, SslMode::Require);
assert_eq!(ci.replication, ReplicationMode::Database);
}
#[test]
fn parse_uri_defaults() {
let ci = ConnInfo::parse("postgresql://localhost/testdb").unwrap();
assert_eq!(ci.host, "localhost");
assert_eq!(ci.port, 5432);
assert_eq!(ci.user, "postgres");
assert_eq!(ci.sslmode, SslMode::Prefer);
}
#[test]
fn parse_uri_encoded_password() {
let ci = ConnInfo::parse("postgresql://user:p%40ss@host/db").unwrap();
assert_eq!(ci.password, Some("p@ss".to_string()));
}
#[test]
fn uri_at_sign_in_query_is_not_credentials() {
let ci = ConnInfo::parse(
"postgresql://pg.internal:5432/appdb?sslmode=require&application_name=svc@prod",
)
.unwrap();
assert_eq!(ci.host, "pg.internal");
assert_eq!(ci.port, 5432);
assert_eq!(ci.dbname, "appdb");
assert!(matches!(ci.sslmode, SslMode::Require));
let ci = ConnInfo::parse("postgresql://u:p@h/db").unwrap();
assert_eq!(ci.user, "u");
assert_eq!(ci.host, "h");
}
#[test]
fn uri_question_mark_in_userinfo_is_not_an_authority_boundary() {
let ci = ConnInfo::parse("postgresql://user:p?ss@host/db").unwrap();
assert_eq!(ci.host, "host");
assert_eq!(ci.user, "user");
assert_eq!(ci.password.as_deref(), Some("p?ss"));
assert_eq!(ci.port, 5432);
let ci = ConnInfo::parse("postgresql://us?er@host/db").unwrap();
assert_eq!(ci.host, "host");
assert_eq!(ci.user, "us?er");
let ci =
ConnInfo::parse("postgresql://pg.internal/appdb?application_name=svc@prod").unwrap();
assert_eq!(ci.host, "pg.internal");
}
#[test]
fn uri_ipv6_trailing_junk_is_rejected() {
assert!(ConnInfo::parse("postgresql://u@[2001:db8::1]5433/db").is_err());
assert!(ConnInfo::parse("postgresql://u@[::1]junk/db").is_err());
assert!(ConnInfo::parse("postgresql://u@[]/db").is_err());
assert_eq!(
ConnInfo::parse("postgresql://u@[2001:db8::1]:5433/db")
.unwrap()
.port,
5433
);
assert_eq!(
ConnInfo::parse("postgresql://u@[::1]/db").unwrap().host,
"::1"
);
}
#[test]
fn negative_connect_timeout_means_no_timeout_not_an_error() {
assert_eq!(
ConnInfo::parse("host=h connect_timeout=-1")
.unwrap()
.connect_timeout,
0
);
assert_eq!(
ConnInfo::parse("postgresql://u@h/db?connect_timeout=-30")
.unwrap()
.connect_timeout,
0
);
assert!(ConnInfo::parse("host=h connect_timeout=abc").is_err());
assert_eq!(
ConnInfo::parse("host=h connect_timeout=10")
.unwrap()
.connect_timeout,
10
);
}
#[test]
fn port_zero_is_rejected() {
assert!(ConnInfo::parse("host=h port=0").is_err());
assert!(ConnInfo::parse("postgresql://u@h:0/db").is_err());
assert_eq!(ConnInfo::parse("host=h port=").unwrap().port, 5432);
}
#[test]
fn typo_in_security_option_is_rejected_not_dropped() {
for s in [
"host=h sslmod=verify-full",
"postgresql://u@h/db?sslmod=verify-full",
] {
let err = ConnInfo::parse(s).unwrap_err().to_string();
assert!(err.contains("invalid connection option"), "{s}: {err}");
assert!(err.contains("sslmod"), "{s}: {err}");
}
assert!(ConnInfo::parse("host=h SSLMODE=require").is_err());
}
#[test]
fn uri_query_param_without_separator_is_rejected() {
let err = ConnInfo::parse("postgresql://u@h/db?sslmodeverify-full")
.unwrap_err()
.to_string();
assert!(err.contains("missing key/value separator"), "{err}");
assert!(err.contains("sslmodeverify-full"), "{err}");
assert!(ConnInfo::parse("postgresql://u@h/db?sslmode=require&").is_ok());
}
#[test]
fn key_value_tolerates_spaces_around_equals() {
let ci = ConnInfo::parse("host = h sslmode = require port = 5433").unwrap();
assert_eq!(ci.host, "h");
assert!(matches!(ci.sslmode, SslMode::Require));
assert_eq!(ci.port, 5433);
let ci = ConnInfo::parse("host=h password = 'it''s'").unwrap();
assert_eq!(ci.password.as_deref(), Some("it's"));
}
#[test]
fn direct_ssl_negotiation_requires_a_non_falling_back_sslmode() {
for weak in ["prefer", "allow", "disable"] {
assert!(
ConnInfo::parse(&format!("host=h sslnegotiation=direct sslmode={weak}")).is_err(),
"sslnegotiation=direct must not pair with sslmode={weak}"
);
}
assert!(ConnInfo::parse("host=h sslnegotiation=direct").is_err());
assert!(ConnInfo::parse("postgresql://u@h/db?sslnegotiation=direct").is_err());
for strong in ["require", "verify-ca", "verify-full"] {
let ci = ConnInfo::parse(&format!("host=h sslnegotiation=direct sslmode={strong}"))
.unwrap_or_else(|e| panic!("sslmode={strong} must be allowed: {e}"));
assert!(matches!(ci.sslnegotiation, SslNegotiation::Direct));
}
}
#[test]
fn uri_without_authority_defaults_the_host() {
let ci = ConnInfo::parse("postgresql:///mydb").unwrap();
assert_eq!(ci.host, "localhost");
assert_eq!(ci.dbname, "mydb");
assert_eq!(ci.port, 5432);
}
#[test]
fn keepalives_is_validated_like_its_siblings() {
assert!(ConnInfo::parse("host=h keepalives=abc").is_err());
assert!(!ConnInfo::parse("host=h keepalives=0").unwrap().keepalives);
assert!(!ConnInfo::parse("host=h keepalives=+0").unwrap().keepalives);
assert!(ConnInfo::parse("host=h keepalives=1").unwrap().keepalives);
assert!(ConnInfo::parse("postgresql://u@h/db?keepalives=x").is_err());
}
#[test]
fn libpq_options_we_do_not_implement_still_parse() {
for kv in [
"application_name=myapp",
"client_encoding=UTF8",
"target_session_attrs=read-write",
"passfile=/x/.pgpass",
"sslsni=1",
"sslcompression=0",
"sslcert=/etc/ssl/client.crt",
"sslkey=/etc/ssl/client.key",
"gsslib=gssapi",
"krbsrvname=postgres",
"load_balance_hosts=random",
"fallback_application_name=fb",
"sslkeylogfile=/tmp/k",
"min_protocol_version=3.0",
"channel_binding=prefer",
"gssencmode=disable",
"sslcertmode=disable",
"ssl_min_protocol_version=TLSv1.2",
"ssl_min_protocol_version=tlsv1.2",
"ssl_min_protocol_version=TLSv1",
] {
assert!(
ConnInfo::parse(&format!("host=h {kv}")).is_ok(),
"key=value rejected: {kv}"
);
assert!(
ConnInfo::parse(&format!("postgresql://u@h/db?{kv}")).is_ok(),
"URI rejected: {kv}"
);
}
assert!(ConnInfo::parse("host=h options='-c statement_timeout=0'").is_ok());
assert!(ConnInfo::parse("postgresql://u@h/db?options=-c%20statement_timeout%3D0").is_ok());
}
#[test]
fn sslrootcert_system_raises_sslmode_to_verify_full() {
for dsn in [
"host=h sslrootcert=system",
"postgresql://u@h/db?sslrootcert=system",
] {
let ci = ConnInfo::parse(dsn).unwrap();
assert_eq!(ci.sslmode, SslMode::VerifyFull, "{dsn}");
assert_eq!(ci.sslrootcert.as_deref(), Some("system"), "{dsn}");
}
}
#[test]
fn sslrootcert_system_rejects_weak_sslmode() {
for mode in ["disable", "allow", "prefer", "require"] {
let err = ConnInfo::parse(&format!("host=h sslrootcert=system sslmode={mode}"))
.unwrap_err()
.to_string();
assert!(
err.contains("may not be used with sslrootcert=system"),
"{mode}: {err}"
);
assert!(err.contains(mode), "{mode}: {err}");
assert!(
ConnInfo::parse(&format!(
"postgresql://u@h/db?sslrootcert=system&sslmode={mode}"
))
.is_err(),
"URI accepted sslmode={mode}"
);
}
assert!(ConnInfo::parse("host=h sslrootcert=system requiressl=0").is_err());
}
#[test]
fn sslrootcert_system_accepts_only_verify_full() {
let ci = ConnInfo::parse("host=h sslrootcert=system sslmode=verify-full").unwrap();
assert_eq!(ci.sslmode, SslMode::VerifyFull);
for dsn in [
"host=h sslrootcert=system sslmode=verify-ca",
"postgresql://u@h/db?sslrootcert=system&sslmode=verify-ca",
] {
let err = ConnInfo::parse(dsn)
.expect_err("verify-ca + sslrootcert=system must be refused")
.to_string();
assert!(
err.contains("may not be used with sslrootcert=system"),
"{dsn}: {err}"
);
}
let ci = ConnInfo::parse("host=h sslrootcert=/etc/ssl/ca.pem sslmode=verify-ca").unwrap();
assert_eq!(ci.sslmode, SslMode::VerifyCa);
}
#[test]
fn ordinary_sslrootcert_path_does_not_change_sslmode() {
let ci = ConnInfo::parse("host=h sslrootcert=/etc/ssl/ca.pem").unwrap();
assert_eq!(ci.sslmode, SslMode::Prefer);
let ci = ConnInfo::parse("host=h sslrootcert=/etc/ssl/ca.pem sslmode=require").unwrap();
assert_eq!(ci.sslmode, SslMode::Require);
}
#[test]
fn sslmode_name_round_trips_every_variant() {
for name in [
"disable",
"allow",
"prefer",
"require",
"verify-ca",
"verify-full",
] {
let mode = parse_sslmode(name).expect(name);
assert_eq!(sslmode_name(mode), name);
}
}
#[test]
fn unterminated_quote_is_accepted_truncated_unlike_libpq() {
let ci = ConnInfo::parse("host=h password='trunc").unwrap();
assert_eq!(ci.password.as_deref(), Some("trunc"));
}
#[test]
fn security_relevant_options_we_cannot_honour_are_refused() {
for key in UNSUPPORTED_OPTIONS {
let err = ConnInfo::parse(&format!("host=h {key}=x"))
.unwrap_err()
.to_string();
assert!(err.contains("not supported"), "{key}: {err}");
assert!(!err.contains("invalid connection option"), "{key}: {err}");
assert!(
ConnInfo::parse(&format!("postgresql://u@h/db?{key}=x")).is_err(),
"URI accepted {key}"
);
}
for kv in [
"channel_binding=require",
"gssencmode=require",
"sslcertmode=require",
"ssl_min_protocol_version=TLSv1.3",
"ssl_min_protocol_version=tlsv1.3",
] {
assert!(ConnInfo::parse(&format!("host=h {kv}")).is_err(), "{kv}");
}
}
#[test]
fn unsupported_option_error_never_echoes_the_value() {
const SECRET: &str = "s3cret-scram-client-key-do-not-log";
for key in UNSUPPORTED_OPTIONS {
for dsn in [
format!("host=h {key}={SECRET}"),
format!("postgresql://u@h/db?{key}={SECRET}"),
] {
let err = ConnInfo::parse(&dsn).unwrap_err().to_string();
assert!(err.contains("not supported"), "{key}: {err}");
assert!(
!err.contains(SECRET),
"{key} leaked its value into the error: {err}"
);
}
}
}
#[test]
fn value_sensitive_options_still_report_which_value_was_refused() {
let err = ConnInfo::parse("host=h channel_binding=require")
.unwrap_err()
.to_string();
assert!(err.contains("channel_binding=require"), "{err}");
assert!(ConnInfo::parse("host=h channel_binding=prefer").is_ok());
}
#[test]
fn deprecated_ssl_aliases_are_translated_like_libpq() {
assert!(matches!(
ConnInfo::parse("host=h requiressl=1").unwrap().sslmode,
SslMode::Require
));
assert!(matches!(
ConnInfo::parse("host=h requiressl=0").unwrap().sslmode,
SslMode::Prefer
));
assert!(matches!(
ConnInfo::parse("postgresql://u@h/db?requiressl=1")
.unwrap()
.sslmode,
SslMode::Require
));
assert!(matches!(
ConnInfo::parse("postgresql://u@h/db?ssl=true")
.unwrap()
.sslmode,
SslMode::Require
));
assert!(ConnInfo::parse("host=h ssl=true").is_err());
}
#[test]
fn uri_query_may_carry_the_hierarchical_params() {
let ci = ConnInfo::parse("postgresql:///mydb?host=localhost&port=5433").unwrap();
assert_eq!(ci.host, "localhost");
assert_eq!(ci.port, 5433);
assert_eq!(ci.dbname, "mydb");
let ci = ConnInfo::parse("postgresql://a:5432/db?host=b&port=5433").unwrap();
assert_eq!(ci.host, "b");
assert_eq!(ci.port, 5433);
let ci = ConnInfo::parse("postgresql://h/?user=alice").unwrap();
assert_eq!(ci.user, "alice");
assert_eq!(ci.dbname, "alice");
}
#[test]
fn empty_port_falls_back_to_default() {
assert_eq!(ConnInfo::parse("host=h port=").unwrap().port, 5432);
assert_eq!(ConnInfo::parse("postgresql://u@h:/db").unwrap().port, 5432);
assert!(ConnInfo::parse("host=h connect_timeout=").is_err());
}
#[test]
fn parse_key_value_basic() {
let ci = ConnInfo::parse(
"host=db.example.com port=5433 user=repl password=secret dbname=mydb sslmode=require",
)
.unwrap();
assert_eq!(ci.host, "db.example.com");
assert_eq!(ci.port, 5433);
assert_eq!(ci.user, "repl");
assert_eq!(ci.password, Some("secret".to_string()));
assert_eq!(ci.dbname, "mydb");
assert_eq!(ci.sslmode, SslMode::Require);
}
#[test]
fn parse_key_value_quoted() {
let ci = ConnInfo::parse("host=localhost password='has spaces'").unwrap();
assert_eq!(ci.password, Some("has spaces".to_string()));
}
#[test]
fn parse_key_value_escaped_quotes() {
let ci = ConnInfo::parse("host=localhost password='it''s a test'").unwrap();
assert_eq!(ci.password, Some("it's a test".to_string()));
}
#[test]
fn parse_key_value_multiple_escaped_quotes() {
let ci = ConnInfo::parse("host=localhost password='a''b''c'").unwrap();
assert_eq!(ci.password, Some("a'b'c".to_string()));
}
#[test]
fn test_parse_sslmode_all_variants() {
assert!(matches!(parse_sslmode("disable"), Ok(SslMode::Disable)));
assert!(matches!(parse_sslmode("allow"), Ok(SslMode::Allow)));
assert!(matches!(parse_sslmode("prefer"), Ok(SslMode::Prefer)));
assert!(matches!(parse_sslmode("require"), Ok(SslMode::Require)));
assert!(matches!(parse_sslmode("verify-ca"), Ok(SslMode::VerifyCa)));
assert!(matches!(
parse_sslmode("verify-full"),
Ok(SslMode::VerifyFull)
));
}
#[test]
fn parse_sslmode_rejects_unknown_instead_of_downgrading() {
for bad in ["something_else", "", "verify_full", "REQUIRE", "requirre"] {
assert!(
parse_sslmode(bad).is_err(),
"sslmode {bad:?} must be rejected, not silently downgraded"
);
}
assert!(ConnInfo::parse("host=h sslmode=verify_full").is_err());
assert!(ConnInfo::parse("postgresql://u@h/db?sslmode=verify_full").is_err());
}
#[test]
fn url_decode_produces_raw_octets_not_latin1() {
assert_eq!(url_decode("p%C3%A9ss").as_bytes(), "péss".as_bytes());
assert_eq!(url_decode("%E6%97%A5%E6%9C%AC"), "日本");
assert_eq!(url_decode("100%"), "100%");
assert_eq!(url_decode("a%zz"), "a%zz");
assert_eq!(url_decode("plain"), "plain");
}
#[test]
fn uri_query_without_path_is_parsed() {
let info = ConnInfo::parse("postgresql://u@myhost?sslmode=require").unwrap();
assert_eq!(info.host, "myhost");
assert!(matches!(info.sslmode, SslMode::Require));
let info = ConnInfo::parse("postgres://h?replication=database").unwrap();
assert_eq!(info.host, "h");
assert!(matches!(info.replication, ReplicationMode::Database));
}
#[test]
fn uri_ipv6_host_is_unbracketed() {
let info = ConnInfo::parse("postgresql://u@[::1]/db").unwrap();
assert_eq!(info.host, "::1");
assert_eq!(info.port, 5432);
let info = ConnInfo::parse("postgresql://u@[2001:db8::1]:5433/db").unwrap();
assert_eq!(info.host, "2001:db8::1");
assert_eq!(info.port, 5433);
assert!(ConnInfo::parse("postgresql://u@[::1/db").is_err());
}
#[test]
fn numeric_options_are_validated_not_defaulted() {
for bad in [
"host=h port=99999",
"host=h port=abc",
"host=h port=-1",
"host=h connect_timeout=abc",
"host=h keepalives_idle=x",
] {
assert!(ConnInfo::parse(bad).is_err(), "{bad:?} must be rejected");
}
assert!(ConnInfo::parse("postgresql://u@h:99999/db").is_err());
let info = ConnInfo::parse("host=h port=5433 connect_timeout=10").unwrap();
assert_eq!(info.port, 5433);
assert_eq!(info.connect_timeout, 10);
}
#[test]
fn test_parse_replication_mode_variants() {
assert!(matches!(
parse_replication_mode("database"),
Ok(ReplicationMode::Database)
));
for on in ["true", "yes", "on", "1"] {
assert!(
matches!(parse_replication_mode(on), Ok(ReplicationMode::Physical)),
"{on}"
);
}
for off in ["false", "no", "off", "0"] {
assert!(
matches!(parse_replication_mode(off), Ok(ReplicationMode::None)),
"{off}"
);
}
}
#[test]
fn parse_replication_mode_rejects_unknown() {
for bad in ["dattabase", "", "DATABASE", "unknown"] {
assert!(parse_replication_mode(bad).is_err(), "{bad:?}");
}
assert!(ConnInfo::parse("host=h replication=dattabase").is_err());
assert!(ConnInfo::parse("postgresql://u@h/db?replication=dattabase").is_err());
}
#[test]
fn test_parse_ssl_negotiation_variants() {
assert!(matches!(
parse_ssl_negotiation("direct"),
Ok(SslNegotiation::Direct)
));
assert!(matches!(
parse_ssl_negotiation("postgres"),
Ok(SslNegotiation::Postgres)
));
}
#[test]
fn parse_ssl_negotiation_rejects_unknown() {
for bad in ["drect", "", "DIRECT", "unknown"] {
assert!(parse_ssl_negotiation(bad).is_err(), "{bad:?}");
}
}
#[test]
fn test_parse_uri_postgres_prefix() {
let info = ConnInfo::parse("postgres://user:pass@localhost:5432/mydb").unwrap();
assert_eq!(info.host, "localhost");
assert_eq!(info.port, 5432);
assert_eq!(info.user, "user");
assert_eq!(info.password, Some("pass".to_string()));
assert_eq!(info.dbname, "mydb");
}
#[test]
fn test_parse_uri_no_credentials() {
let info = ConnInfo::parse("postgresql://localhost:5432/mydb").unwrap();
assert_eq!(info.host, "localhost");
assert_eq!(info.user, "postgres"); assert_eq!(info.password, None);
}
#[test]
fn test_parse_uri_user_no_password() {
let info = ConnInfo::parse("postgresql://myuser@localhost:5432/mydb").unwrap();
assert_eq!(info.user, "myuser");
assert_eq!(info.password, None);
}
#[test]
fn test_parse_uri_empty_password() {
let info = ConnInfo::parse("postgresql://myuser:@localhost:5432/mydb").unwrap();
assert_eq!(info.user, "myuser");
assert!(info.password.is_none() || info.password.as_deref() == Some(""));
}
#[test]
fn test_parse_uri_no_database() {
let info = ConnInfo::parse("postgresql://myuser:pass@localhost:5432").unwrap();
assert_eq!(info.dbname, info.user);
}
#[test]
fn test_url_decode_no_encoding() {
assert_eq!(url_decode("hello_world"), "hello_world");
}
#[test]
fn test_url_decode_multiple_encoded() {
assert_eq!(url_decode("%20%40%2F"), " @/");
}
#[test]
fn test_url_decode_plus_sign() {
let result = url_decode("hello+world");
assert!(!result.is_empty());
}
#[test]
fn test_parse_uri_sslrootcert() {
let ci = ConnInfo::parse(
"postgresql://user:pass@host:5432/db?sslmode=verify-ca&sslrootcert=/path/to/ca.pem",
)
.unwrap();
assert_eq!(ci.sslmode, SslMode::VerifyCa);
assert_eq!(ci.sslrootcert, Some("/path/to/ca.pem".to_string()));
}
#[test]
fn test_parse_uri_sslrootcert_encoded() {
let ci = ConnInfo::parse(
"postgresql://user:pass@host/db?sslrootcert=/path%20with%20spaces/ca.pem",
)
.unwrap();
assert_eq!(ci.sslrootcert, Some("/path with spaces/ca.pem".to_string()));
}
#[test]
fn test_parse_uri_no_sslrootcert() {
let ci = ConnInfo::parse("postgresql://user:pass@host/db?sslmode=require").unwrap();
assert!(ci.sslrootcert.is_none());
}
#[test]
fn test_parse_key_value_sslrootcert() {
let ci = ConnInfo::parse(
"host=localhost sslmode=verify-ca sslrootcert=/etc/ssl/certs/ca.pem user=test",
)
.unwrap();
assert_eq!(ci.sslmode, SslMode::VerifyCa);
assert_eq!(ci.sslrootcert, Some("/etc/ssl/certs/ca.pem".to_string()));
}
#[test]
fn test_parse_key_value_sslrootcert_quoted() {
let ci = ConnInfo::parse("host=localhost sslrootcert='/path with spaces/ca.pem'").unwrap();
assert_eq!(ci.sslrootcert, Some("/path with spaces/ca.pem".to_string()));
}
#[test]
fn test_parse_uri_sslnegotiation_direct() {
let ci = ConnInfo::parse(
"postgresql://user:pass@host:5432/db?sslmode=require&sslnegotiation=direct",
)
.unwrap();
assert_eq!(ci.sslnegotiation, SslNegotiation::Direct);
}
#[test]
fn test_parse_uri_sslnegotiation_postgres() {
let ci = ConnInfo::parse(
"postgresql://user:pass@host:5432/db?sslmode=require&sslnegotiation=postgres",
)
.unwrap();
assert_eq!(ci.sslnegotiation, SslNegotiation::Postgres);
}
#[test]
fn test_parse_uri_sslnegotiation_default() {
let ci = ConnInfo::parse("postgresql://user:pass@host:5432/db?sslmode=require").unwrap();
assert_eq!(ci.sslnegotiation, SslNegotiation::Postgres);
}
#[test]
fn test_parse_key_value_sslnegotiation_direct() {
let ci = ConnInfo::parse("host=localhost sslmode=require sslnegotiation=direct user=test")
.unwrap();
assert_eq!(ci.sslnegotiation, SslNegotiation::Direct);
}
#[test]
fn test_parse_key_value_sslnegotiation_default() {
let ci = ConnInfo::parse("host=localhost sslmode=require user=test").unwrap();
assert_eq!(ci.sslnegotiation, SslNegotiation::Postgres);
}
#[test]
fn test_parse_uri_keepalive_params() {
let ci = ConnInfo::parse(
"postgresql://user:pass@host:5432/db?keepalives=1&keepalives_idle=60&keepalives_interval=5&keepalives_count=6",
)
.unwrap();
assert!(ci.keepalives);
assert_eq!(ci.keepalives_idle, 60);
assert_eq!(ci.keepalives_interval, 5);
assert_eq!(ci.keepalives_count, 6);
}
#[test]
fn test_parse_uri_keepalives_disabled() {
let ci = ConnInfo::parse("postgresql://user:pass@host/db?keepalives=0").unwrap();
assert!(!ci.keepalives);
}
#[test]
fn tcp_user_timeout_is_honoured_not_discarded() {
let uri = ConnInfo::parse("postgresql://user:pass@host/db?tcp_user_timeout=15000").unwrap();
assert_eq!(uri.tcp_user_timeout, 15000);
let kv = ConnInfo::parse("host=h user=u dbname=d tcp_user_timeout=15000").unwrap();
assert_eq!(kv.tcp_user_timeout, 15000);
assert_eq!(
ConnInfo::parse("host=h user=u dbname=d")
.unwrap()
.tcp_user_timeout,
0
);
}
#[test]
fn tcp_user_timeout_clamps_negative_and_rejects_garbage() {
assert_eq!(
ConnInfo::parse("host=h user=u dbname=d tcp_user_timeout=-1")
.unwrap()
.tcp_user_timeout,
0
);
assert!(ConnInfo::parse("host=h user=u dbname=d tcp_user_timeout=abc").is_err());
}
#[test]
fn test_parse_uri_connect_timeout() {
let ci = ConnInfo::parse("postgresql://user:pass@host/db?connect_timeout=30").unwrap();
assert_eq!(ci.connect_timeout, 30);
}
#[test]
fn test_parse_key_value_keepalive_params() {
let ci = ConnInfo::parse(
"host=localhost keepalives=1 keepalives_idle=90 keepalives_interval=15 keepalives_count=5 connect_timeout=10",
)
.unwrap();
assert!(ci.keepalives);
assert_eq!(ci.keepalives_idle, 90);
assert_eq!(ci.keepalives_interval, 15);
assert_eq!(ci.keepalives_count, 5);
assert_eq!(ci.connect_timeout, 10);
}
#[test]
fn test_keepalive_defaults() {
let ci = ConnInfo::parse("postgresql://user:pass@host/db").unwrap();
assert!(ci.keepalives);
assert_eq!(ci.keepalives_idle, 120);
assert_eq!(ci.keepalives_interval, 10);
assert_eq!(ci.keepalives_count, 3);
assert_eq!(ci.connect_timeout, 0);
}
#[test]
fn test_debug_redacts_password() {
let ci = ConnInfo::parse("postgresql://user:supersecret@host/db").unwrap();
let debug_output = format!("{:?}", ci);
assert!(
!debug_output.contains("supersecret"),
"Debug output should not contain the password: {debug_output}"
);
assert!(
debug_output.contains("REDACTED"),
"Debug output should contain REDACTED: {debug_output}"
);
}
#[test]
fn test_debug_shows_none_when_no_password() {
let ci = ConnInfo::parse("postgresql://user@host/db").unwrap();
let debug_output = format!("{:?}", ci);
assert!(
debug_output.contains("None"),
"Debug should show None for missing password: {debug_output}"
);
}
}