Skip to main content

prax_postgres/
config.rs

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