1use std::{fmt, time::Duration};
4
5use crate::error::Error;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SslMode {
16 #[default]
20 Require,
21 VerifyCa,
23 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#[derive(Clone)]
43pub struct Config {
44 pub host: String,
46 pub port: u16,
48 pub user: String,
50 pub password: Option<String>,
52 pub database: Option<String>,
54 pub application_name: Option<String>,
56 pub options: Option<String>,
58 pub sslmode: SslMode,
60 pub sslrootcert: Option<String>,
62 pub connect_timeout: Duration,
64 pub timezone: Option<String>,
67 pub statement_cache_size: usize,
70 pub max_message_len: usize,
73 pub max_scram_iterations: u32,
77 pub pool: PoolConfig,
80}
81
82impl fmt::Debug for Config {
83 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
113fn 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
126fn 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 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 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
256fn 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
268fn 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#[derive(Debug, Clone)]
297pub struct PoolConfig {
298 pub pool_size: u32,
300 pub min_connections: u32,
307 pub acquire_timeout: Duration,
309 pub idle_timeout: Duration,
311 pub max_lifetime: Duration,
313 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 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 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}