use crate::text_width::display_width;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::CryptoProvider;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio_postgres::types::ToSql;
use tokio_postgres::{Client, Row};
use tokio_postgres_rustls::MakeRustlsConnect;
pub fn block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to start Tokio runtime")
.block_on(fut)
}
#[derive(Debug)]
pub enum CommandError {
Connect(String),
Other(String),
}
impl CommandError {
const fn exit_code(&self) -> i32 {
match self {
Self::Connect(_) => 2,
Self::Other(_) => 1,
}
}
fn message(&self) -> &str {
match self {
Self::Connect(msg) | Self::Other(msg) => msg,
}
}
}
pub trait ResultExt<T> {
fn pg(self) -> Result<T, CommandError>;
}
impl<T, E: std::fmt::Display> ResultExt<T> for Result<T, E> {
fn pg(self) -> Result<T, CommandError> {
self.map_err(|e| CommandError::Other(e.to_string()))
}
}
#[derive(Debug)]
struct NoServerCertVerification(CryptoProvider);
impl ServerCertVerifier for NoServerCertVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
fn tls_connector() -> MakeRustlsConnect {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = rustls::ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()
.expect("ring provider supports rustls's default protocol versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoServerCertVerification((*provider).clone())))
.with_no_client_auth();
MakeRustlsConnect::new(config)
}
const UNSUPPORTED_SSL_PARAMS: &[&str] = &[
"sslrootcert",
"sslcert",
"sslkey",
"sslpassword",
"sslcrl",
"sslcrldir",
];
fn filter_ssl_params(
pairs: impl Iterator<Item = (String, String)>,
) -> Result<Vec<(String, String)>, CommandError> {
let pairs: Vec<(String, String)> = pairs.collect();
if find_last_pair(&pairs, "sslmode") == Some("require")
&& find_pair(&pairs, "sslrootcert").is_some()
{
return Err(CommandError::Other(
"sslmode=require with sslrootcert is not supported: PostgreSQL treats this \
combination as requiring certificate verification against that CA file, but this \
native connection path doesn't implement CA-based verification. Use sslmode=require \
without sslrootcert to encrypt without verifying the server's certificate, or \
sslmode=disable to connect in plaintext."
.to_owned(),
));
}
if find_pair(&pairs, "sslcert").is_some() || find_pair(&pairs, "sslkey").is_some() {
return Err(CommandError::Other(
"sslcert/sslkey (client-certificate authentication) is not supported: this native \
connection path doesn't load a client certificate for the TLS handshake. Configure \
password-based authentication instead, or omit sslcert/sslkey/sslpassword."
.to_owned(),
));
}
let mut out = Vec::new();
for (key, value) in pairs {
if UNSUPPORTED_SSL_PARAMS.contains(&key.as_str()) {
continue;
}
if key == "sslmode" && matches!(value.as_str(), "verify-ca" | "verify-full") {
return Err(CommandError::Other(format!(
"sslmode={value} is not supported: certificate verification isn't implemented \
for this native connection path. Use sslmode=require to encrypt without \
verifying the server's certificate, or sslmode=disable to connect in plaintext."
)));
}
if key == "sslmode" && value == "allow" {
return Err(CommandError::Other(
"sslmode=allow is not supported: this native connection path only implements \
disable/prefer/require semantics. Use sslmode=prefer to negotiate TLS \
opportunistically (preferring it, rather than allow's plaintext-first order), \
or sslmode=require to mandate it."
.to_owned(),
));
}
out.push((key, value));
}
Ok(out)
}
pub fn parse_keyword_value_pairs(s: &str) -> Option<Vec<(String, String)>> {
let mut pairs = Vec::new();
let mut chars = s.char_indices().peekable();
loop {
while matches!(chars.peek(), Some((_, c)) if c.is_whitespace()) {
chars.next();
}
let Some(&(key_start, _)) = chars.peek() else {
break;
};
while matches!(chars.peek(), Some((_, c)) if !c.is_whitespace() && *c != '=') {
chars.next();
}
let key_end = chars.peek().map_or(s.len(), |&(i, _)| i);
if key_end == key_start {
return None;
}
let key = &s[key_start..key_end];
while matches!(chars.peek(), Some((_, c)) if c.is_whitespace()) {
chars.next();
}
if chars.next().map(|(_, c)| c) != Some('=') {
return None;
}
while matches!(chars.peek(), Some((_, c)) if c.is_whitespace()) {
chars.next();
}
let mut value = String::new();
if matches!(chars.peek(), Some((_, '\''))) {
chars.next();
let mut terminated = false;
while let Some((_, c)) = chars.next() {
if c == '\'' {
terminated = true;
break;
}
if c == '\\' {
if let Some((_, c2)) = chars.next() {
value.push(c2);
}
} else {
value.push(c);
}
}
if !terminated {
return None;
}
} else {
while matches!(chars.peek(), Some((_, c)) if !c.is_whitespace()) {
let (_, c) = chars.next().unwrap();
if c == '\\' {
if let Some((_, c2)) = chars.next() {
value.push(c2);
}
} else {
value.push(c);
}
}
if value.is_empty() {
return None;
}
}
pairs.push((key.to_owned(), value));
}
if pairs.is_empty() { None } else { Some(pairs) }
}
fn percent_decode(s: &str) -> String {
percent_encoding::percent_decode_str(s)
.decode_utf8_lossy()
.into_owned()
}
fn translate_jdbc_ssl_param(pairs: &mut [(String, String)]) {
for (key, value) in pairs.iter_mut() {
if key == "ssl" && value == "true" {
"sslmode".clone_into(key);
"require".clone_into(value);
}
}
}
fn translate_requiressl_param(pairs: &mut Vec<(String, String)>) {
let requiressl = take_pair(pairs, "requiressl");
if find_pair(pairs, "sslmode").is_none()
&& let Some(value) = requiressl
{
let sslmode = if value == "1" { "require" } else { "prefer" };
pairs.push(("sslmode".to_owned(), sslmode.to_owned()));
}
}
pub fn parse_raw_query_pairs(query: &str) -> Vec<(String, String)> {
query
.split('&')
.filter(|segment| !segment.is_empty())
.map(|segment| match segment.split_once('=') {
Some((key, value)) => (percent_decode(key), percent_decode(value)),
None => (percent_decode(segment), String::new()),
})
.collect()
}
pub fn query_value_token(s: &str) -> String {
const RESERVED: percent_encoding::AsciiSet = percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
percent_encoding::utf8_percent_encode(s, &RESERVED).to_string()
}
fn keyword_value_token(key: &str, value: &str) -> String {
if value.is_empty() || value.contains(|c: char| c.is_whitespace() || c == '\'' || c == '\\') {
let escaped = value.replace('\\', "\\\\").replace('\'', "\\'");
format!("{key}='{escaped}'")
} else {
format!("{key}={value}")
}
}
fn take_pair(pairs: &mut Vec<(String, String)>, key: &str) -> Option<String> {
let mut result = None;
pairs.retain(|(k, v)| {
if k == key {
result = Some(v.clone());
false
} else {
true
}
});
result
}
#[allow(clippy::struct_excessive_bools)] struct ExplicitFields {
host: bool,
hostaddr: bool,
port: bool,
user: bool,
dbname: bool,
}
fn explicit_fields(is_set: impl Fn(&str) -> bool) -> ExplicitFields {
ExplicitFields {
host: is_set("host"),
hostaddr: is_set("hostaddr"),
port: is_set("port"),
user: is_set("user"),
dbname: is_set("dbname"),
}
}
fn fill_in_libpq_env_defaults(
pairs: &mut Vec<(String, String)>,
explicit: &ExplicitFields,
) -> bool {
if !explicit.host
&& let Ok(pghost) = std::env::var("PGHOST")
{
pairs.push(("host".to_owned(), pghost));
}
if !explicit.hostaddr
&& let Ok(pghostaddr) = std::env::var("PGHOSTADDR")
{
pairs.push(("hostaddr".to_owned(), pghostaddr));
}
let injected_port = if !explicit.port
&& let Ok(pgport) = std::env::var("PGPORT")
{
pairs.push(("port".to_owned(), pgport));
true
} else {
false
};
if !explicit.user
&& let Ok(pguser) = std::env::var("PGUSER")
{
pairs.push(("user".to_owned(), pguser));
}
if !explicit.dbname
&& let Ok(pgdatabase) = std::env::var("PGDATABASE")
{
pairs.push(("dbname".to_owned(), pgdatabase));
}
fill_in_simple_libpq_env_fallbacks(pairs);
injected_port
}
const SIMPLE_LIBPQ_ENV_FALLBACKS: &[(&str, &str)] = &[
("connect_timeout", "PGCONNECT_TIMEOUT"),
("target_session_attrs", "PGTARGETSESSIONATTRS"),
("channel_binding", "PGCHANNELBINDING"),
("options", "PGOPTIONS"),
("application_name", "PGAPPNAME"),
("load_balance_hosts", "PGLOADBALANCEHOSTS"),
("sslnegotiation", "PGSSLNEGOTIATION"),
];
fn fill_in_simple_libpq_env_fallbacks(pairs: &mut Vec<(String, String)>) {
for (param, env_var) in SIMPLE_LIBPQ_ENV_FALLBACKS {
if find_pair(pairs, param).is_none()
&& let Ok(value) = std::env::var(env_var)
{
pairs.push(((*param).to_owned(), value));
}
}
}
fn fill_in_libpq_ssl_env_defaults(pairs: &mut Vec<(String, String)>) {
if find_pair(pairs, "sslmode").is_none()
&& let Ok(pgsslmode) = std::env::var("PGSSLMODE")
{
pairs.push(("sslmode".to_owned(), pgsslmode));
}
if find_pair(pairs, "sslmode").is_none() && std::env::var("PGREQUIRESSL").as_deref() == Ok("1")
{
pairs.push(("sslmode".to_owned(), "require".to_owned()));
}
if find_pair(pairs, "sslrootcert").is_none()
&& let Ok(pgsslrootcert) = std::env::var("PGSSLROOTCERT")
{
pairs.push(("sslrootcert".to_owned(), pgsslrootcert));
}
if find_pair(pairs, "sslcert").is_none()
&& let Ok(pgsslcert) = std::env::var("PGSSLCERT")
{
pairs.push(("sslcert".to_owned(), pgsslcert));
}
if find_pair(pairs, "sslkey").is_none()
&& let Ok(pgsslkey) = std::env::var("PGSSLKEY")
{
pairs.push(("sslkey".to_owned(), pgsslkey));
}
}
fn find_pair<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
fn find_last_pair<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
pairs
.iter()
.rev()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn current_os_user() -> String {
whoami::username().unwrap_or_else(|_| "postgres".to_owned())
}
fn parse_pgpass_line(line: &str) -> Option<[String; 5]> {
let mut fields = Vec::with_capacity(5);
let mut current = String::new();
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && matches!(chars.peek(), Some(':' | '\\')) {
current.push(chars.next().unwrap());
} else if c == ':' {
fields.push(std::mem::take(&mut current));
} else {
current.push(c);
}
}
fields.push(current);
fields.try_into().ok()
}
fn pgpass_lookup(
contents: &str,
host: &str,
port: &str,
dbname: &str,
user: &str,
) -> Option<String> {
let field_matches = |field: &str, value: &str| field == "*" || field == value;
contents.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let [f_host, f_port, f_dbname, f_user, f_password] = parse_pgpass_line(trimmed)?;
(field_matches(&f_host, host)
&& field_matches(&f_port, port)
&& field_matches(&f_dbname, dbname)
&& field_matches(&f_user, user))
.then_some(f_password)
})
}
#[cfg(not(windows))]
fn pgpass_file_path(passfile_override: Option<&str>) -> Option<PathBuf> {
if let Some(path) = passfile_override {
return Some(PathBuf::from(path));
}
if let Ok(path) = std::env::var("PGPASSFILE") {
return Some(PathBuf::from(path));
}
Some(directories::BaseDirs::new()?.home_dir().join(".pgpass"))
}
#[cfg(windows)]
fn pgpass_file_path(passfile_override: Option<&str>) -> Option<PathBuf> {
if let Some(path) = passfile_override {
return Some(PathBuf::from(path));
}
if let Ok(path) = std::env::var("PGPASSFILE") {
return Some(PathBuf::from(path));
}
Some(
directories::BaseDirs::new()?
.config_dir()
.join("postgresql")
.join("pgpass.conf"),
)
}
#[cfg(unix)]
fn pgpass_permissions_ok(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).is_ok_and(|meta| meta.permissions().mode().trailing_zeros() >= 6)
}
#[cfg(not(unix))]
const fn pgpass_permissions_ok(_path: &Path) -> bool {
true
}
fn pgpass_password(
host: &str,
port: &str,
dbname: &str,
user: &str,
passfile_override: Option<&str>,
) -> Option<String> {
let path = pgpass_file_path(passfile_override)?;
if !path.is_file() {
return None;
}
if !pgpass_permissions_ok(&path) {
eprintln!(
"autumn: ignoring {} — it has group or world access; permissions should be u=rw (0600) or less",
path.display()
);
return None;
}
let contents = std::fs::read_to_string(&path).ok()?;
pgpass_lookup(&contents, host, port, dbname, user)
}
fn resolve_password(
host: &str,
port: &str,
dbname: &str,
user: &str,
passfile_override: Option<&str>,
) -> Option<String> {
std::env::var("PGPASSWORD")
.ok()
.or_else(|| pgpass_password(host, port, dbname, user, passfile_override))
}
fn fill_in_missing_password(
pairs: &mut Vec<(String, String)>,
is_explicit_password: bool,
explicit_host: Option<&str>,
explicit_port: Option<&str>,
explicit_user: Option<&str>,
explicit_dbname: Option<&str>,
passfile: Option<&str>,
) {
let host = explicit_host
.map(str::to_owned)
.or_else(|| find_last_pair(pairs, "host").map(str::to_owned))
.or_else(|| find_last_pair(pairs, "hostaddr").map(str::to_owned))
.unwrap_or_else(|| "localhost".to_owned());
let port = explicit_port
.map(str::to_owned)
.or_else(|| find_last_pair(pairs, "port").map(str::to_owned))
.unwrap_or_else(|| "5432".to_owned());
let user = explicit_user
.map(str::to_owned)
.or_else(|| find_last_pair(pairs, "user").map(str::to_owned))
.unwrap_or_else(current_os_user);
let dbname = explicit_dbname
.map(str::to_owned)
.or_else(|| find_last_pair(pairs, "dbname").map(str::to_owned))
.unwrap_or_else(|| user.clone());
if !is_explicit_password
&& let Some(password) = resolve_password(&host, &port, &dbname, &user, passfile)
{
pairs.push(("password".to_owned(), password));
}
}
fn parse_service_file(contents: &str, service_name: &str) -> Option<Vec<(String, String)>> {
let mut pairs = Vec::new();
let mut in_section = false;
let mut found = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if let Some(name) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
in_section = name == service_name;
found |= in_section;
continue;
}
if in_section && let Some((key, value)) = trimmed.split_once('=') {
pairs.push((key.trim().to_owned(), value.trim().to_owned()));
}
}
found.then_some(pairs)
}
#[cfg(not(windows))]
fn service_file_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("PGSERVICEFILE") {
return Some(PathBuf::from(path));
}
Some(
directories::BaseDirs::new()?
.home_dir()
.join(".pg_service.conf"),
)
}
#[cfg(windows)]
fn service_file_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("PGSERVICEFILE") {
return Some(PathBuf::from(path));
}
Some(
directories::BaseDirs::new()?
.config_dir()
.join("postgresql")
.join(".pg_service.conf"),
)
}
fn system_service_file_path() -> Option<PathBuf> {
std::env::var("PGSYSCONFDIR")
.ok()
.map(|dir| PathBuf::from(dir).join("pg_service.conf"))
}
fn load_service(service_name: &str) -> Result<Vec<(String, String)>, CommandError> {
let mut checked_paths = Vec::new();
if let Some(path) = service_file_path() {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Some(pairs) = parse_service_file(&contents, service_name)
{
return Ok(pairs);
}
checked_paths.push(path);
}
if let Some(path) = system_service_file_path() {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Some(pairs) = parse_service_file(&contents, service_name)
{
return Ok(pairs);
}
checked_paths.push(path);
}
if checked_paths.is_empty() {
return Err(CommandError::Other(format!(
"service={service_name} was requested but no service file could be located \
(set PGSERVICEFILE or PGSYSCONFDIR, or create ~/.pg_service.conf)"
)));
}
Err(CommandError::Other(format!(
"service \"{service_name}\" was not found in {}",
checked_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" or ")
)))
}
fn sanitize_db_url(db_url: &str) -> Result<String, CommandError> {
if let Ok(url) = url::Url::parse(db_url) {
return sanitize_url_form(url);
}
if let Some(pairs) = parse_keyword_value_pairs(db_url) {
return sanitize_keyword_form(pairs);
}
Ok(db_url.to_owned())
}
fn sanitize_url_form(mut url: url::Url) -> Result<String, CommandError> {
let mut pairs: Vec<(String, String)> = parse_raw_query_pairs(url.query().unwrap_or(""));
translate_jdbc_ssl_param(&mut pairs);
let explicit_user = find_last_pair(&pairs, "user")
.map(str::to_owned)
.or_else(|| {
Some(url.username())
.filter(|u| !u.is_empty())
.map(percent_decode)
});
let explicit_password = find_last_pair(&pairs, "password")
.map(str::to_owned)
.or_else(|| url.password().map(percent_decode));
let explicit_host = find_last_pair(&pairs, "host")
.map(str::to_owned)
.or_else(|| url.host_str().filter(|h| !h.is_empty()).map(percent_decode));
let explicit_port = find_last_pair(&pairs, "port")
.map(str::to_owned)
.or_else(|| url.port().map(|p| p.to_string()));
let explicit_dbname = find_last_pair(&pairs, "dbname")
.map(str::to_owned)
.or_else(|| {
Some(url.path())
.filter(|p| p.len() > 1)
.map(|p| percent_decode(p.trim_start_matches('/')))
});
let is_explicit = |pairs: &[(String, String)], key: &str| match key {
"user" => explicit_user.is_some() || find_pair(pairs, "user").is_some(),
"password" => explicit_password.is_some() || find_pair(pairs, "password").is_some(),
"host" => explicit_host.is_some() || find_pair(pairs, "host").is_some(),
"port" => explicit_port.is_some() || find_pair(pairs, "port").is_some(),
"dbname" => explicit_dbname.is_some() || find_pair(pairs, "dbname").is_some(),
_ => find_pair(pairs, key).is_some(),
};
let mut needs_keyword_form = url.host_str().is_some_and(|h| !h.is_empty())
&& (find_pair(&pairs, "port").is_some() || find_pair(&pairs, "host").is_some());
let service_name = take_pair(&mut pairs, "service").or_else(|| std::env::var("PGSERVICE").ok());
if let Some(service_name) = service_name {
for (key, value) in load_service(&service_name)? {
if !is_explicit(&pairs, &key) {
needs_keyword_form |= key == "host" || key == "port";
pairs.push((key, value));
}
}
}
translate_requiressl_param(&mut pairs);
let passfile = take_pair(&mut pairs, "passfile");
let explicit_fields = explicit_fields(|key| is_explicit(&pairs, key));
needs_keyword_form |= fill_in_libpq_env_defaults(&mut pairs, &explicit_fields);
fill_in_libpq_ssl_env_defaults(&mut pairs);
needs_keyword_form |= find_pair(&pairs, "host").is_some_and(|h| h.contains(','));
let is_explicit_password = is_explicit(&pairs, "password");
fill_in_missing_password(
&mut pairs,
is_explicit_password,
explicit_host.as_deref(),
explicit_port.as_deref(),
explicit_user.as_deref(),
explicit_dbname.as_deref(),
passfile.as_deref(),
);
pairs = filter_ssl_params(pairs.into_iter())?;
if needs_keyword_form {
for (key, value) in [
("user", explicit_user),
("password", explicit_password),
("host", explicit_host),
("port", explicit_port),
("dbname", explicit_dbname),
] {
if let Some(value) = value
&& find_pair(&pairs, key).is_none()
{
pairs.push((key.to_owned(), value));
}
}
return Ok(pairs
.iter()
.map(|(key, value)| keyword_value_token(key, value))
.collect::<Vec<_>>()
.join(" "));
}
if pairs.is_empty() {
url.set_query(None);
} else {
let query = pairs
.iter()
.map(|(key, value)| format!("{}={}", query_value_token(key), query_value_token(value)))
.collect::<Vec<_>>()
.join("&");
url.set_query(Some(&query));
}
Ok(url.into())
}
fn sanitize_keyword_form(mut pairs: Vec<(String, String)>) -> Result<String, CommandError> {
let service_name = take_pair(&mut pairs, "service").or_else(|| std::env::var("PGSERVICE").ok());
if let Some(service_name) = service_name {
for (key, value) in load_service(&service_name)? {
if find_pair(&pairs, &key).is_none() {
pairs.push((key, value));
}
}
}
translate_requiressl_param(&mut pairs);
let passfile = take_pair(&mut pairs, "passfile");
let explicit_fields = explicit_fields(|key| find_pair(&pairs, key).is_some());
fill_in_libpq_env_defaults(&mut pairs, &explicit_fields);
fill_in_libpq_ssl_env_defaults(&mut pairs);
let mut pairs = filter_ssl_params(pairs.into_iter())?;
let is_explicit_password = find_pair(&pairs, "password").is_some();
fill_in_missing_password(
&mut pairs,
is_explicit_password,
None,
None,
None,
None,
passfile.as_deref(),
);
Ok(pairs
.iter()
.map(|(key, value)| keyword_value_token(key, value))
.collect::<Vec<_>>()
.join(" "))
}
pub async fn connect(label: &str, db_url: &str) -> Result<Client, CommandError> {
let db_url = sanitize_db_url(db_url)?;
let (client, connection) = tokio_postgres::connect(&db_url, tls_connector())
.await
.map_err(|e| CommandError::Connect(format!("failed to connect to the database: {e}")))?;
let label = label.to_owned();
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("autumn {label}: connection error: {e}");
}
});
Ok(client)
}
pub fn block_on_or_die<T>(
label: &str,
fut: impl std::future::Future<Output = Result<T, CommandError>>,
) -> T {
match block_on(fut) {
Ok(value) => value,
Err(e) => {
eprintln!("autumn {label}: {}", e.message());
std::process::exit(e.exit_code());
}
}
}
pub fn die(label: &str, err: impl std::fmt::Display) -> ! {
eprintln!("autumn {label}: {err}");
std::process::exit(1);
}
pub async fn execute_with_audit(
client: &mut Client,
mutation_sql: &str,
mutation_params: &[&(dyn ToSql + Sync)],
not_found_message: Option<&str>,
audit_sql: &str,
audit_params: &[&(dyn ToSql + Sync)],
) -> Result<(), CommandError> {
execute_many_with_audit(
client,
&[(mutation_sql, mutation_params)],
not_found_message,
audit_sql,
audit_params,
)
.await
}
pub async fn execute_many_with_audit(
client: &mut Client,
mutation_statements: &[(&str, &[&(dyn ToSql + Sync)])],
not_found_message: Option<&str>,
audit_sql: &str,
audit_params: &[&(dyn ToSql + Sync)],
) -> Result<(), CommandError> {
let txn = client.transaction().await.pg()?;
let mut affected = 0;
for (sql, params) in mutation_statements {
affected = txn.execute(*sql, params).await.pg()?;
}
if affected == 0
&& let Some(msg) = not_found_message
{
return Err(CommandError::Other(msg.to_owned()));
}
txn.execute(audit_sql, audit_params).await.pg()?;
txn.commit().await.pg()
}
pub fn row_to_strings(row: &Row) -> Vec<Option<String>> {
(0..row.len())
.map(|i| row.get::<_, Option<String>>(i))
.collect()
}
pub fn format_table(headers: &[&str], rows: &[Vec<Option<String>>]) -> String {
let mut widths: Vec<usize> = headers.iter().map(|h| display_width(h)).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(display_width(cell.as_deref().unwrap_or("")));
}
}
let format_row = |cells: &[&str]| -> String {
cells
.iter()
.enumerate()
.map(|(i, cell)| format!("{cell:width$}", width = widths[i]))
.collect::<Vec<_>>()
.join(" | ")
.trim_end()
.to_owned()
};
let mut out = String::new();
out.push_str(&format_row(headers));
out.push('\n');
out.push_str(
&widths
.iter()
.map(|w| "-".repeat(*w))
.collect::<Vec<_>>()
.join("-+-"),
);
out.push('\n');
for row in rows {
let cells: Vec<&str> = row.iter().map(|c| c.as_deref().unwrap_or("")).collect();
out.push_str(&format_row(&cells));
out.push('\n');
}
out
}
pub fn print_table(headers: &[&str], rows: &[Vec<Option<String>>]) {
print!("{}", format_table(headers, rows));
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn set_pgpass_permissions_safe(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[cfg(not(unix))]
fn set_pgpass_permissions_safe(_path: &std::path::Path) {}
#[test]
fn print_table_smoke_does_not_panic_on_empty_rows() {
print_table(&["key", "value"], &[]);
}
#[test]
fn tls_connector_builds_without_panicking() {
let _ = tls_connector();
}
#[test]
fn sanitize_drops_unsupported_ssl_params_from_url() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let sanitized = sanitize_db_url(
"postgres://user:pw@host/db?sslmode=prefer&sslrootcert=/etc/ca.pem&connect_timeout=5",
)
.unwrap();
let parsed = url::Url::parse(&sanitized).unwrap();
let pairs: std::collections::HashMap<_, _> =
parsed.query_pairs().into_owned().collect();
assert_eq!(pairs.get("sslmode").map(String::as_str), Some("prefer"));
assert!(!pairs.contains_key("sslrootcert"));
assert_eq!(pairs.get("connect_timeout").map(String::as_str), Some("5"));
},
);
}
#[test]
fn sanitize_rejects_require_with_sslrootcert_in_url_form() {
let err = sanitize_db_url("postgres://host/db?sslmode=require&sslrootcert=/etc/ca.pem")
.unwrap_err();
assert!(err.message().contains("sslrootcert"));
assert!(err.message().contains("require"));
}
#[test]
fn sanitize_rejects_require_with_sslrootcert_in_url_form_regardless_of_param_order() {
let err = sanitize_db_url("postgres://host/db?sslrootcert=/etc/ca.pem&sslmode=require")
.unwrap_err();
assert!(err.message().contains("sslrootcert"));
}
#[test]
fn sanitize_rejects_require_with_sslrootcert_when_an_earlier_sslmode_shadows_it() {
let err = sanitize_db_url(
"postgres://host/db?sslmode=prefer&sslrootcert=/etc/ca.pem&sslmode=require",
)
.unwrap_err();
assert!(err.message().contains("sslrootcert"));
assert!(err.message().contains("require"));
}
#[test]
fn sanitize_allows_prefer_with_sslrootcert_in_url_form() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let sanitized =
sanitize_db_url("postgres://host/db?sslmode=prefer&sslrootcert=/etc/ca.pem")
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Prefer
);
},
);
}
#[test]
fn sanitize_uses_pgsslmode_env_var_when_url_has_no_inline_sslmode() {
temp_env::with_var("PGSSLMODE", Some("require"), || {
let sanitized = sanitize_db_url("postgres://host/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Require
);
});
}
#[test]
fn sanitize_uses_pgsslmode_env_var_in_keyword_form_too() {
temp_env::with_var("PGSSLMODE", Some("require"), || {
let sanitized = sanitize_db_url("host=host dbname=db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Require
);
});
}
#[test]
fn sanitize_prefers_inline_sslmode_over_pgsslmode_env_var() {
temp_env::with_var("PGSSLMODE", Some("require"), || {
let sanitized = sanitize_db_url("postgres://host/db?sslmode=disable").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Disable
);
});
}
#[test]
fn sanitize_rejects_verify_full_from_pgsslmode_env_var() {
temp_env::with_vars(
[
("PGSSLMODE", Some("verify-full")),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let err = sanitize_db_url("postgres://host/db").unwrap_err();
assert!(err.message().contains("verify-full"));
},
);
}
#[test]
fn sanitize_uses_pgrequiressl_env_var_when_url_has_no_inline_sslmode() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGREQUIRESSL", Some("1")),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Require
);
},
);
}
#[test]
fn sanitize_ignores_pgrequiressl_zero() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGREQUIRESSL", Some("0")),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Prefer
);
},
);
}
#[test]
fn sanitize_suppresses_pgrequiressl_when_pgsslmode_is_also_set() {
temp_env::with_vars(
[
("PGSSLMODE", Some("disable")),
("PGREQUIRESSL", Some("1")),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Disable
);
},
);
}
#[test]
fn sanitize_prefers_inline_sslmode_over_pgrequiressl_env_var() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGREQUIRESSL", Some("1")),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?sslmode=disable").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Disable
);
},
);
}
#[test]
fn sanitize_rejects_require_pgsslmode_combined_with_pgsslrootcert_env_var() {
temp_env::with_vars(
[
("PGSSLMODE", Some("require")),
("PGSSLROOTCERT", Some("/etc/ca.pem")),
],
|| {
let err = sanitize_db_url("postgres://host/db").unwrap_err();
assert!(err.message().contains("sslrootcert"));
},
);
}
#[test]
fn sanitize_rejects_require_pgsslmode_combined_with_pgsslrootcert_env_var_in_keyword_form() {
temp_env::with_vars(
[
("PGSSLMODE", Some("require")),
("PGSSLROOTCERT", Some("/etc/ca.pem")),
],
|| {
let err = sanitize_db_url("host=host dbname=db").unwrap_err();
assert!(err.message().contains("sslrootcert"));
},
);
}
#[test]
fn sanitize_ignores_pgsslrootcert_env_var_when_effective_sslmode_is_not_require() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", Some("/etc/ca.pem")),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?sslmode=prefer").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Prefer
);
},
);
}
#[test]
fn sanitize_does_not_override_explicit_inline_sslrootcert_with_pgsslrootcert_env_var() {
temp_env::with_var("PGSSLROOTCERT", Some("/etc/env-ca.pem"), || {
let sanitized =
sanitize_db_url("postgres://host/db?sslmode=prefer&sslrootcert=/etc/inline-ca.pem")
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Prefer
);
});
}
#[test]
fn sanitize_rejects_verify_full_in_url_form_instead_of_downgrading() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let err = sanitize_db_url("postgres://host/db?sslmode=verify-full").unwrap_err();
assert!(err.message().contains("verify-full"));
},
);
}
#[test]
fn sanitize_rejects_verify_ca_in_url_form_instead_of_downgrading() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let err = sanitize_db_url("postgres://host/db?sslmode=verify-ca").unwrap_err();
assert!(err.message().contains("verify-ca"));
},
);
}
#[test]
fn sanitize_translates_jdbc_ssl_true_to_sslmode_require() {
temp_env::with_vars(
[
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?ssl=true").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Require
);
},
);
}
#[test]
fn sanitize_does_not_let_jdbc_ssl_true_override_an_explicit_sslmode() {
temp_env::with_vars(
[
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres://host/db?ssl=true&sslmode=disable").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Disable
);
},
);
}
#[test]
fn sanitize_leaves_non_true_ssl_value_alone() {
temp_env::with_vars(
[
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?ssl=false").unwrap();
let parsed: Result<tokio_postgres::Config, _> = sanitized.parse();
assert!(parsed.is_err());
},
);
}
#[test]
fn sanitize_translates_inline_requiressl_one_to_sslmode_require_in_url_form() {
temp_env::with_vars(
[
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?requiressl=1").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Require
);
},
);
}
#[test]
fn sanitize_translates_inline_requiressl_zero_to_sslmode_prefer_in_keyword_form() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let sanitized = sanitize_db_url("host=host dbname=db requiressl=0").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Prefer
);
},
);
}
#[test]
fn sanitize_does_not_let_requiressl_override_an_explicit_sslmode() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let sanitized =
sanitize_db_url("postgres://host/db?requiressl=1&sslmode=disable").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Disable
);
},
);
}
#[test]
fn sanitize_translates_requiressl_contributed_by_a_service_group() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nrequiressl=1\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Require
);
},
);
}
#[test]
fn sanitize_rejects_sslmode_allow_with_a_clear_message() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let err = sanitize_db_url("postgres://host/db?sslmode=allow").unwrap_err();
assert!(err.message().contains("sslmode=allow"));
assert!(err.message().contains("prefer"));
let err = sanitize_db_url("host=localhost sslmode=allow").unwrap_err();
assert!(err.message().contains("sslmode=allow"));
},
);
}
#[test]
fn sanitize_leaves_ordinary_url_unchanged_in_content() {
temp_env::with_vars(
[("PGSSLROOTCERT", None::<&str>), ("PGSSLMODE", None::<&str>)],
|| {
let sanitized =
sanitize_db_url("postgres://user:pw@host:5432/db?sslmode=require").unwrap();
let parsed = url::Url::parse(&sanitized).unwrap();
let pairs: std::collections::HashMap<_, _> =
parsed.query_pairs().into_owned().collect();
assert_eq!(pairs.get("sslmode").map(String::as_str), Some("require"));
},
);
}
#[test]
fn sanitize_rebuilds_as_keyword_form_when_port_is_a_query_parameter() {
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://db.internal/app?port=6543").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[6543]);
assert_eq!(config.get_dbname(), Some("app"));
},
);
}
#[test]
fn sanitize_rebuilds_as_keyword_form_when_port_is_a_query_parameter_alongside_an_authority_port()
{
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized =
sanitize_db_url("postgres://db.internal:5432/app?port=6543").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[6543]);
},
);
}
#[test]
fn sanitize_rebuilds_as_keyword_form_when_query_host_is_comma_separated() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres:///db?host=host1,host2").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[
tokio_postgres::config::Host::Tcp("host1".to_owned()),
tokio_postgres::config::Host::Tcp("host2".to_owned()),
]
);
},
);
}
#[test]
fn sanitize_rebuilds_as_keyword_form_when_query_host_overrides_a_non_empty_authority_host() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres://authorityhost/db?host=queryhost").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("queryhost".to_owned())]
);
},
);
}
#[test]
fn sanitize_rebuilds_as_keyword_form_when_pghost_env_var_is_comma_separated() {
temp_env::with_var("PGHOST", Some("host1,host2"), || {
let sanitized = sanitize_db_url("postgres:///db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[
tokio_postgres::config::Host::Tcp("host1".to_owned()),
tokio_postgres::config::Host::Tcp("host2".to_owned()),
]
);
});
}
#[test]
fn sanitize_prefers_query_user_password_dbname_over_url_structure() {
temp_env::with_var("PGSERVICE", None::<&str>, || {
let sanitized = sanitize_db_url(
"postgres://alice:secret1@db.internal/app1?user=bob&password=secret2&dbname=app2",
)
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_user(), Some("bob"));
assert_eq!(config.get_password(), Some(b"secret2".as_slice()));
assert_eq!(config.get_dbname(), Some("app2"));
});
}
#[test]
fn sanitize_uses_query_user_for_pgpass_lookup_not_url_userinfo() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join(".pgpass");
std::fs::write(
&pgpass_path,
"db.internal:5432:app:alice:wrong_password\n\
db.internal:5432:app:bob:right_password\n",
)
.unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres://alice@db.internal/app?user=bob").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_user(), Some("bob"));
assert_eq!(config.get_password(), Some(b"right_password".as_slice()));
},
);
}
#[test]
fn sanitize_uses_query_host_for_pgpass_lookup_not_url_authority() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join(".pgpass");
std::fs::write(
&pgpass_path,
"placeholder:5432:app:postgres:wrong_password\n\
actual-db:5432:app:postgres:right_password\n",
)
.unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres://postgres@placeholder/app?host=actual-db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("actual-db".to_owned())]
);
assert_eq!(config.get_password(), Some(b"right_password".as_slice()));
},
);
}
#[test]
fn sanitize_uses_query_port_for_pgpass_lookup_not_url_authority() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join(".pgpass");
std::fs::write(
&pgpass_path,
"db.internal:5432:app:postgres:wrong_password\n\
db.internal:6543:app:postgres:right_password\n",
)
.unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres://postgres@db.internal:5432/app?port=6543").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[6543]);
assert_eq!(config.get_password(), Some(b"right_password".as_slice()));
},
);
}
#[test]
fn sanitize_uses_last_occurrence_of_repeated_user_for_pgpass_lookup_in_keyword_form() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join(".pgpass");
std::fs::write(
&pgpass_path,
"db.internal:5432:app:alice:wrong_password\n\
db.internal:5432:app:bob:right_password\n",
)
.unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("host=db.internal dbname=app user=alice user=bob").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_user(), Some("bob"));
assert_eq!(config.get_password(), Some(b"right_password".as_slice()));
},
);
}
#[test]
fn sanitize_uses_last_occurrence_of_repeated_dbname_for_pgpass_lookup_in_url_form() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join(".pgpass");
std::fs::write(
&pgpass_path,
"db.internal:5432:old:postgres:wrong_password\n\
db.internal:5432:new:postgres:right_password\n",
)
.unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres://postgres@db.internal/?dbname=old&dbname=new")
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("new"));
assert_eq!(config.get_password(), Some(b"right_password".as_slice()));
},
);
}
#[test]
fn sanitize_drops_ca_and_crl_ssl_params_from_url() {
temp_env::with_vars(
[
("PGHOST", None::<&str>),
("PGHOSTADDR", None::<&str>),
("PGPORT", None::<&str>),
("PGUSER", None::<&str>),
("PGDATABASE", None::<&str>),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-file-for-test")),
("PGSERVICEFILE", None::<&str>),
("PGSERVICE", None::<&str>),
("PGSYSCONFDIR", None::<&str>),
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
("PGCONNECT_TIMEOUT", None::<&str>),
("PGTARGETSESSIONATTRS", None::<&str>),
("PGCHANNELBINDING", None::<&str>),
("PGOPTIONS", None::<&str>),
("PGAPPNAME", None::<&str>),
("PGLOADBALANCEHOSTS", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres://host/db?sslcrl=/crl.pem&sslcrldir=/crls").unwrap();
let parsed = url::Url::parse(&sanitized).unwrap();
assert_eq!(parsed.query_pairs().count(), 0);
},
);
}
#[test]
fn sanitize_rejects_sslcert_in_url_form_instead_of_silently_dropping() {
temp_env::with_vars(
[("PGSSLMODE", None::<&str>), ("PGSSLROOTCERT", None::<&str>)],
|| {
let err =
sanitize_db_url("postgres://host/db?sslcert=/c.pem&sslkey=/k.pem").unwrap_err();
assert!(err.message().contains("sslcert"));
},
);
}
#[test]
fn sanitize_rejects_sslkey_alone_in_url_form() {
temp_env::with_vars(
[("PGSSLMODE", None::<&str>), ("PGSSLROOTCERT", None::<&str>)],
|| {
let err = sanitize_db_url("postgres://host/db?sslkey=/k.pem").unwrap_err();
assert!(err.message().contains("sslkey"));
},
);
}
#[test]
fn sanitize_rejects_sslcert_in_keyword_form() {
temp_env::with_vars(
[("PGSSLMODE", None::<&str>), ("PGSSLROOTCERT", None::<&str>)],
|| {
let err = sanitize_db_url("host=host dbname=db sslcert=/c.pem sslkey=/k.pem")
.unwrap_err();
assert!(err.message().contains("sslcert"));
},
);
}
#[test]
fn sanitize_rejects_sslcert_from_a_service_group() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nsslcert=/c.pem\nsslkey=/k.pem\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", None::<&str>),
],
|| {
let err = sanitize_db_url("postgres://host/db?service=prod").unwrap_err();
assert!(err.message().contains("sslcert"));
},
);
}
#[test]
fn sanitize_rejects_pgsslcert_env_var_in_url_form() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", Some("/c.pem")),
("PGSSLKEY", Some("/k.pem")),
],
|| {
let err = sanitize_db_url("postgres://host/db").unwrap_err();
assert!(err.message().contains("sslcert"));
},
);
}
#[test]
fn sanitize_rejects_pgsslkey_env_var_in_keyword_form() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", Some("/k.pem")),
],
|| {
let err = sanitize_db_url("host=host dbname=db").unwrap_err();
assert!(err.message().contains("sslkey"));
},
);
}
#[test]
fn sanitize_does_not_override_explicit_sslcert_rejection_message_source() {
temp_env::with_vars(
[
("PGSSLMODE", None::<&str>),
("PGSSLROOTCERT", None::<&str>),
("PGSSLCERT", Some("/from-env.pem")),
("PGSSLKEY", None::<&str>),
],
|| {
let err = sanitize_db_url("postgres://host/db?sslcert=/inline.pem").unwrap_err();
assert!(err.message().contains("sslcert"));
},
);
}
#[test]
fn sanitize_drops_unsupported_ssl_params_from_keyword_form() {
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-file-for-test")),
],
|| {
let sanitized = sanitize_db_url(
"host=localhost dbname=mydb sslmode=prefer sslrootcert=/etc/ca.pem",
)
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("mydb"));
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Prefer
);
},
);
}
#[test]
fn sanitize_rejects_verify_full_in_keyword_form_instead_of_downgrading() {
temp_env::with_vars(
[("PGSSLCERT", None::<&str>), ("PGSSLKEY", None::<&str>)],
|| {
let err =
sanitize_db_url("host=localhost dbname=mydb sslmode=verify-full").unwrap_err();
assert!(err.message().contains("verify-full"));
},
);
}
#[test]
fn sanitize_rejects_require_with_sslrootcert_in_keyword_form() {
let err =
sanitize_db_url("host=localhost dbname=mydb sslmode=require sslrootcert=/etc/ca.pem")
.unwrap_err();
assert!(err.message().contains("sslrootcert"));
assert!(err.message().contains("require"));
}
#[test]
fn sanitize_handles_quoted_values_in_keyword_form() {
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-file-for-test")),
],
|| {
let sanitized = sanitize_db_url(
"host=localhost dbname='my db' sslmode=prefer sslrootcert=/etc/ca.pem",
)
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("my db"));
},
);
}
#[test]
fn sanitize_passes_through_strings_matching_neither_shape() {
let input = "not a valid connection string at all";
assert_eq!(sanitize_db_url(input).unwrap(), input);
}
#[test]
fn sanitize_fills_in_pghost_when_url_has_neither_host_nor_hostaddr() {
temp_env::with_var("PGHOST", Some("db.internal"), || {
let sanitized = sanitize_db_url("postgres:///app").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("db.internal".to_owned())]
);
});
}
#[test]
fn sanitize_fills_in_pghost_when_keyword_form_has_neither_host_nor_hostaddr() {
temp_env::with_var("PGHOST", Some("db.internal"), || {
let sanitized = sanitize_db_url("dbname=app").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("db.internal".to_owned())]
);
});
}
#[test]
fn sanitize_does_not_override_an_explicit_host_with_pghost() {
temp_env::with_var("PGHOST", Some("wrong-host"), || {
let sanitized = sanitize_db_url("postgres://right-host/app").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("right-host".to_owned())]
);
});
}
#[test]
fn sanitize_fills_in_both_pghost_and_pghostaddr_when_url_has_neither() {
temp_env::with_vars(
[
("PGHOST", Some("db.internal")),
("PGHOSTADDR", Some("10.0.0.5")),
],
|| {
let sanitized = sanitize_db_url("postgres:///app").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("db.internal".to_owned())]
);
assert_eq!(
config.get_hostaddrs(),
&["10.0.0.5".parse::<std::net::IpAddr>().unwrap()]
);
},
);
}
#[test]
fn sanitize_fills_in_both_pghost_and_pghostaddr_in_keyword_form_too() {
temp_env::with_vars(
[
("PGHOST", Some("db.internal")),
("PGHOSTADDR", Some("10.0.0.5")),
],
|| {
let sanitized = sanitize_db_url("dbname=app").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("db.internal".to_owned())]
);
assert_eq!(
config.get_hostaddrs(),
&["10.0.0.5".parse::<std::net::IpAddr>().unwrap()]
);
},
);
}
#[test]
fn sanitize_fills_in_pghostaddr_even_when_host_is_already_explicit() {
temp_env::with_var("PGHOSTADDR", Some("10.0.0.5"), || {
let sanitized = sanitize_db_url("postgres://db-name/app").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("db-name".to_owned())]
);
assert_eq!(
config.get_hostaddrs(),
&["10.0.0.5".parse::<std::net::IpAddr>().unwrap()]
);
});
}
#[test]
fn sanitize_fills_in_pghost_even_when_hostaddr_is_already_explicit() {
temp_env::with_var("PGHOST", Some("db-name"), || {
let sanitized = sanitize_db_url("postgres:///app?hostaddr=10.0.0.9").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("db-name".to_owned())]
);
assert_eq!(
config.get_hostaddrs(),
&["10.0.0.9".parse::<std::net::IpAddr>().unwrap()]
);
});
}
#[test]
fn sanitize_fills_in_pghostaddr_even_when_host_is_already_explicit_in_keyword_form() {
temp_env::with_var("PGHOSTADDR", Some("10.0.0.5"), || {
let sanitized = sanitize_db_url("host=db-name dbname=app").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Tcp("db-name".to_owned())]
);
assert_eq!(
config.get_hostaddrs(),
&["10.0.0.5".parse::<std::net::IpAddr>().unwrap()]
);
});
}
#[test]
fn sanitize_fills_in_pgport_pguser_pgdatabase_when_url_omits_them() {
temp_env::with_vars(
[
("PGPORT", Some("6543")),
("PGUSER", Some("app_user")),
("PGDATABASE", Some("app_db")),
("PGPASSWORD", None),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[6543]);
assert_eq!(config.get_user(), Some("app_user"));
assert_eq!(config.get_dbname(), Some("app_db"));
},
);
}
#[test]
fn sanitize_fills_in_pgport_pguser_pgdatabase_in_keyword_form_too() {
temp_env::with_vars(
[
("PGPORT", Some("6543")),
("PGUSER", Some("app_user")),
("PGDATABASE", Some("app_db")),
("PGPASSWORD", None),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("host=db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[6543]);
assert_eq!(config.get_user(), Some("app_user"));
assert_eq!(config.get_dbname(), Some("app_db"));
},
);
}
#[test]
fn sanitize_does_not_override_explicit_port_user_dbname_with_env_vars() {
temp_env::with_vars(
[
("PGPORT", Some("9999")),
("PGUSER", Some("wrong_user")),
("PGDATABASE", Some("wrong_db")),
("PGPASSWORD", None),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized =
sanitize_db_url("postgres://right_user@db.internal:6543/right_db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[6543]);
assert_eq!(config.get_user(), Some("right_user"));
assert_eq!(config.get_dbname(), Some("right_db"));
},
);
}
#[test]
fn sanitize_uses_pgconnect_timeout_env_var_when_url_omits_it() {
temp_env::with_var("PGCONNECT_TIMEOUT", Some("10"), || {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_connect_timeout(),
Some(&std::time::Duration::from_secs(10))
);
});
}
#[test]
fn sanitize_uses_pgconnect_timeout_env_var_in_keyword_form_too() {
temp_env::with_var("PGCONNECT_TIMEOUT", Some("10"), || {
let sanitized = sanitize_db_url("host=db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_connect_timeout(),
Some(&std::time::Duration::from_secs(10))
);
});
}
#[test]
fn sanitize_does_not_override_explicit_connect_timeout_with_pgconnect_timeout_env_var() {
temp_env::with_var("PGCONNECT_TIMEOUT", Some("999"), || {
let sanitized = sanitize_db_url("postgres://db.internal?connect_timeout=10").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_connect_timeout(),
Some(&std::time::Duration::from_secs(10))
);
});
}
#[test]
fn sanitize_preserves_literal_plus_in_url_query_values() {
temp_env::with_vars(
[("PGSSLMODE", None::<&str>), ("PGSSLROOTCERT", None::<&str>)],
|| {
let sanitized =
sanitize_db_url("postgres://db.internal/mydb?application_name=ops+cli")
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_application_name(), Some("ops+cli"));
},
);
}
#[test]
fn sanitize_still_percent_decodes_url_query_values_with_plus_present() {
temp_env::with_vars(
[("PGSSLMODE", None::<&str>), ("PGSSLROOTCERT", None::<&str>)],
|| {
let sanitized = sanitize_db_url(
"postgres://db.internal/mydb?options=-c%20search_path%3Dapp+public",
)
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_options(), Some("-c search_path=app+public"));
},
);
}
#[test]
fn sanitize_uses_pgsslnegotiation_env_var_when_url_omits_it() {
temp_env::with_var("PGSSLNEGOTIATION", Some("direct"), || {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_ssl_negotiation(),
tokio_postgres::config::SslNegotiation::Direct
);
});
}
#[test]
fn sanitize_uses_pgloadbalancehosts_env_var_when_url_omits_it() {
temp_env::with_var("PGLOADBALANCEHOSTS", Some("random"), || {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_load_balance_hosts(),
tokio_postgres::config::LoadBalanceHosts::Random
);
});
}
#[test]
fn sanitize_uses_pgtargetsessionattrs_env_var_when_url_omits_it() {
temp_env::with_var("PGTARGETSESSIONATTRS", Some("read-write"), || {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_target_session_attrs(),
tokio_postgres::config::TargetSessionAttrs::ReadWrite
);
});
}
#[test]
fn sanitize_uses_pgtargetsessionattrs_env_var_in_keyword_form_too() {
temp_env::with_var("PGTARGETSESSIONATTRS", Some("read-write"), || {
let sanitized = sanitize_db_url("host=db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_target_session_attrs(),
tokio_postgres::config::TargetSessionAttrs::ReadWrite
);
});
}
#[test]
fn sanitize_does_not_override_explicit_target_session_attrs_with_env_var() {
temp_env::with_var("PGTARGETSESSIONATTRS", Some("read-write"), || {
let sanitized =
sanitize_db_url("postgres://db.internal?target_session_attrs=any").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_target_session_attrs(),
tokio_postgres::config::TargetSessionAttrs::Any
);
});
}
#[test]
fn sanitize_uses_pgchannelbinding_env_var_when_url_omits_it() {
temp_env::with_var("PGCHANNELBINDING", Some("require"), || {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_channel_binding(),
tokio_postgres::config::ChannelBinding::Require
);
});
}
#[test]
fn sanitize_uses_pgoptions_env_var_when_url_omits_it() {
temp_env::with_var("PGOPTIONS", Some("-c search_path=app,public"), || {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_options(), Some("-c search_path=app,public"));
});
}
#[test]
fn sanitize_uses_pgappname_env_var_when_url_omits_it() {
temp_env::with_var("PGAPPNAME", Some("my-app"), || {
let sanitized = sanitize_db_url("postgres://db.internal").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_application_name(), Some("my-app"));
});
}
#[test]
fn sanitize_does_not_override_explicit_channel_binding_options_appname_with_env_vars() {
temp_env::with_vars(
[
("PGCHANNELBINDING", Some("require")),
("PGOPTIONS", Some("-c wrong=1")),
("PGAPPNAME", Some("wrong-app")),
],
|| {
let sanitized = sanitize_db_url(
"postgres://db.internal?channel_binding=disable&options=-c%20right%3D1&application_name=right-app",
)
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_channel_binding(),
tokio_postgres::config::ChannelBinding::Disable
);
assert_eq!(config.get_options(), Some("-c right=1"));
assert_eq!(config.get_application_name(), Some("right-app"));
},
);
}
#[test]
fn sanitize_leaves_password_unset_when_no_source_is_configured() {
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-file-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://user@host:5432/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), None);
},
);
}
#[test]
fn sanitize_fills_missing_password_from_pgpassword_env() {
temp_env::with_vars(
[
("PGPASSWORD", Some("from-env")),
("PGPASSFILE", Some("/nonexistent-pgpass-file-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://user@host:5432/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), Some(b"from-env".as_slice()));
},
);
}
#[test]
fn sanitize_does_not_override_an_explicit_password_with_pgpassword_env() {
temp_env::with_var("PGPASSWORD", Some("from-env"), || {
let sanitized = sanitize_db_url("postgres://user:explicit@host:5432/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), Some(b"explicit".as_slice()));
});
}
#[test]
fn sanitize_fills_missing_password_from_pgpass_file() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join("pgpass");
std::fs::write(&pgpass_path, "host:5432:db:user:from-pgpass\n").unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://user@host:5432/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), Some(b"from-pgpass".as_slice()));
},
);
}
#[test]
#[cfg(unix)]
fn pgpass_file_with_insecure_permissions_is_ignored() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join("pgpass");
std::fs::write(&pgpass_path, "*:*:*:*:insecure-password\n").unwrap();
std::fs::set_permissions(&pgpass_path, std::fs::Permissions::from_mode(0o644)).unwrap();
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("postgres://user@host:5432/db").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), None);
},
);
}
#[test]
fn pgpass_lookup_matches_wildcards_and_skips_non_matching_lines() {
let contents = "otherhost:5432:otherdb:otheruser:wrong\n*:*:*:user:right\n";
assert_eq!(
pgpass_lookup(contents, "host", "5432", "db", "user"),
Some("right".to_owned())
);
}
#[test]
fn pgpass_lookup_handles_escaped_colons_and_backslashes() {
let contents = "host:5432:db:user:pa\\:ss\\\\word\n";
assert_eq!(
pgpass_lookup(contents, "host", "5432", "db", "user"),
Some("pa:ss\\word".to_owned())
);
}
#[test]
fn parse_service_file_extracts_named_section_only() {
let contents = "# comment\n[dev]\nhost=devhost\n\n[prod]\nhost=prodhost\nport=6543\n";
let pairs = parse_service_file(contents, "prod").unwrap();
assert!(pairs.contains(&("host".to_owned(), "prodhost".to_owned())));
assert!(pairs.contains(&("port".to_owned(), "6543".to_owned())));
assert_eq!(parse_service_file(contents, "missing"), None);
}
#[test]
fn sanitize_merges_service_file_params_without_overriding_explicit_ones() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(
&service_path,
"[prod]\nhost=svchost\nport=6543\ndbname=svcdb\nuser=svcuser\npassword=svcpass\n",
)
.unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("db"));
assert_eq!(config.get_user(), Some("svcuser"));
assert_eq!(config.get_password(), Some(b"svcpass".as_slice()));
assert_eq!(config.get_ports(), &[6543]);
},
);
}
#[test]
fn sanitize_prefers_service_file_password_over_an_ambient_pgpassword() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\npassword=svcpass\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", Some("ambient-password")),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), Some(b"svcpass".as_slice()));
},
);
}
#[test]
fn sanitize_falls_back_to_hostaddr_for_pgpass_lookup_when_host_is_absent() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join("pgpass");
std::fs::write(&pgpass_path, "10.0.0.5:5432:db:user:from-hostaddr-pgpass\n").unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("hostaddr=10.0.0.5 dbname=db user=user").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_password(),
Some(b"from-hostaddr-pgpass".as_slice())
);
},
);
}
#[test]
fn sanitize_falls_back_to_hostaddr_for_pgpass_lookup_in_url_form_too() {
let dir = tempfile::tempdir().unwrap();
let pgpass_path = dir.path().join("pgpass");
std::fs::write(&pgpass_path, "10.0.0.5:5432:db:user:from-hostaddr-pgpass\n").unwrap();
set_pgpass_permissions_safe(&pgpass_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some(pgpass_path.to_str().unwrap())),
("PGSERVICE", None::<&str>),
],
|| {
let sanitized =
sanitize_db_url("postgres:///db?hostaddr=10.0.0.5&user=user").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_password(),
Some(b"from-hostaddr-pgpass".as_slice())
);
},
);
}
#[test]
fn sanitize_uses_passfile_parameter_for_password_lookup_in_url_form() {
let dir = tempfile::tempdir().unwrap();
let passfile_path = dir.path().join("custom_pgpass");
std::fs::write(&passfile_path, "host:5432:db:user:from-passfile\n").unwrap();
set_pgpass_permissions_safe(&passfile_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpassfile-for-test")),
],
|| {
let sanitized = sanitize_db_url(&format!(
"postgres://user@host/db?passfile={}",
passfile_path.to_str().unwrap()
))
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), Some(b"from-passfile".as_slice()));
},
);
}
#[test]
fn sanitize_uses_passfile_parameter_for_password_lookup_in_keyword_form() {
let dir = tempfile::tempdir().unwrap();
let passfile_path = dir.path().join("custom_pgpass");
std::fs::write(&passfile_path, "host:5432:db:user:from-passfile\n").unwrap();
set_pgpass_permissions_safe(&passfile_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpassfile-for-test")),
],
|| {
let sanitized = sanitize_db_url(&format!(
"host=host dbname=db user=user {}",
keyword_value_token("passfile", passfile_path.to_str().unwrap())
))
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_password(), Some(b"from-passfile".as_slice()));
},
);
}
#[test]
fn sanitize_uses_last_occurrence_of_repeated_passfile_in_url_form() {
let dir = tempfile::tempdir().unwrap();
let wrong_path = dir.path().join("wrong_pgpass");
std::fs::write(&wrong_path, "host:5432:db:user:from-wrong-passfile\n").unwrap();
set_pgpass_permissions_safe(&wrong_path);
let right_path = dir.path().join("right_pgpass");
std::fs::write(&right_path, "host:5432:db:user:from-right-passfile\n").unwrap();
set_pgpass_permissions_safe(&right_path);
temp_env::with_vars(
[
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpassfile-for-test")),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url(&format!(
"postgres://user@host/db?passfile={}&passfile={}",
wrong_path.to_str().unwrap(),
right_path.to_str().unwrap()
))
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_password(),
Some(b"from-right-passfile".as_slice())
);
},
);
}
#[test]
fn sanitize_uses_last_occurrence_of_repeated_service_in_keyword_form() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(
&service_path,
"[old]\ndbname=old_db\n[prod]\ndbname=prod_db\n",
)
.unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let sanitized = sanitize_db_url("host=host service=old service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("prod_db"));
},
);
}
#[test]
fn sanitize_percent_decodes_explicit_password_when_forced_into_keyword_form() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nport=6543\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized =
sanitize_db_url("postgres://user:p%40ss@host/db?service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_user(), Some("user"));
assert_eq!(config.get_password(), Some(b"p@ss".as_slice()));
assert_eq!(config.get_ports(), &[6543]);
},
);
}
#[test]
#[cfg(unix)]
fn sanitize_percent_decodes_explicit_unix_socket_host_when_forced_into_keyword_form() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nport=6543\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized =
sanitize_db_url("postgresql://%2Fvar%2Frun%2Fpostgresql/db?service=prod")
.unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(
config.get_hosts(),
&[tokio_postgres::config::Host::Unix(
std::path::PathBuf::from("/var/run/postgresql")
)]
);
assert_eq!(config.get_ports(), &[6543]);
},
);
}
#[test]
fn sanitize_merges_service_file_params_in_keyword_form_too() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\ndbname=svcdb\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("host=localhost service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("svcdb"));
},
);
}
#[test]
fn sanitize_drops_unsupported_ssl_params_from_a_service_group_in_url_form() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(
&service_path,
"[prod]\ndbname=svcdb\nsslmode=prefer\nsslrootcert=/ca.pem\n",
)
.unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("db"));
assert_eq!(
config.get_ssl_mode(),
tokio_postgres::config::SslMode::Prefer
);
},
);
}
#[test]
fn sanitize_rejects_require_with_sslrootcert_from_a_service_group() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(
&service_path,
"[prod]\ndbname=svcdb\nsslmode=require\nsslrootcert=/ca.pem\n",
)
.unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let err = sanitize_db_url("postgres://host/db?service=prod").unwrap_err();
assert!(err.message().contains("sslrootcert"));
},
);
}
#[test]
fn sanitize_rejects_require_from_url_combined_with_sslrootcert_from_service_group() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nsslrootcert=/ca.pem\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let err =
sanitize_db_url("postgres://host/db?sslmode=require&service=prod").unwrap_err();
assert!(err.message().contains("sslrootcert"));
},
);
}
#[test]
fn sanitize_rejects_sslrootcert_from_url_combined_with_require_from_service_group() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nsslmode=require\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let err = sanitize_db_url("postgres://host/db?sslrootcert=/ca.pem&service=prod")
.unwrap_err();
assert!(err.message().contains("sslrootcert"));
},
);
}
#[test]
fn sanitize_rejects_require_from_keyword_form_combined_with_sslrootcert_from_service_group() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nsslrootcert=/ca.pem\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let err = sanitize_db_url("host=host dbname=db sslmode=require service=prod")
.unwrap_err();
assert!(err.message().contains("sslrootcert"));
},
);
}
#[test]
fn sanitize_drops_unsupported_ssl_params_from_a_service_group_in_keyword_form() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nsslrootcert=/ca.pem\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("host=localhost dbname=db service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("db"));
},
);
}
#[test]
fn sanitize_rejects_verify_full_from_a_service_group_instead_of_a_raw_parse_error() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\nsslmode=verify-full\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGSSLCERT", None::<&str>),
("PGSSLKEY", None::<&str>),
],
|| {
let err = sanitize_db_url("postgres://host/db?service=prod").unwrap_err();
assert!(err.message().contains("verify-full"));
},
);
}
#[test]
fn sanitize_errors_when_named_service_is_missing_from_service_file() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[other]\nhost=x\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGSYSCONFDIR", None::<&str>),
],
|| {
let err = sanitize_db_url("postgres://host/db?service=missing").unwrap_err();
assert!(err.message().contains("missing"));
},
);
}
#[test]
fn sanitize_falls_back_to_system_wide_service_file_via_pgsysconfdir() {
let user_dir = tempfile::tempdir().unwrap();
let user_service_path = user_dir.path().join("pg_service.conf");
std::fs::write(&user_service_path, "[other]\nhost=x\n").unwrap();
let sys_dir = tempfile::tempdir().unwrap();
std::fs::write(
sys_dir.path().join("pg_service.conf"),
"[prod]\nhost=syswide\nport=6543\n",
)
.unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(user_service_path.to_str().unwrap())),
("PGSYSCONFDIR", Some(sys_dir.path().to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[6543]);
},
);
}
#[test]
fn sanitize_prefers_user_service_file_over_system_wide_one() {
let user_dir = tempfile::tempdir().unwrap();
let user_service_path = user_dir.path().join("pg_service.conf");
std::fs::write(&user_service_path, "[prod]\nhost=userhost\nport=1111\n").unwrap();
let sys_dir = tempfile::tempdir().unwrap();
std::fs::write(
sys_dir.path().join("pg_service.conf"),
"[prod]\nhost=syshost\nport=2222\n",
)
.unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(user_service_path.to_str().unwrap())),
("PGSYSCONFDIR", Some(sys_dir.path().to_str().unwrap())),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://host/db?service=prod").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_ports(), &[1111]);
},
);
}
#[test]
fn sanitize_errors_when_service_is_missing_from_both_user_and_system_files() {
let user_dir = tempfile::tempdir().unwrap();
let user_service_path = user_dir.path().join("pg_service.conf");
std::fs::write(&user_service_path, "[other]\nhost=x\n").unwrap();
let sys_dir = tempfile::tempdir().unwrap();
std::fs::write(sys_dir.path().join("pg_service.conf"), "[other]\nhost=y\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(user_service_path.to_str().unwrap())),
("PGSYSCONFDIR", Some(sys_dir.path().to_str().unwrap())),
],
|| {
let err = sanitize_db_url("postgres://host/db?service=missing").unwrap_err();
assert!(err.message().contains("missing"));
},
);
}
#[test]
fn sanitize_uses_pgservice_env_var_when_no_inline_service_param() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\ndbname=svcdb\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGSERVICE", Some("prod")),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://host").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("svcdb"));
},
);
}
#[test]
fn sanitize_uses_pgservice_env_var_in_keyword_form_too() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(&service_path, "[prod]\ndbname=svcdb\n").unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGSERVICE", Some("prod")),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("host=host").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("svcdb"));
},
);
}
#[test]
fn sanitize_prefers_inline_service_param_over_pgservice_env_var() {
let dir = tempfile::tempdir().unwrap();
let service_path = dir.path().join("pg_service.conf");
std::fs::write(
&service_path,
"[inline_wins]\ndbname=inline_db\n[wrong]\ndbname=wrong_db\n",
)
.unwrap();
temp_env::with_vars(
[
("PGSERVICEFILE", Some(service_path.to_str().unwrap())),
("PGSERVICE", Some("wrong")),
("PGPASSWORD", None::<&str>),
("PGPASSFILE", Some("/nonexistent-pgpass-for-test")),
],
|| {
let sanitized = sanitize_db_url("postgres://host?service=inline_wins").unwrap();
let config: tokio_postgres::Config = sanitized.parse().unwrap();
assert_eq!(config.get_dbname(), Some("inline_db"));
},
);
}
#[test]
fn no_server_cert_verification_reports_supported_signature_schemes() {
let provider = rustls::crypto::ring::default_provider();
let verifier = NoServerCertVerification(provider);
assert!(!verifier.supported_verify_schemes().is_empty());
}
#[test]
fn separator_plus_marks_align_with_header_pipes_for_three_plus_columns() {
let table = format_table(
&["name", "state", "variants", "winner"],
&[vec![
Some("checkout_flow".to_owned()),
Some("running".to_owned()),
Some("control=50, treatment=50".to_owned()),
None,
]],
);
let lines: Vec<&str> = table.lines().collect();
let header_pipes: Vec<usize> = lines[0]
.char_indices()
.filter(|(_, c)| *c == '|')
.map(|(i, _)| i)
.collect();
let separator_plusses: Vec<usize> = lines[1]
.char_indices()
.filter(|(_, c)| *c == '+')
.map(|(i, _)| i)
.collect();
let data_pipes: Vec<usize> = lines[2]
.char_indices()
.filter(|(_, c)| *c == '|')
.map(|(i, _)| i)
.collect();
assert_eq!(header_pipes, separator_plusses);
assert_eq!(header_pipes, data_pipes);
}
#[test]
fn block_on_returns_future_output() {
let result = block_on(async { 1 + 1 });
assert_eq!(result, 2);
}
#[test]
fn connect_error_exits_with_code_2() {
assert_eq!(CommandError::Connect("x".into()).exit_code(), 2);
}
#[test]
fn other_error_exits_with_code_1() {
assert_eq!(CommandError::Other("x".into()).exit_code(), 1);
}
#[test]
fn pg_ext_wraps_display_error_as_other() {
let result: Result<(), String> = Err("boom".to_owned());
match result.pg() {
Err(CommandError::Other(msg)) => assert_eq!(msg, "boom"),
_ => panic!("expected CommandError::Other"),
}
}
}