Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions crates/storage-postgres/src/bootstrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use tokio::sync::OnceCell;

use crate::CATALOG_VERSION;
use crate::config::build_connection_url;
use crate::migrations;

/// Utilities for bootstrapping a PostgreSQL backend store.
Expand Down Expand Up @@ -82,11 +83,10 @@ impl PostgresBootstrapper {

/// Build the connection URL for the application user and a named database.
fn app_connection_url(&self, database: &str) -> String {
format!(
"postgresql://{}:{}@{}:{}/{}",
self.config.app_user,
self.config.app_password,
self.config.host,
build_connection_url(
&self.config.app_user,
&self.config.app_password,
&self.config.host,
self.config.port,
database,
)
Expand Down
151 changes: 149 additions & 2 deletions crates/storage-postgres/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,32 @@ pub struct ConnParts {
pub database: String,
}

/// Build a `PostgreSQL` connection URL from discrete components.
///
/// When `host` starts with `/` (a Unix socket directory), the host is moved into
/// the `?host=` query parameter and the URL host is set to `localhost`. This is
/// the PostgreSQL-documented form for socket connections and is accepted by
/// `sqlx::postgres::PgConnectOptions::from_url` and libpq. For TCP hosts the
/// classic `postgresql://user:password@host:port/database` form is emitted.
pub fn build_connection_url(
user: &str,
password: &str,
host: &str,
port: u16,
database: &str,
) -> String {
if host.starts_with('/') {
format!("postgresql://{user}:{password}@localhost:{port}/{database}?host={host}")
} else {
format!("postgresql://{user}:{password}@{host}:{port}/{database}")
}
}

/// Parse host, port, user, password, and database from a `PostgreSQL` connection string.
///
/// Handles the standard `postgresql://user:pass@host:port/db` format.
/// Handles the standard `postgresql://user:pass@host:port/db` format and the
/// socket-style `postgresql://user:pass@localhost:port/db?host=/path` form
/// produced by [`build_connection_url`] when the host is a Unix socket path.
///
/// # Errors
///
Expand All @@ -60,6 +83,8 @@ pub fn parse_connection_string(conn: &str) -> anyhow::Result<ConnParts> {
anyhow::anyhow!("Connection string must start with postgresql:// or postgres://")
})?;

let (rest, query) = rest.split_once('?').map_or((rest, ""), |(r, q)| (r, q));

let (userpass, hostdb) = rest
.split_once('@')
.ok_or_else(|| anyhow::anyhow!("Connection string missing '@' separator"))?;
Expand All @@ -81,10 +106,22 @@ pub fn parse_connection_string(conn: &str) -> anyhow::Result<ConnParts> {
.parse()
.map_err(|_| anyhow::anyhow!("Invalid port: {port_str}"))?;

// `?host=<path>` (libpq/sqlx idiom for Unix sockets) overrides the URL host
// when present and non-empty. Last value wins, matching libpq semantics.
let socket_host = query
.split('&')
.filter_map(|kv| kv.split_once('='))
.filter(|(k, _)| *k == "host")
.map(|(_, v)| v)
.next_back()
.filter(|v| !v.is_empty());

let host = socket_host.map_or_else(|| host.to_owned(), |v| v.to_owned());

Ok(ConnParts {
user,
password,
host: host.to_owned(),
host,
port,
database: database.to_owned(),
})
Expand All @@ -109,3 +146,113 @@ impl extenddb_storage::config::StorageConfig for PostgresStorageConfig {
Box::new(self.clone())
}
}

#[cfg(test)]
mod tests {
use super::*;
use sqlx::postgres::PgConnectOptions;
use std::str::FromStr;

#[test]
fn build_url_tcp_host_uses_classic_form() {
let url = build_connection_url("alice", "pw", "db.example.com", 5432, "extenddb_catalog");
assert_eq!(
url,
"postgresql://alice:pw@db.example.com:5432/extenddb_catalog"
);
}

#[test]
fn build_url_unix_socket_uses_host_query() {
let url = build_connection_url(
"alice",
"pw",
"/var/run/postgresql",
5432,
"extenddb_catalog",
);
assert_eq!(
url,
"postgresql://alice:pw@localhost:5432/extenddb_catalog?host=/var/run/postgresql"
);
}

#[test]
fn parse_tcp_url_unchanged() {
let parts =
parse_connection_string("postgresql://alice:pw@db.example.com:5432/extenddb_catalog")
.expect("parse");
assert_eq!(parts.user, "alice");
assert_eq!(parts.password, "pw");
assert_eq!(parts.host, "db.example.com");
assert_eq!(parts.port, 5432);
assert_eq!(parts.database, "extenddb_catalog");
}

#[test]
fn parse_socket_url_extracts_host_query() {
let parts = parse_connection_string(
"postgresql://alice:pw@localhost:5432/extenddb_catalog?host=/var/run/postgresql",
)
.expect("parse");
assert_eq!(parts.user, "alice");
assert_eq!(parts.password, "pw");
assert_eq!(parts.host, "/var/run/postgresql");
assert_eq!(parts.port, 5432);
assert_eq!(parts.database, "extenddb_catalog");
}

#[test]
fn parse_empty_host_query_falls_back_to_url_host() {
let parts = parse_connection_string("postgresql://alice:pw@db.example.com:5432/db?host=")
.expect("parse");
assert_eq!(parts.host, "db.example.com");
}

#[test]
fn parse_socket_url_ignores_unknown_query_params() {
let parts = parse_connection_string(
"postgresql://u:p@localhost:5432/db?application_name=extenddb&host=/sock&sslmode=disable",
)
.expect("parse");
assert_eq!(parts.host, "/sock");
}

#[test]
fn round_trip_socket_url_via_custom_parser() {
let original_host = "/var/run/postgresql";
let url = build_connection_url("alice", "pw", original_host, 5432, "extenddb_catalog");
let parts = parse_connection_string(&url).expect("parse");
assert_eq!(parts.host, original_host);
assert_eq!(parts.port, 5432);
assert_eq!(parts.database, "extenddb_catalog");
assert_eq!(parts.user, "alice");
assert_eq!(parts.password, "pw");
}

#[test]
fn round_trip_tcp_url_via_custom_parser() {
let url = build_connection_url("alice", "pw", "db.example.com", 5432, "extenddb_catalog");
let parts = parse_connection_string(&url).expect("parse");
assert_eq!(parts.host, "db.example.com");
assert_eq!(parts.port, 5432);
assert_eq!(parts.database, "extenddb_catalog");
}

/// Regression for issue #52: the URL the daemon reads must parse cleanly
/// through `sqlx::postgres::PgConnectOptions::from_url`. The pre-fix URL
/// `postgresql://u:p@/var/run/postgresql:5432/db` fails with "empty host".
#[test]
fn sqlx_accepts_socket_url_from_builder() {
let url = build_connection_url("alice", "pw", "/var/run/postgresql", 5432, "db");
PgConnectOptions::from_str(&url)
.expect("sqlx PgConnectOptions::from_str must accept builder output for Unix sockets");
}

#[test]
fn sqlx_accepts_tcp_url_from_builder() {
let url = build_connection_url("alice", "pw", "db.example.com", 5432, "db");
PgConnectOptions::from_str(&url)
.expect("sqlx PgConnectOptions::from_str must accept builder output for TCP hosts");
}
}