1use std::time::Duration;
4
5use crate::error::{PgError, PgResult};
6
7#[derive(Debug, Clone)]
9pub struct PgConfig {
10 pub url: String,
12 pub host: String,
14 pub port: u16,
16 pub database: String,
18 pub user: String,
20 pub password: Option<String>,
22 pub ssl_mode: SslMode,
30 pub connect_timeout: Duration,
32 pub statement_timeout: Option<Duration>,
34 pub application_name: Option<String>,
36 pub options: Vec<(String, String)>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum SslMode {
51 Disable,
53 #[default]
55 Prefer,
56 Require,
59 VerifyCa,
63 VerifyFull,
65}
66
67fn 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
76fn is_safe_guc_value(value: &str) -> bool {
82 !value
83 .chars()
84 .any(|c| c.is_whitespace() || c == '\\' || c == '\'')
85}
86
87impl PgConfig {
88 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 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 pub fn builder() -> PgConfigBuilder {
183 PgConfigBuilder::new()
184 }
185
186 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 let mut options = Vec::new();
232 if let Some(timeout) = self.statement_timeout {
233 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 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#[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 pub fn new() -> Self {
278 Self::default()
279 }
280
281 pub fn url(mut self, url: impl Into<String>) -> Self {
283 self.url = Some(url.into());
284 self
285 }
286
287 pub fn host(mut self, host: impl Into<String>) -> Self {
289 self.host = Some(host.into());
290 self
291 }
292
293 pub fn port(mut self, port: u16) -> Self {
295 self.port = Some(port);
296 self
297 }
298
299 pub fn database(mut self, database: impl Into<String>) -> Self {
301 self.database = Some(database.into());
302 self
303 }
304
305 pub fn user(mut self, user: impl Into<String>) -> Self {
307 self.user = Some(user.into());
308 self
309 }
310
311 pub fn password(mut self, password: impl Into<String>) -> Self {
313 self.password = Some(password.into());
314 self
315 }
316
317 pub fn ssl_mode(mut self, mode: SslMode) -> Self {
319 self.ssl_mode = Some(mode);
320 self
321 }
322
323 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
325 self.connect_timeout = Some(timeout);
326 self
327 }
328
329 pub fn statement_timeout(mut self, timeout: Duration) -> Self {
331 self.statement_timeout = Some(timeout);
332 self
333 }
334
335 pub fn application_name(mut self, name: impl Into<String>) -> Self {
337 self.application_name = Some(name.into());
338 self
339 }
340
341 pub fn build(self) -> PgResult<PgConfig> {
343 if let Some(url) = self.url {
344 let mut config = PgConfig::from_url(url)?;
345
346 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 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 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 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 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 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}