Skip to main content

prax_postgres/
config.rs

1//! PostgreSQL connection configuration.
2
3use std::time::Duration;
4
5use crate::error::{PgError, PgResult};
6
7/// PostgreSQL connection configuration.
8#[derive(Debug, Clone)]
9pub struct PgConfig {
10    /// Database URL.
11    pub url: String,
12    /// Host (extracted from URL or explicit).
13    pub host: String,
14    /// Port (default: 5432).
15    pub port: u16,
16    /// Database name.
17    pub database: String,
18    /// Username.
19    pub user: String,
20    /// Password.
21    pub password: Option<String>,
22    /// SSL mode.
23    ///
24    /// With the default `tls` cargo feature, TLS connections are established
25    /// via rustls with certificates verified against the Mozilla root store
26    /// (chain + hostname). Without the feature, any TLS-requiring mode fails
27    /// at pool build time with a clear error — it is never silently
28    /// downgraded to plaintext.
29    pub ssl_mode: SslMode,
30    /// Connection timeout.
31    pub connect_timeout: Duration,
32    /// Statement timeout.
33    pub statement_timeout: Option<Duration>,
34    /// Application name (shown in pg_stat_activity).
35    pub application_name: Option<String>,
36    /// Additional options.
37    pub options: Vec<(String, String)>,
38}
39
40/// SSL mode for connections.
41///
42/// With the default `tls` cargo feature, `Require`/`VerifyCa`/`VerifyFull`
43/// establish rustls-encrypted connections verified against the Mozilla root
44/// store. `Prefer` uses TLS when the server offers it and falls back to
45/// plaintext only when the server declines TLS (note: stricter than libpq —
46/// a certificate verification failure fails the connection rather than
47/// retrying plaintext). Without the `tls` feature, TLS-requiring modes fail
48/// at pool build time.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum SslMode {
51    /// Disable SSL.
52    Disable,
53    /// Prefer SSL but allow non-SSL when the server declines TLS.
54    #[default]
55    Prefer,
56    /// Require SSL. Certificates are verified (chain + hostname) — stricter
57    /// than libpq's `require`, which skips verification.
58    Require,
59    /// Require SSL and verify the certificate chain. Currently also verifies
60    /// the hostname (i.e. behaves as `VerifyFull`; libpq's hostname-less
61    /// `verify-ca` is not yet distinguished).
62    VerifyCa,
63    /// Require SSL and verify the certificate chain and hostname.
64    VerifyFull,
65}
66
67/// Validate a GUC name destined for the `options` startup parameter.
68/// Postgres GUC names match `^[A-Za-z_][A-Za-z0-9_.]*$` (the `.`
69/// separates extension namespaces, e.g. `pg_trgm.similarity_threshold`).
70fn is_valid_guc_key(key: &str) -> bool {
71    let mut chars = key.chars();
72    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
73        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
74}
75
76/// Reject values that could break out of the space-joined `options`
77/// startup parameter: whitespace terminates the assignment and starts a
78/// new token, and `\` / `'` are libpq's quoting characters in that
79/// string. A percent-decoded URL value could otherwise smuggle extra
80/// `-c key=value` assignments (e.g. `?x=1%20-c%20search_path%3Devil`).
81fn is_safe_guc_value(value: &str) -> bool {
82    !value
83        .chars()
84        .any(|c| c.is_whitespace() || c == '\\' || c == '\'')
85}
86
87impl PgConfig {
88    /// Create a new configuration from a database URL.
89    pub fn from_url(url: impl Into<String>) -> PgResult<Self> {
90        let url = url.into();
91        let parsed = url::Url::parse(&url)
92            .map_err(|e| PgError::config(format!("invalid database URL: {}", e)))?;
93
94        if parsed.scheme() != "postgresql" && parsed.scheme() != "postgres" {
95            return Err(PgError::config(format!(
96                "invalid scheme: expected 'postgresql' or 'postgres', got '{}'",
97                parsed.scheme()
98            )));
99        }
100
101        let host = parsed
102            .host_str()
103            .ok_or_else(|| PgError::config("missing host in URL"))?
104            .to_string();
105
106        let port = parsed.port().unwrap_or(5432);
107
108        let database = parsed.path().trim_start_matches('/').to_string();
109
110        if database.is_empty() {
111            return Err(PgError::config("missing database name in URL"));
112        }
113
114        let user = if parsed.username().is_empty() {
115            "postgres".to_string()
116        } else {
117            parsed.username().to_string()
118        };
119
120        let password = parsed.password().map(String::from);
121
122        // Parse query parameters
123        let mut ssl_mode = SslMode::Prefer;
124        let mut connect_timeout = Duration::from_secs(30);
125        let mut statement_timeout = None;
126        let mut application_name = None;
127        let mut options = Vec::new();
128
129        for (key, value) in parsed.query_pairs() {
130            let key_str: &str = &key;
131            let value_str: &str = &value;
132            match key_str {
133                "sslmode" => {
134                    ssl_mode = match value_str {
135                        "disable" => SslMode::Disable,
136                        "prefer" => SslMode::Prefer,
137                        "require" => SslMode::Require,
138                        "verify-ca" => SslMode::VerifyCa,
139                        "verify-full" => SslMode::VerifyFull,
140                        other => {
141                            return Err(PgError::config(format!("invalid sslmode: {}", other)));
142                        }
143                    };
144                }
145                "connect_timeout" => {
146                    let secs: u64 = value_str
147                        .parse()
148                        .map_err(|_| PgError::config("invalid connect_timeout"))?;
149                    connect_timeout = Duration::from_secs(secs);
150                }
151                "statement_timeout" => {
152                    let ms: u64 = value_str
153                        .parse()
154                        .map_err(|_| PgError::config("invalid statement_timeout"))?;
155                    statement_timeout = Some(Duration::from_millis(ms));
156                }
157                "application_name" => {
158                    application_name = Some(value_str.to_string());
159                }
160                _ => {
161                    options.push((key_str.to_string(), value_str.to_string()));
162                }
163            }
164        }
165
166        Ok(Self {
167            url,
168            host,
169            port,
170            database,
171            user,
172            password,
173            ssl_mode,
174            connect_timeout,
175            statement_timeout,
176            application_name,
177            options,
178        })
179    }
180
181    /// Create a builder for configuration.
182    pub fn builder() -> PgConfigBuilder {
183        PgConfigBuilder::new()
184    }
185
186    /// Convert to tokio-postgres config.
187    ///
188    /// Applies everything `tokio_postgres::Config` can express without a TLS
189    /// connector: host/port/dbname/user/password, `application_name`,
190    /// `connect_timeout`, the driver's [`tokio_postgres::config::SslMode`],
191    /// plus `statement_timeout` and any extra `options` (passed via the
192    /// libpq-style `options` startup parameter as space-separated
193    /// `-c key=value` pairs).
194    ///
195    /// Option pairs whose key is not a valid GUC name, or whose value
196    /// contains whitespace / `\` / `'`, are dropped with a warning:
197    /// percent-decoded values could otherwise smuggle extra `-c`
198    /// assignments into the space-joined string.
199    ///
200    /// `Require`/`VerifyCa`/`VerifyFull` all map to the driver's
201    /// `SslMode::Require`; the pool supplies the rustls connector (with
202    /// webpki certificate verification) that makes the mode satisfiable.
203    pub fn to_pg_config(&self) -> tokio_postgres::Config {
204        let mut config = tokio_postgres::Config::new();
205        config.host(&self.host);
206        config.port(self.port);
207        config.dbname(&self.database);
208        config.user(&self.user);
209
210        if let Some(ref password) = self.password {
211            config.password(password);
212        }
213
214        if let Some(ref app_name) = self.application_name {
215            config.application_name(app_name);
216        }
217
218        config.connect_timeout(self.connect_timeout);
219
220        let driver_ssl_mode = match self.ssl_mode {
221            SslMode::Disable => tokio_postgres::config::SslMode::Disable,
222            SslMode::Prefer => tokio_postgres::config::SslMode::Prefer,
223            SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull => {
224                tokio_postgres::config::SslMode::Require
225            }
226        };
227        config.ssl_mode(driver_ssl_mode);
228
229        // `statement_timeout` and arbitrary GUC options ride the libpq-style
230        // `options` startup parameter as space-separated `-c key=value` pairs.
231        let mut options = Vec::new();
232        if let Some(timeout) = self.statement_timeout {
233            // A bare integer is interpreted as milliseconds by PostgreSQL.
234            options.push(format!("-c statement_timeout={}", timeout.as_millis()));
235        }
236        for (key, value) in &self.options {
237            if !is_valid_guc_key(key) {
238                tracing::warn!(key = %key, "dropping connection option with invalid GUC name");
239                continue;
240            }
241            if !is_safe_guc_value(value) {
242                // Name the key but never the value, so a malicious
243                // payload doesn't land in the logs.
244                tracing::warn!(
245                    key = %key,
246                    "dropping connection option whose value contains whitespace or quoting characters"
247                );
248                continue;
249            }
250            options.push(format!("-c {}={}", key, value));
251        }
252        if !options.is_empty() {
253            config.options(options.join(" "));
254        }
255
256        config
257    }
258}
259
260/// Builder for PostgreSQL configuration.
261#[derive(Debug, Default)]
262pub struct PgConfigBuilder {
263    url: Option<String>,
264    host: Option<String>,
265    port: Option<u16>,
266    database: Option<String>,
267    user: Option<String>,
268    password: Option<String>,
269    ssl_mode: Option<SslMode>,
270    connect_timeout: Option<Duration>,
271    statement_timeout: Option<Duration>,
272    application_name: Option<String>,
273}
274
275impl PgConfigBuilder {
276    /// Create a new builder.
277    pub fn new() -> Self {
278        Self::default()
279    }
280
281    /// Set the database URL (parses all connection parameters).
282    pub fn url(mut self, url: impl Into<String>) -> Self {
283        self.url = Some(url.into());
284        self
285    }
286
287    /// Set the host.
288    pub fn host(mut self, host: impl Into<String>) -> Self {
289        self.host = Some(host.into());
290        self
291    }
292
293    /// Set the port.
294    pub fn port(mut self, port: u16) -> Self {
295        self.port = Some(port);
296        self
297    }
298
299    /// Set the database name.
300    pub fn database(mut self, database: impl Into<String>) -> Self {
301        self.database = Some(database.into());
302        self
303    }
304
305    /// Set the username.
306    pub fn user(mut self, user: impl Into<String>) -> Self {
307        self.user = Some(user.into());
308        self
309    }
310
311    /// Set the password.
312    pub fn password(mut self, password: impl Into<String>) -> Self {
313        self.password = Some(password.into());
314        self
315    }
316
317    /// Set the SSL mode.
318    pub fn ssl_mode(mut self, mode: SslMode) -> Self {
319        self.ssl_mode = Some(mode);
320        self
321    }
322
323    /// Set the connection timeout.
324    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
325        self.connect_timeout = Some(timeout);
326        self
327    }
328
329    /// Set the statement timeout.
330    pub fn statement_timeout(mut self, timeout: Duration) -> Self {
331        self.statement_timeout = Some(timeout);
332        self
333    }
334
335    /// Set the application name.
336    pub fn application_name(mut self, name: impl Into<String>) -> Self {
337        self.application_name = Some(name.into());
338        self
339    }
340
341    /// Build the configuration.
342    pub fn build(self) -> PgResult<PgConfig> {
343        if let Some(url) = self.url {
344            let mut config = PgConfig::from_url(url)?;
345
346            // Override with explicit values
347            if let Some(host) = self.host {
348                config.host = host;
349            }
350            if let Some(port) = self.port {
351                config.port = port;
352            }
353            if let Some(database) = self.database {
354                config.database = database;
355            }
356            if let Some(user) = self.user {
357                config.user = user;
358            }
359            if let Some(password) = self.password {
360                config.password = Some(password);
361            }
362            if let Some(ssl_mode) = self.ssl_mode {
363                config.ssl_mode = ssl_mode;
364            }
365            if let Some(timeout) = self.connect_timeout {
366                config.connect_timeout = timeout;
367            }
368            if let Some(timeout) = self.statement_timeout {
369                config.statement_timeout = Some(timeout);
370            }
371            if let Some(name) = self.application_name {
372                config.application_name = Some(name);
373            }
374
375            Ok(config)
376        } else {
377            // Build from individual components
378            let host = self.host.unwrap_or_else(|| "localhost".to_string());
379            let port = self.port.unwrap_or(5432);
380            let database = self
381                .database
382                .ok_or_else(|| PgError::config("database name is required"))?;
383            let user = self.user.unwrap_or_else(|| "postgres".to_string());
384
385            let url = format!(
386                "postgresql://{}{}@{}:{}/{}",
387                user,
388                self.password
389                    .as_ref()
390                    .map(|p| format!(":{}", p))
391                    .unwrap_or_default(),
392                host,
393                port,
394                database
395            );
396
397            Ok(PgConfig {
398                url,
399                host,
400                port,
401                database,
402                user,
403                password: self.password,
404                ssl_mode: self.ssl_mode.unwrap_or_default(),
405                connect_timeout: self.connect_timeout.unwrap_or(Duration::from_secs(30)),
406                statement_timeout: self.statement_timeout,
407                application_name: self.application_name,
408                options: Vec::new(),
409            })
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_config_from_url() {
420        let config = PgConfig::from_url("postgresql://user:pass@localhost:5432/mydb").unwrap();
421        assert_eq!(config.host, "localhost");
422        assert_eq!(config.port, 5432);
423        assert_eq!(config.database, "mydb");
424        assert_eq!(config.user, "user");
425        assert_eq!(config.password, Some("pass".to_string()));
426    }
427
428    #[test]
429    fn test_config_from_url_with_params() {
430        let config =
431            PgConfig::from_url("postgresql://localhost/mydb?sslmode=require&application_name=prax")
432                .unwrap();
433        assert_eq!(config.ssl_mode, SslMode::Require);
434        assert_eq!(config.application_name, Some("prax".to_string()));
435    }
436
437    #[test]
438    fn test_to_pg_config_applies_statement_timeout_and_options() {
439        let config = PgConfig::from_url(
440            "postgresql://localhost/mydb?statement_timeout=5000&search_path=public",
441        )
442        .unwrap();
443        let pg_config = config.to_pg_config();
444        assert_eq!(
445            pg_config.get_options(),
446            Some("-c statement_timeout=5000 -c search_path=public")
447        );
448    }
449
450    #[test]
451    fn test_to_pg_config_without_timeouts_or_options_sets_none() {
452        let config = PgConfig::from_url("postgresql://localhost/mydb").unwrap();
453        let pg_config = config.to_pg_config();
454        assert_eq!(pg_config.get_options(), None);
455    }
456
457    #[test]
458    fn test_to_pg_config_drops_option_with_smuggled_value() {
459        // Percent-decoded whitespace in a value must not survive into
460        // the space-joined `options` string, where it would smuggle in
461        // extra `-c key=value` assignments.
462        let config =
463            PgConfig::from_url("postgresql://localhost/mydb?x=1%20-c%20search_path%3Devil")
464                .unwrap();
465        let pg_config = config.to_pg_config();
466        assert_eq!(pg_config.get_options(), None);
467    }
468
469    #[test]
470    fn test_to_pg_config_drops_option_with_invalid_key() {
471        let config =
472            PgConfig::from_url("postgresql://localhost/mydb?bad%20key=1&search_path=public")
473                .unwrap();
474        let pg_config = config.to_pg_config();
475        // The invalid key is dropped; the valid option is kept.
476        assert_eq!(pg_config.get_options(), Some("-c search_path=public"));
477    }
478
479    #[test]
480    fn test_to_pg_config_drops_option_with_quoting_chars() {
481        // `\` and `'` are libpq's quoting characters inside `options`.
482        let config = PgConfig::from_url("postgresql://localhost/mydb?a=b%5Cc&d=e%27f").unwrap();
483        let pg_config = config.to_pg_config();
484        assert_eq!(pg_config.get_options(), None);
485    }
486
487    #[test]
488    fn test_to_pg_config_maps_sslmode_require() {
489        // TLS is supported via rustls: `require` maps to the driver's
490        // `Require` (never downgraded).
491        let config = PgConfig::from_url("postgresql://localhost/mydb?sslmode=require").unwrap();
492        let pg_config = config.to_pg_config();
493        assert_eq!(
494            pg_config.get_ssl_mode(),
495            tokio_postgres::config::SslMode::Require
496        );
497    }
498
499    #[test]
500    fn test_from_url_parses_verify_modes() {
501        let config = PgConfig::from_url("postgresql://localhost/mydb?sslmode=verify-ca").unwrap();
502        assert_eq!(config.ssl_mode, SslMode::VerifyCa);
503        let pg_config = config.to_pg_config();
504        assert_eq!(
505            pg_config.get_ssl_mode(),
506            tokio_postgres::config::SslMode::Require
507        );
508
509        let config = PgConfig::from_url("postgresql://localhost/mydb?sslmode=verify-full").unwrap();
510        assert_eq!(config.ssl_mode, SslMode::VerifyFull);
511
512        assert!(PgConfig::from_url("postgresql://localhost/mydb?sslmode=bogus").is_err());
513    }
514
515    #[test]
516    fn test_to_pg_config_maps_sslmode_disable() {
517        let config = PgConfig::from_url("postgresql://localhost/mydb?sslmode=disable").unwrap();
518        let pg_config = config.to_pg_config();
519        assert_eq!(
520            pg_config.get_ssl_mode(),
521            tokio_postgres::config::SslMode::Disable
522        );
523    }
524
525    #[test]
526    fn test_config_builder() {
527        let config = PgConfig::builder()
528            .host("localhost")
529            .port(5432)
530            .database("mydb")
531            .user("postgres")
532            .build()
533            .unwrap();
534
535        assert_eq!(config.host, "localhost");
536        assert_eq!(config.database, "mydb");
537    }
538
539    #[test]
540    fn test_config_invalid_scheme() {
541        let result = PgConfig::from_url("mysql://localhost/db");
542        assert!(result.is_err());
543    }
544}