Skip to main content

postgresql/
config.rs

1//! Connection configuration parsed from a `postgres://` URL.
2
3use std::{fmt, time::Duration};
4
5use crate::error::Error;
6
7/// How the client verifies the server certificate.
8///
9/// TLS is always required: `disable` and `prefer` (which permit plaintext) are
10/// rejected. The default is [`SslMode::Require`], which encrypts the
11/// connection but does **not** authenticate the server — an active attacker
12/// can impersonate it. Use [`SslMode::VerifyFull`] unless the network is
13/// trusted.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SslMode {
16    /// Require TLS. The server certificate is **not** verified, so the
17    /// connection is encrypted but the server is unauthenticated (like
18    /// libpq's `require`).
19    #[default]
20    Require,
21    /// Require TLS and verify the certificate chain, but not the hostname.
22    VerifyCa,
23    /// Require TLS and verify both the certificate chain and the hostname.
24    VerifyFull,
25}
26
27impl SslMode {
28    fn parse(value: &str) -> Result<Self, Error> {
29        match value {
30            "require" => Ok(SslMode::Require),
31            "verify-ca" => Ok(SslMode::VerifyCa),
32            "verify-full" => Ok(SslMode::VerifyFull),
33            "disable" | "prefer" | "allow" => Err(Error::Config(format!(
34                "sslmode `{value}` is not supported: this client always requires TLS"
35            ))),
36            other => Err(Error::Config(format!("unsupported sslmode `{other}`"))),
37        }
38    }
39}
40
41/// A parsed `postgres://` connection URL.
42#[derive(Clone)]
43pub struct Config {
44    /// Host name or IP address.
45    pub host: String,
46    /// TCP port.
47    pub port: u16,
48    /// User name.
49    pub user: String,
50    /// Password, if any.
51    pub password: Option<String>,
52    /// Database name, if any.
53    pub database: Option<String>,
54    /// `application_name` reported to the server.
55    pub application_name: Option<String>,
56    /// Extra `options` passed to the server.
57    pub options: Option<String>,
58    /// TLS mode.
59    pub sslmode: SslMode,
60    /// Path to a PEM file of trusted root certificates.
61    pub sslrootcert: Option<String>,
62    /// Timeout applied to establishing a connection.
63    pub connect_timeout: Duration,
64    /// IANA time zone used to interpret zone-less `timestamp`, `date` and
65    /// `time` values. Defaults to UTC.
66    pub timezone: Option<String>,
67    /// Maximum number of prepared statements cached per connection (`0`
68    /// disables caching, for transaction-mode poolers).
69    pub statement_cache_size: usize,
70    /// Largest accepted backend message, in bytes. Defaults to 128 MiB. A
71    /// hostile server cannot make the client buffer more than this per message.
72    pub max_message_len: usize,
73    /// Largest SCRAM iteration count accepted from the server. Defaults to
74    /// 2,000,000. The server chooses this value and it is pure client CPU
75    /// work, so it bounds the work an unauthenticated server can force.
76    pub max_scram_iterations: u32,
77    /// Pool settings parsed from the URL (overridden by an explicit
78    /// [`PoolConfig`]).
79    pub pool: PoolConfig,
80}
81
82impl fmt::Debug for Config {
83    /// Formats the configuration with the password redacted, so a config
84    /// logged or printed for diagnostics cannot leak the credentials.
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("Config")
87            .field("host", &self.host)
88            .field("port", &self.port)
89            .field("user", &self.user)
90            .field("password", &self.password.as_ref().map(|_| "<redacted>"))
91            .field("database", &self.database)
92            .field("application_name", &self.application_name)
93            .field("options", &self.options)
94            .field("sslmode", &self.sslmode)
95            .field("sslrootcert", &self.sslrootcert)
96            .field("connect_timeout", &self.connect_timeout)
97            .field("timezone", &self.timezone)
98            .field("statement_cache_size", &self.statement_cache_size)
99            .field("max_message_len", &self.max_message_len)
100            .field("max_scram_iterations", &self.max_scram_iterations)
101            .field("pool", &self.pool)
102            .finish()
103    }
104}
105
106fn decode(input: &str) -> Result<String, Error> {
107    Ok(percent_encoding::percent_decode_str(input)
108        .decode_utf8()
109        .map_err(|e| Error::Config(format!("invalid percent-encoding in connection URL: {e}")))?
110        .into_owned())
111}
112
113/// Rejects a value containing a NUL byte.
114///
115/// Startup parameters are NUL-delimited strings on the wire, so a NUL in one of
116/// them would split it and inject extra startup parameters (for example a
117/// different `user`). Refusing it at the configuration boundary keeps the
118/// connection string from being able to alter the login.
119fn reject_nul(name: &str, value: &str) -> Result<(), Error> {
120    if value.contains('\0') {
121        return Err(Error::Config(format!("`{name}` must not contain a NUL byte")));
122    }
123    Ok(())
124}
125
126/// Percent-decodes one URL component and rejects a decoded NUL byte.
127fn decode_checked(name: &str, input: &str) -> Result<String, Error> {
128    let decoded = decode(input)?;
129    reject_nul(name, &decoded)?;
130    Ok(decoded)
131}
132
133impl Config {
134    /// Parses a `postgres://` or `postgresql://` URL.
135    ///
136    /// Supported query parameters: `sslmode`, `sslrootcert`, `connect_timeout`
137    /// (seconds), `application_name`, `options`, `timezone`,
138    /// `statement_cache_size`, `max_message_len` (bytes),
139    /// `max_scram_iterations`, `pool_size`, `min_connections`,
140    /// `acquire_timeout`, `idle_timeout` and `max_lifetime`. Unknown
141    /// parameters are rejected so that typos surface immediately.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`Error::Config`] for a malformed URL, a missing user, or an
146    /// unsupported parameter.
147    pub fn parse(url: &str) -> Result<Self, Error> {
148        let parsed = url::Url::parse(url).map_err(|e| Error::Config(format!("invalid URL: {e}")))?;
149
150        let scheme = parsed.scheme();
151        if scheme != "postgres" && scheme != "postgresql" {
152            return Err(Error::Config(format!(
153                "unsupported URL scheme `{scheme}` (expected `postgres` or `postgresql`)"
154            )));
155        }
156
157        let host = parsed
158            .host_str()
159            .filter(|h| !h.is_empty())
160            .unwrap_or("localhost")
161            .to_string();
162        reject_nul("host", &host)?;
163        let port = parsed.port().unwrap_or(5432);
164
165        let mut user = decode_checked("user", parsed.username())?;
166        if user.is_empty() {
167            user = std::env::var("PGUSER")
168                .or_else(|_| std::env::var("USER"))
169                .or_else(|_| std::env::var("LOGNAME"))
170                .unwrap_or_default();
171            reject_nul("user", &user)?;
172        }
173        if user.is_empty() {
174            return Err(Error::Config("connection URL is missing a user".into()));
175        }
176
177        let password = parsed.password().map(|p| decode_checked("password", p)).transpose()?;
178
179        let database = {
180            let path = parsed.path().trim_start_matches('/');
181            if path.is_empty() {
182                None
183            } else {
184                Some(decode_checked("database", path)?)
185            }
186        };
187
188        let mut sslmode = SslMode::default();
189        let mut sslrootcert = None;
190        let mut application_name = None;
191        let mut options = None;
192        let mut connect_timeout = Duration::from_secs(10);
193        let mut timezone = None;
194        let mut statement_cache_size = 256usize;
195        let mut max_message_len = postgresql_protocol::DEFAULT_MAX_MESSAGE_LEN;
196        let mut max_scram_iterations = postgresql_protocol::scram::DEFAULT_MAX_SCRAM_ITERATIONS;
197        let mut pool = PoolConfig::default();
198
199        for (key, value) in parsed.query_pairs() {
200            match key.as_ref() {
201                "sslmode" => sslmode = SslMode::parse(&value)?,
202                "sslrootcert" => sslrootcert = Some(value.into_owned()),
203                "application_name" => application_name = Some(value.into_owned()),
204                "options" => options = Some(value.into_owned()),
205                "connect_timeout" => connect_timeout = Duration::from_secs(parse_secs("connect_timeout", &value)?),
206                "timezone" => timezone = Some(value.into_owned()),
207                "statement_cache_size" => {
208                    statement_cache_size = parse_count("statement_cache_size", &value)? as usize;
209                }
210                "max_message_len" => max_message_len = parse_len("max_message_len", &value)?,
211                "max_scram_iterations" => max_scram_iterations = parse_iterations("max_scram_iterations", &value)?,
212                "pool_size" => pool.pool_size = parse_count("pool_size", &value)?,
213                "min_connections" => pool.min_connections = parse_count("min_connections", &value)?,
214                "acquire_timeout" => pool.acquire_timeout = Duration::from_secs(parse_secs("acquire_timeout", &value)?),
215                "idle_timeout" => pool.idle_timeout = Duration::from_secs(parse_secs("idle_timeout", &value)?),
216                "max_lifetime" => pool.max_lifetime = Duration::from_secs(parse_secs("max_lifetime", &value)?),
217                other => {
218                    return Err(Error::Config(format!("unsupported connection parameter `{other}`")));
219                }
220            }
221        }
222
223        // The startup packet carries `application_name` and `options` as
224        // NUL-delimited strings; a NUL would inject extra startup parameters.
225        for (name, value) in [
226            ("sslrootcert", &sslrootcert),
227            ("application_name", &application_name),
228            ("options", &options),
229            ("timezone", &timezone),
230        ] {
231            if let Some(value) = value {
232                reject_nul(name, value)?;
233            }
234        }
235
236        Ok(Config {
237            host,
238            port,
239            user,
240            password,
241            database,
242            application_name,
243            options,
244            sslmode,
245            sslrootcert,
246            connect_timeout,
247            timezone,
248            statement_cache_size,
249            max_message_len,
250            max_scram_iterations,
251            pool,
252        })
253    }
254}
255
256/// Parses a byte length, rejecting values that are too small to be useful or
257/// too large to fit the protocol's 32-bit length prefix.
258fn parse_len(name: &str, value: &str) -> Result<usize, Error> {
259    let n: usize = value
260        .parse()
261        .map_err(|_| Error::Config(format!("`{name}` must be an integer number of bytes")))?;
262    if !(64 * 1024..=i32::MAX as usize).contains(&n) {
263        return Err(Error::Config(format!("`{name}` must be between 65536 and {} bytes", i32::MAX)));
264    }
265    Ok(n)
266}
267
268/// Parses a positive iteration count.
269fn parse_iterations(name: &str, value: &str) -> Result<u32, Error> {
270    let n: u32 = value
271        .parse()
272        .map_err(|_| Error::Config(format!("`{name}` must be a positive integer")))?;
273    if n == 0 {
274        return Err(Error::Config(format!("`{name}` must be greater than zero")));
275    }
276    Ok(n)
277}
278
279fn parse_secs(name: &str, value: &str) -> Result<u64, Error> {
280    value
281        .parse()
282        .map_err(|_| Error::Config(format!("`{name}` must be an integer number of seconds")))
283}
284
285fn parse_count(name: &str, value: &str) -> Result<u32, Error> {
286    value
287        .parse()
288        .map_err(|_| Error::Config(format!("`{name}` must be a non-negative integer")))
289}
290
291/// Connection pool tuning.
292///
293/// These values can also be supplied in the connection URL (`pool_size`,
294/// `min_connections`, `acquire_timeout`, `idle_timeout`, `max_lifetime`); a
295/// [`PoolConfig`] passed to [`crate::Pool::connect`] overrides the URL.
296#[derive(Debug, Clone)]
297pub struct PoolConfig {
298    /// Maximum number of connections in the pool.
299    pub pool_size: u32,
300    /// Connections to open eagerly when the pool is created, and the floor the
301    /// idle reaper will not drop below.
302    ///
303    /// This is best-effort: if some connections cannot be established the pool
304    /// starts with fewer, and the floor is not replenished if connections are
305    /// lost or expire.
306    pub min_connections: u32,
307    /// How long [`crate::Pool::get`] waits for a connection before failing.
308    pub acquire_timeout: Duration,
309    /// Close a connection after this long without use.
310    pub idle_timeout: Duration,
311    /// Close a connection this long after it was created.
312    pub max_lifetime: Duration,
313    /// Largest accepted backend message, in bytes, for connections created by
314    /// this pool. Overrides the URL's `max_message_len` when a [`PoolConfig`]
315    /// is supplied explicitly.
316    pub max_message_len: usize,
317}
318
319impl Default for PoolConfig {
320    fn default() -> Self {
321        PoolConfig {
322            pool_size: 80,
323            min_connections: 0,
324            acquire_timeout: Duration::from_secs(30),
325            idle_timeout: Duration::from_secs(600),
326            max_lifetime: Duration::from_secs(1800),
327            max_message_len: postgresql_protocol::DEFAULT_MAX_MESSAGE_LEN,
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn parses_full_url() {
338        let config = Config::parse(
339            "postgres://alice:s3cret@db.example.com:6543/app?sslmode=verify-full&application_name=web&connect_timeout=5",
340        )
341        .unwrap();
342        assert_eq!(config.host, "db.example.com");
343        assert_eq!(config.port, 6543);
344        assert_eq!(config.user, "alice");
345        assert_eq!(config.password.as_deref(), Some("s3cret"));
346        assert_eq!(config.database.as_deref(), Some("app"));
347        assert_eq!(config.application_name.as_deref(), Some("web"));
348        assert_eq!(config.sslmode, SslMode::VerifyFull);
349        assert_eq!(config.connect_timeout, Duration::from_secs(5));
350    }
351
352    #[test]
353    fn defaults() {
354        let config = Config::parse("postgres://bob@localhost/db").unwrap();
355        assert_eq!(config.port, 5432);
356        assert_eq!(config.sslmode, SslMode::Require);
357        assert_eq!(config.password, None);
358    }
359
360    #[test]
361    fn rejects_plaintext_sslmode() {
362        assert!(Config::parse("postgres://bob@localhost/db?sslmode=disable").is_err());
363        assert!(Config::parse("postgres://bob@localhost/db?sslmode=prefer").is_err());
364    }
365
366    #[test]
367    fn parses_pool_parameters() {
368        let config = Config::parse(
369            "postgres://bob@localhost/db?pool_size=5&min_connections=2&acquire_timeout=3&idle_timeout=60&max_lifetime=120",
370        )
371        .unwrap();
372        assert_eq!(config.pool.pool_size, 5);
373        assert_eq!(config.pool.min_connections, 2);
374        assert_eq!(config.pool.acquire_timeout, Duration::from_secs(3));
375        assert_eq!(config.pool.idle_timeout, Duration::from_secs(60));
376        assert_eq!(config.pool.max_lifetime, Duration::from_secs(120));
377    }
378
379    #[test]
380    fn rejects_unknown_parameter() {
381        assert!(Config::parse("postgres://bob@localhost/db?nope=1").is_err());
382    }
383
384    #[test]
385    fn parses_limits() {
386        let config =
387            Config::parse("postgres://bob@localhost/db?max_message_len=1048576&max_scram_iterations=5000").unwrap();
388        assert_eq!(config.max_message_len, 1_048_576);
389        assert_eq!(config.max_scram_iterations, 5000);
390        // The pool default is applied when no explicit PoolConfig is supplied.
391        assert_eq!(config.pool.max_message_len, postgresql_protocol::DEFAULT_MAX_MESSAGE_LEN);
392    }
393
394    #[test]
395    fn rejects_bad_limits() {
396        assert!(Config::parse("postgres://bob@localhost/db?max_message_len=100").is_err());
397        assert!(Config::parse("postgres://bob@localhost/db?max_message_len=99999999999").is_err());
398        assert!(Config::parse("postgres://bob@localhost/db?max_scram_iterations=0").is_err());
399    }
400
401    #[test]
402    fn rejects_unknown_scheme() {
403        assert!(Config::parse("mysql://bob@localhost/db").is_err());
404    }
405
406    #[test]
407    fn debug_redacts_the_password() {
408        let config = Config::parse("postgres://alice:s3cret@localhost/db").unwrap();
409        let rendered = format!("{config:?}");
410        assert!(!rendered.contains("s3cret"), "the password leaked in Debug: {rendered}");
411        assert!(rendered.contains("alice"));
412        assert!(rendered.contains("<redacted>"));
413    }
414
415    #[test]
416    fn rejects_nul_in_connection_parameters() {
417        // Startup parameters are NUL-delimited; a NUL must never reach them.
418        assert!(Config::parse("postgres://us%00er@localhost/db").is_err());
419        assert!(Config::parse("postgres://bob:pa%00ss@localhost/db").is_err());
420        assert!(Config::parse("postgres://bob@localhost/db%00name").is_err());
421        assert!(Config::parse("postgres://bob@localhost/db?application_name=a%00user%00admin").is_err());
422        assert!(Config::parse("postgres://bob@localhost/db?options=-c%00x=1").is_err());
423        assert!(Config::parse("postgres://bob@localhost/db?timezone=UTC%00").is_err());
424    }
425
426    #[test]
427    fn decodes_percent_encoding() {
428        let config = Config::parse("postgres://a%40b:p%3Ass@localhost/db%20name").unwrap();
429        assert_eq!(config.user, "a@b");
430        assert_eq!(config.password.as_deref(), Some("p:ss"));
431        assert_eq!(config.database.as_deref(), Some("db name"));
432    }
433}