1use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::error::{PgError, PgResult};
7
8#[derive(Debug, Clone)]
10pub struct PgConfig {
11 pub url: String,
13 pub host: String,
15 pub port: u16,
17 pub database: String,
19 pub user: String,
21 pub password: Option<String>,
23 pub ssl_mode: SslMode,
31 pub ssl_root_cert: Option<PathBuf>,
44 pub connect_timeout: Duration,
46 pub statement_timeout: Option<Duration>,
48 pub application_name: Option<String>,
50 pub options: Vec<(String, String)>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum SslMode {
65 Disable,
67 #[default]
69 Prefer,
70 Require,
73 VerifyCa,
77 VerifyFull,
79}
80
81fn 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
90fn is_safe_guc_value(value: &str) -> bool {
96 !value
97 .chars()
98 .any(|c| c.is_whitespace() || c == '\\' || c == '\'')
99}
100
101impl PgConfig {
102 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 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 pub fn builder() -> PgConfigBuilder {
202 PgConfigBuilder::new()
203 }
204
205 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 let mut options = Vec::new();
251 if let Some(timeout) = self.statement_timeout {
252 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 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#[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 pub fn new() -> Self {
298 Self::default()
299 }
300
301 pub fn url(mut self, url: impl Into<String>) -> Self {
303 self.url = Some(url.into());
304 self
305 }
306
307 pub fn host(mut self, host: impl Into<String>) -> Self {
309 self.host = Some(host.into());
310 self
311 }
312
313 pub fn port(mut self, port: u16) -> Self {
315 self.port = Some(port);
316 self
317 }
318
319 pub fn database(mut self, database: impl Into<String>) -> Self {
321 self.database = Some(database.into());
322 self
323 }
324
325 pub fn user(mut self, user: impl Into<String>) -> Self {
327 self.user = Some(user.into());
328 self
329 }
330
331 pub fn password(mut self, password: impl Into<String>) -> Self {
333 self.password = Some(password.into());
334 self
335 }
336
337 pub fn ssl_mode(mut self, mode: SslMode) -> Self {
339 self.ssl_mode = Some(mode);
340 self
341 }
342
343 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 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
353 self.connect_timeout = Some(timeout);
354 self
355 }
356
357 pub fn statement_timeout(mut self, timeout: Duration) -> Self {
359 self.statement_timeout = Some(timeout);
360 self
361 }
362
363 pub fn application_name(mut self, name: impl Into<String>) -> Self {
365 self.application_name = Some(name.into());
366 self
367 }
368
369 pub fn build(self) -> PgResult<PgConfig> {
371 if let Some(url) = self.url {
372 let mut config = PgConfig::from_url(url)?;
373
374 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 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 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 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 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 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 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}