diff --git a/Cargo.lock b/Cargo.lock index eb29a804b4..b13b22f6ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1012,6 +1018,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pq-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1624,6 +1641,7 @@ dependencies = [ "mime", "once_cell", "postgres-protocol", + "pq-sys", "pyo3", "pyo3-log", "pythonize", @@ -1908,6 +1926,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1d1bee28e4..6a704d826f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -66,6 +66,10 @@ tokio-postgres = "0.7" once_cell = "1.18.0" itertools = "0.14.0" postgres-protocol = "0.6.10" +# Raw libpq bindings, used by `database::postgres::libpq` to resolve libpq's +# default host. Ships pre-generated bindings and links the system libpq itself, +# so — unlike a bindgen-based binding — the build needs no libclang. +pq-sys = "0.7.5" [build-dependencies] blake2 = "0.10.4" diff --git a/rust/src/database/postgres/libpq.rs b/rust/src/database/postgres/libpq.rs new file mode 100644 index 0000000000..579b5a492c --- /dev/null +++ b/rust/src/database/postgres/libpq.rs @@ -0,0 +1,92 @@ +//! Resolve libpq's default host for a DSN that omits `host=`. +//! +//! [`tokio_postgres`] applies its *own* default host (`localhost`) when a DSN +//! omits `host=`, whereas Synapse has always used libpq's default — the +//! compiled-in default socket directory (which distributions such as Debian +//! patch) and environment variables like `PGHOST`. So when a DSN has no host we +//! ask the real libpq what it would do and copy that across. +//! +//! We call libpq through [`pq_sys`], which ships pre-generated bindings and +//! links the system libpq itself. Unlike a bindgen-based binding crate that +//! generates its FFI at build time, this needs no libclang to build — only a C +//! linker and a discoverable libpq (`pg_config` / `pkg-config`). + +use std::ffi::{c_char, CStr, CString}; + +use anyhow::{bail, Error}; +use pq_sys::{ConnStatusType, PGconn, PQconnectStart, PQerrorMessage, PQfinish, PQhost, PQstatus}; + +/// Owns a `PGconn` and calls `PQfinish` on drop. +struct Connection(*mut PGconn); + +impl Drop for Connection { + fn drop(&mut self) { + // SAFETY: `self.0` was returned by `PQconnectStart` and is finished + // exactly once, here. `PQfinish` accepts a connection that never + // completed its (non-blocking) connect. + unsafe { PQfinish(self.0) } + } +} + +/// Ask libpq which host an otherwise-empty connection string would connect to. +/// +/// Resolves libpq's defaults *without opening a socket*: `PQconnectStart` +/// begins a non-blocking connection but we never poll it (no `PQconnectPoll`), +/// then drop it immediately — so this only parses options and applies defaults. +pub fn default_host() -> Result { + // An empty conninfo means "use every default". + let conninfo = CString::new("")?; + + // SAFETY: `conninfo` is a valid NUL-terminated string that outlives the + // call; `PQconnectStart` copies what it needs. + let conn = unsafe { PQconnectStart(conninfo.as_ptr()) }; + if conn.is_null() { + bail!("libpq PQconnectStart returned null (out of memory)"); + } + let conn = Connection(conn); + + // SAFETY: `conn.0` is non-null and valid for the rest of this scope. + if unsafe { PQstatus(conn.0) } == ConnStatusType::CONNECTION_BAD { + // SAFETY: `PQerrorMessage` returns a valid (possibly empty) C string + // owned by the connection. + let msg = unsafe { cstr_lossy(PQerrorMessage(conn.0)) }; + bail!("libpq could not parse default connection options: {msg}"); + } + + // SAFETY: `conn.0` is valid; `PQhost` returns a pointer owned by the + // connection (valid until `PQfinish`), or null. + let host_ptr = unsafe { PQhost(conn.0) }; + if host_ptr.is_null() { + bail!("libpq PQhost returned null"); + } + + // SAFETY: non-null, checked just above. + let host = unsafe { cstr_lossy(host_ptr) }; + if host.is_empty() { + bail!("libpq reported an empty default host"); + } + + Ok(host) +} + +/// Copy a libpq-owned C string into an owned `String` (lossy on non-UTF-8). +/// +/// # Safety +/// `ptr` must be non-null and point to a valid NUL-terminated string. +unsafe fn cstr_lossy(ptr: *const c_char) -> String { + CStr::from_ptr(ptr).to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_a_nonempty_default_host() { + // We can't assert the exact value (it's host/distro/env dependent), + // but libpq must always resolve *some* non-empty default host — the + // whole reason we defer to it rather than hard-coding one. + let host = default_host().expect("libpq should resolve a default host"); + assert!(!host.is_empty()); + } +} diff --git a/rust/src/database/postgres/mod.rs b/rust/src/database/postgres/mod.rs index 3b22399e1b..4c16cf3063 100644 --- a/rust/src/database/postgres/mod.rs +++ b/rust/src/database/postgres/mod.rs @@ -1,8 +1,11 @@ //! [`tokio_postgres`]-backed `Connection` / `Cursor` types exposed to Python. //! -//! The driver itself is async; we drive it from sync Python methods via a -//! shared multi-thread tokio runtime (see `super::runtime`). +//! The driver itself is async; we drive it from sync Python methods via the +//! extension's shared multi-thread tokio runtime (see [`crate::tokio_runtime`]). +//! [`connect`] takes the runtime's handle from the reactor once and hands it to +//! the [`Connection`], which carries it for the life of the connection. +use anyhow::Error; use log::warn; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; @@ -14,6 +17,7 @@ use crate::tokio_runtime::runtime_handle; mod connection; mod cursor_state; mod helpers; +mod libpq; mod value; /// Register the `postgres` submodule (the `Connection` / `Cursor` classes and @@ -59,9 +63,8 @@ fn connect<'py>( ) -> PyResult> { let handle = runtime_handle(reactor)?; - let config = dsn - .parse::() - .map_err(|e| PyRuntimeError::new_err(format!("Failed to parse DSN: {e}")))?; + let config = fixup_default_host(dsn) + .map_err(|e| PyRuntimeError::new_err(format!("Failed to prepare DSN: {e}")))?; // TLS is not yet supported: unlike libpq (whose default is // `sslmode=prefer`), we never negotiate TLS regardless of the DSN's @@ -81,3 +84,29 @@ fn connect<'py>( Bound::new(py, conn) } + +/// Fix up a DSN to ensure it has a host, using libpq's default host if +/// necessary. +/// +/// [`tokio_postgres`] has a different default host than libpq, which is what +/// Synapse previously used (and is what e.g. `psql` uses). libpq's default host +/// is configurable, so when the DSN omits a host we ask libpq what its default +/// would be and use that instead (see [`libpq::default_host`]). +fn fixup_default_host(dsn: &str) -> Result { + let mut config = dsn.parse::()?; + + // `tokio_postgres` parses only the DSN string (it does not consult `PGHOST` + // or the compiled-in default), so an empty host list means the DSN really + // omitted the host. A DSN that gives a `hostaddr` instead of a `host` is + // still connectable as-is, so leave it alone too — injecting a default host + // there would just confuse TLS/SNI. + if !config.get_hosts().is_empty() || !config.get_hostaddrs().is_empty() { + return Ok(config); + } + + // Resolve libpq's default host without connecting (see `libpq::default_host`). + let host = libpq::default_host()?; + config.host(&host); + + Ok(config) +}