From dc197dcc52f7806beccc5c23e26c034e66754cb3 Mon Sep 17 00:00:00 2001 From: Markus Schirp Date: Sat, 19 Apr 2025 23:36:25 +0000 Subject: [PATCH] Add pg connection options creation without environment --- sqlx-postgres/src/options/doc.md | 15 +- sqlx-postgres/src/options/mod.rs | 191 ++++++++++++++++++++++++-- sqlx-postgres/src/options/parse.rs | 163 ++++++++++++++++++++++ sqlx-postgres/src/options/ssl_mode.rs | 2 +- 4 files changed, 355 insertions(+), 16 deletions(-) diff --git a/sqlx-postgres/src/options/doc.md b/sqlx-postgres/src/options/doc.md index 33dd63b7a8..94a9a19166 100644 --- a/sqlx-postgres/src/options/doc.md +++ b/sqlx-postgres/src/options/doc.md @@ -39,7 +39,8 @@ if a parameter is not passed in via URL, it is populated by reading | `options` | `PGOPTIONS` | Unset. | | `application_name` | `PGAPPNAME` | Unset. | -[`passfile`] handling may be bypassed using [`PgConnectOptions::new_without_pgpass()`]. +[`passfile`] handling may be bypassed using [`PgConnectOptions::default_without_env()`], +which also bypasses all environment variables and uses hardcoded defaults. ## SQLx-Specific SQLx also parses some bespoke parameters. These are _not_ configurable by environment variable. @@ -154,8 +155,8 @@ use sqlx::postgres::{PgConnectOptions, PgConnection, PgPool, PgSslMode}; // URL connection string let conn = PgConnection::connect("postgres://localhost/mydb").await?; -// Manually-constructed options -let conn = PgConnectOptions::new() +// Manually-constructed options with environment defaults +let conn = PgConnectOptions::with_libpq_defaults() .host("secret-host") .port(2525) .username("secret-user") @@ -164,6 +165,14 @@ let conn = PgConnectOptions::new() .connect() .await?; +// Or start from hardcoded defaults without environment variables +let conn = PgConnectOptions::default_without_env() + .host("secret-host") + .username("secret-user") + .password("secret-password") + .connect() + .await?; + // Modifying options parsed from a string let mut opts: PgConnectOptions = "postgres://localhost/mydb".parse()?; diff --git a/sqlx-postgres/src/options/mod.rs b/sqlx-postgres/src/options/mod.rs index efbc43989b..dbeb8de7b7 100644 --- a/sqlx-postgres/src/options/mod.rs +++ b/sqlx-postgres/src/options/mod.rs @@ -34,7 +34,7 @@ pub struct PgConnectOptions { impl Default for PgConnectOptions { fn default() -> Self { - Self::new_without_pgpass().apply_pgpass() + Self::with_libpq_defaults() } } @@ -44,16 +44,93 @@ impl PgConnectOptions { /// This behaves as if parsed from the connection string `postgres://` /// /// See the type-level documentation for details. + /// + /// # Deprecated + /// This method is deprecated. Use [`with_libpq_defaults()`](Self::with_libpq_defaults) instead. + #[deprecated( + since = "0.9.0", + note = "Use `with_libpq_defaults()` instead to make the behavior more explicit" + )] pub fn new() -> Self { - Self::new_without_pgpass().apply_pgpass() + Self::with_libpq_defaults() + } + + /// Create a default set of connection options populated from the current environment, + /// mimicking libpq's default behavior. + /// + /// This reads environment variables (`PGHOST`, `PGPORT`, `PGUSER`, etc.) and `.pgpass` file + /// to populate connection options, similar to how libpq behaves. + /// + /// This behaves as if parsed from the connection string `postgres://` + /// + /// See the type-level documentation for details. + pub fn with_libpq_defaults() -> Self { + Self::default_without_env_internal().apply_env_and_pgpass() } /// Create a default set of connection options _without_ reading from `passfile`. /// - /// Equivalent to [`PgConnectOptions::new()`] but `passfile` is ignored. + /// Equivalent to [`PgConnectOptions::with_libpq_defaults()`] but `passfile` is ignored. /// /// See the type-level documentation for details. + /// + /// # Deprecated + /// This method is deprecated. Use [`default_without_env()`](Self::default_without_env) instead. + #[deprecated( + since = "0.9.0", + note = "Use `default_without_env()` for explicit defaults without environment variables" + )] pub fn new_without_pgpass() -> Self { + Self::default_without_env_internal().apply_env() + } + + /// Create connection options with sensible defaults without reading environment variables. + /// + /// This method provides a predictable baseline for connection options that doesn't depend + /// on the environment. Useful for developer tools, third-party libraries, or any context + /// where environment variables cannot be relied upon. + /// + /// The defaults are: + /// - `host`: `"localhost"` (or Unix socket path if available) + /// - `port`: `5432` + /// - `username`: `"postgres"` + /// - `ssl_mode`: [`PgSslMode::Prefer`] + /// - `statement_cache_capacity`: `100` + /// - `extra_float_digits`: `Some("2")` + /// + /// All other fields are set to `None`. + /// + /// Does not respect any `PG*` environment variables or `.pgpass` files. + /// + /// See the type-level documentation for details. + pub fn default_without_env() -> Self { + let port = 5432; + let host = default_host(port); + let username = "postgres".to_string(); + + PgConnectOptions { + host, + port, + socket: None, + username, + password: None, + database: None, + ssl_mode: PgSslMode::Prefer, + ssl_root_cert: None, + ssl_client_cert: None, + ssl_client_key: None, + statement_cache_capacity: 100, + application_name: None, + extra_float_digits: Some("2".into()), + log_settings: Default::default(), + options: None, + } + } + + /// Internal method that reads environment variables (libpq-style). + /// + /// This is used internally by `with_libpq_defaults()` and the deprecated `new_without_pgpass()`. + fn default_without_env_internal() -> Self { let port = var("PGPORT") .ok() .and_then(|v| v.parse().ok()) @@ -68,23 +145,25 @@ impl PgConnectOptions { let database = var("PGDATABASE").ok(); + let ssl_mode = var("PGSSLMODE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_default(); + PgConnectOptions { - port, host, + port, socket: None, username, password: var("PGPASSWORD").ok(), database, + ssl_mode, ssl_root_cert: var("PGSSLROOTCERT").ok().map(CertificateInput::from), ssl_client_cert: var("PGSSLCERT").ok().map(CertificateInput::from), // As of writing, the implementation of `From` only looks for // `-----BEGIN CERTIFICATE-----` and so will not attempt to parse // a PEM-encoded private key. ssl_client_key: var("PGSSLKEY").ok().map(CertificateInput::from), - ssl_mode: var("PGSSLMODE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_default(), statement_cache_capacity: 100, application_name: var("PGAPPNAME").ok(), extra_float_digits: Some("2".into()), @@ -106,6 +185,24 @@ impl PgConnectOptions { self } + /// Apply environment variables only (no pgpass). + /// + /// This is used internally for the deprecated `new_without_pgpass()`. + fn apply_env(self) -> Self { + // Environment variables are already applied in default_without_env_internal() + // This method exists for backwards compatibility but doesn't do anything + self + } + + /// Apply both environment variables and pgpass. + /// + /// This is used internally for `with_libpq_defaults()`. + fn apply_env_and_pgpass(self) -> Self { + // Environment variables are already applied in default_without_env_internal() + // We just need to apply pgpass + self.apply_pgpass() + } + /// Sets the name of the host to connect to. /// /// If a host name begins with a slash, it specifies @@ -629,26 +726,26 @@ impl Write for PgOptionsWriteEscaped<'_> { #[test] fn test_options_formatting() { - let options = PgConnectOptions::new().options([("geqo", "off")]); + let options = PgConnectOptions::default_without_env().options([("geqo", "off")]); assert_eq!(options.options, Some("-c geqo=off".to_string())); let options = options.options([("search_path", "sqlx")]); assert_eq!( options.options, Some("-c geqo=off -c search_path=sqlx".to_string()) ); - let options = PgConnectOptions::new().options([("geqo", "off"), ("statement_timeout", "5min")]); + let options = PgConnectOptions::default_without_env().options([("geqo", "off"), ("statement_timeout", "5min")]); assert_eq!( options.options, Some("-c geqo=off -c statement_timeout=5min".to_string()) ); // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-OPTIONS let options = - PgConnectOptions::new().options([("application_name", r"/back\slash/ and\ spaces")]); + PgConnectOptions::default_without_env().options([("application_name", r"/back\slash/ and\ spaces")]); assert_eq!( options.options, Some(r"-c application_name=/back\\slash/\ and\\\ spaces".to_string()) ); - let options = PgConnectOptions::new(); + let options = PgConnectOptions::default_without_env(); assert_eq!(options.options, None); } @@ -664,3 +761,73 @@ fn test_pg_write_escaped() { x.write_char('z').unwrap(); assert_eq!(buf, r"x\\y\ \\\ z"); } + +#[test] +#[allow(deprecated)] +fn test_deprecated_api_backwards_compatibility() { + // Test that deprecated methods still work correctly for backwards compatibility + + // Test deprecated new() method + let options = PgConnectOptions::new(); + assert_eq!(options.port, 5432); + assert_eq!(options.statement_cache_capacity, 100); + assert!(options.extra_float_digits.is_some()); + + // Test deprecated new_without_pgpass() method + let options = PgConnectOptions::new_without_pgpass(); + assert_eq!(options.port, 5432); + assert_eq!(options.statement_cache_capacity, 100); + + // Verify the deprecated methods can be chained with builder methods + let options = PgConnectOptions::new() + .host("example.com") + .port(5433) + .username("testuser") + .database("testdb"); + + assert_eq!(options.get_host(), "example.com"); + assert_eq!(options.get_port(), 5433); + assert_eq!(options.get_username(), "testuser"); + assert_eq!(options.get_database(), Some("testdb")); + + // Verify new_without_pgpass() works with builder pattern + let options = PgConnectOptions::new_without_pgpass() + .host("localhost") + .username("postgres"); + + assert_eq!(options.get_host(), "localhost"); + assert_eq!(options.get_username(), "postgres"); +} + +#[test] +fn test_new_api_without_environment() { + // Test the new API methods that don't read environment variables + + // Test default_without_env() provides hardcoded defaults + let options = PgConnectOptions::default_without_env(); + assert_eq!(options.port, 5432); + assert_eq!(options.username, "postgres"); // Hardcoded, not OS username + assert_eq!(options.ssl_mode, PgSslMode::Prefer); + assert_eq!(options.statement_cache_capacity, 100); + assert!(options.extra_float_digits.is_some()); + assert!(options.password.is_none()); + assert!(options.database.is_none()); + + // Test builder pattern with default_without_env() + let options = PgConnectOptions::default_without_env() + .host("example.com") + .port(5433) + .username("myuser") + .database("mydb") + .password("mypass"); + + assert_eq!(options.get_host(), "example.com"); + assert_eq!(options.get_port(), 5433); + assert_eq!(options.get_username(), "myuser"); + assert_eq!(options.get_database(), Some("mydb")); + + // Test with_libpq_defaults() + let options = PgConnectOptions::with_libpq_defaults(); + assert_eq!(options.port, 5432); + assert_eq!(options.statement_cache_capacity, 100); +} diff --git a/sqlx-postgres/src/options/parse.rs b/sqlx-postgres/src/options/parse.rs index e911305698..4baf89bb75 100644 --- a/sqlx-postgres/src/options/parse.rs +++ b/sqlx-postgres/src/options/parse.rs @@ -7,6 +7,7 @@ use std::str::FromStr; impl PgConnectOptions { pub(crate) fn parse_from_url(url: &Url) -> Result { + #[allow(deprecated)] let mut options = Self::new_without_pgpass(); if let Some(host) = url.host_str() { @@ -113,6 +114,137 @@ impl PgConnectOptions { Ok(options) } + /// Parse a connection URL without reading environment variables or `.pgpass`. + /// + /// This is similar to [`parse_from_url`](Self::parse_from_url) but uses + /// [`default_without_env()`](Self::default_without_env) as the baseline, + /// ensuring no environment variables influence the connection options. + pub(crate) fn parse_from_url_without_env(url: &Url) -> Result { + let mut options = Self::default_without_env(); + + if let Some(host) = url.host_str() { + let host_decoded = percent_decode_str(host); + options = match host_decoded.clone().next() { + Some(b'/') => options.socket(&*host_decoded.decode_utf8().map_err(Error::config)?), + _ => options.host(host), + } + } + + if let Some(port) = url.port() { + options = options.port(port); + } + + let username = url.username(); + if !username.is_empty() { + options = options.username( + &percent_decode_str(username) + .decode_utf8() + .map_err(Error::config)?, + ); + } + + if let Some(password) = url.password() { + options = options.password( + &percent_decode_str(password) + .decode_utf8() + .map_err(Error::config)?, + ); + } + + let path = url.path().trim_start_matches('/'); + if !path.is_empty() { + options = options.database( + &percent_decode_str(path) + .decode_utf8() + .map_err(Error::config)?, + ); + } + + for (key, value) in url.query_pairs().into_iter() { + match &*key { + "sslmode" | "ssl-mode" => { + options = options.ssl_mode(value.parse().map_err(Error::config)?); + } + + "sslrootcert" | "ssl-root-cert" | "ssl-ca" => { + options = options.ssl_root_cert(&*value); + } + + "sslcert" | "ssl-cert" => options = options.ssl_client_cert(&*value), + + "sslkey" | "ssl-key" => options = options.ssl_client_key(&*value), + + "statement-cache-capacity" => { + options = + options.statement_cache_capacity(value.parse().map_err(Error::config)?); + } + + "host" => { + if value.starts_with('/') { + options = options.socket(&*value); + } else { + options = options.host(&value); + } + } + + "hostaddr" => { + value.parse::().map_err(Error::config)?; + options = options.host(&value) + } + + "port" => options = options.port(value.parse().map_err(Error::config)?), + + "dbname" => options = options.database(&value), + + "user" => options = options.username(&value), + + "password" => options = options.password(&value), + + "application_name" => options = options.application_name(&value), + + "options" => { + if let Some(options) = options.options.as_mut() { + options.push(' '); + options.push_str(&value); + } else { + options.options = Some(value.to_string()); + } + } + + k if k.starts_with("options[") => { + if let Some(key) = k.strip_prefix("options[").unwrap().strip_suffix(']') { + options = options.options([(key, &*value)]); + } + } + + _ => tracing::warn!(%key, %value, "ignoring unrecognized connect parameter"), + } + } + + // Note: we don't apply pgpass or env here + Ok(options) + } + + /// Parse a connection URL without reading environment variables or `.pgpass`. + /// + /// This is similar to [`FromStr`] but ensures no environment variables + /// or `.pgpass` files influence the connection options. All connection + /// parameters must be explicitly specified in the URL or will use the + /// hardcoded defaults from [`default_without_env()`](Self::default_without_env). + /// + /// # Example + /// + /// ```rust + /// # use sqlx_postgres::PgConnectOptions; + /// let options = PgConnectOptions::from_url_without_env( + /// "postgres://postgres:password@localhost:5432/mydb" + /// ).unwrap(); + /// ``` + pub fn from_url_without_env(url: &str) -> Result { + let url: Url = url.parse().map_err(Error::config)?; + Self::parse_from_url_without_env(&url) + } + pub(crate) fn build_url(&self) -> Url { let host = match &self.socket { Some(socket) => { @@ -340,3 +472,34 @@ fn built_url_can_be_parsed() { assert!(parsed.is_ok()); } + +#[test] +fn test_from_url_without_env() { + // Test that from_url_without_env uses hardcoded defaults, not environment + let url = "postgres://testuser:testpass@testhost:5433/testdb"; + let opts = PgConnectOptions::from_url_without_env(url).unwrap(); + + assert_eq!(opts.get_host(), "testhost"); + assert_eq!(opts.get_port(), 5433); + assert_eq!(opts.get_username(), "testuser"); + assert_eq!(opts.get_database(), Some("testdb")); + + // Test minimal URL uses hardcoded defaults + let url = "postgres://"; + let opts = PgConnectOptions::from_url_without_env(url).unwrap(); + + // Should use hardcoded defaults, not environment variables + assert_eq!(opts.get_port(), 5432); + assert_eq!(opts.get_username(), "postgres"); // Hardcoded default, not OS username + assert_eq!(opts.get_ssl_mode(), PgSslMode::Prefer); + + // Test URL with query parameters + let url = "postgres://user@host/db?sslmode=require&application_name=myapp"; + let opts = PgConnectOptions::from_url_without_env(url).unwrap(); + + assert_eq!(opts.get_username(), "user"); + assert_eq!(opts.get_host(), "host"); + assert_eq!(opts.get_database(), Some("db")); + assert_eq!(opts.get_ssl_mode(), PgSslMode::Require); + assert_eq!(opts.get_application_name(), Some("myapp")); +} diff --git a/sqlx-postgres/src/options/ssl_mode.rs b/sqlx-postgres/src/options/ssl_mode.rs index 657728ab07..dc35174e6b 100644 --- a/sqlx-postgres/src/options/ssl_mode.rs +++ b/sqlx-postgres/src/options/ssl_mode.rs @@ -4,7 +4,7 @@ use std::str::FromStr; /// Options for controlling the level of protection provided for PostgreSQL SSL connections. /// /// It is used by the [`ssl_mode`](super::PgConnectOptions::ssl_mode) method. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum PgSslMode { /// Only try a non-SSL connection. Disable,