Skip to main content

qail_pg/driver/pool/
config.rs

1//! Pool configuration, URL parsing, and builder.
2
3use crate::driver::{
4    AuthSettings, GssEncMode, GssTokenProvider, GssTokenProviderEx, PgError, PgResult,
5    ScramChannelBindingMode, TlsConfig, TlsMode,
6};
7use std::time::Duration;
8
9/// Configuration for a PostgreSQL connection pool.
10///
11/// Use the builder pattern to customise settings:
12///
13/// ```ignore
14/// use std::time::Duration;
15/// use qail_pg::driver::pool::PoolConfig;
16/// let config = PoolConfig::new("localhost", 5432, "app", "mydb")
17///     .password("secret")
18///     .max_connections(20)
19///     .acquire_timeout(Duration::from_secs(5));
20/// ```
21#[derive(Clone)]
22pub struct PoolConfig {
23    /// PostgreSQL server hostname or IP address.
24    pub host: String,
25    /// PostgreSQL server port (default: 5432).
26    pub port: u16,
27    /// Database role / user name.
28    pub user: String,
29    /// Target database name.
30    pub database: String,
31    /// Optional password for authentication.
32    pub password: Option<String>,
33    /// Hard upper limit on simultaneous connections (default: 10).
34    pub max_connections: usize,
35    /// Minimum idle connections kept warm in the pool (default: 1).
36    pub min_connections: usize,
37    /// Close idle connections after this duration (default: 10 min).
38    pub idle_timeout: Duration,
39    /// Maximum time to wait when acquiring a connection (default: 30s).
40    pub acquire_timeout: Duration,
41    /// TCP connect timeout for new connections (default: 10s).
42    pub connect_timeout: Duration,
43    /// Optional maximum lifetime of any connection in the pool.
44    pub max_lifetime: Option<Duration>,
45    /// Maximum number of leaked-connection cleanup tasks that may run concurrently.
46    ///
47    /// When a `PooledConnection` is dropped without calling `release()`, the pool
48    /// can attempt async reset-and-return. This bound prevents unbounded cleanup fanout.
49    pub leaked_cleanup_queue: usize,
50    /// When `true`, run a health check (`SELECT 1`) before handing out a connection.
51    pub test_on_acquire: bool,
52    /// TLS mode for new connections.
53    pub tls_mode: TlsMode,
54    /// Optional custom CA bundle (PEM) for server certificate validation.
55    pub tls_ca_cert_pem: Option<Vec<u8>>,
56    /// Optional mTLS client certificate/key configuration.
57    pub mtls: Option<TlsConfig>,
58    /// Optional callback for Kerberos/GSS/SSPI token generation.
59    pub gss_token_provider: Option<GssTokenProvider>,
60    /// Optional stateful callback for Kerberos/GSS/SSPI token generation.
61    pub gss_token_provider_ex: Option<GssTokenProviderEx>,
62    /// Number of retries for transient GSS/Kerberos connection failures.
63    pub gss_connect_retries: usize,
64    /// Base delay for GSS/Kerberos connect retry backoff.
65    pub gss_retry_base_delay: Duration,
66    /// Transient GSS failures in one window before opening the local circuit.
67    pub gss_circuit_breaker_threshold: usize,
68    /// Rolling window used to count transient GSS failures.
69    pub gss_circuit_breaker_window: Duration,
70    /// Cooldown duration while the local GSS circuit stays open.
71    pub gss_circuit_breaker_cooldown: Duration,
72    /// Password-auth policy.
73    pub auth_settings: AuthSettings,
74    /// GSSAPI session encryption mode (`gssencmode` URL parameter).
75    pub gss_enc_mode: GssEncMode,
76    /// Opt into Linux io_uring for plain TCP transport.
77    ///
78    /// Disabled by default because some production environments disallow
79    /// io_uring for security policy reasons. TLS/mTLS/GSSENC paths ignore this.
80    pub io_uring: bool,
81}
82
83impl PoolConfig {
84    /// Create a new pool configuration with **production-safe** defaults.
85    ///
86    /// Defaults: `tls_mode = Require`, `auth_settings = scram_only()`.
87    /// For local development without TLS, use [`PoolConfig::new_dev`].
88    ///
89    /// # Arguments
90    ///
91    /// * `host` — PostgreSQL server hostname or IP.
92    /// * `port` — TCP port (typically 5432).
93    /// * `user` — PostgreSQL role name.
94    /// * `database` — Target database name.
95    pub fn new(host: &str, port: u16, user: &str, database: &str) -> Self {
96        Self {
97            host: host.to_string(),
98            port,
99            user: user.to_string(),
100            database: database.to_string(),
101            password: None,
102            max_connections: 10,
103            min_connections: 1,
104            idle_timeout: Duration::from_secs(600), // 10 minutes
105            acquire_timeout: Duration::from_secs(30), // 30 seconds
106            connect_timeout: Duration::from_secs(10), // 10 seconds
107            max_lifetime: None,                     // No limit by default
108            leaked_cleanup_queue: 64,               // Bounded cleanup fanout
109            test_on_acquire: false,                 // Disabled by default for performance
110            tls_mode: TlsMode::Prefer,
111            tls_ca_cert_pem: None,
112            mtls: None,
113            gss_token_provider: None,
114            gss_token_provider_ex: None,
115            gss_connect_retries: 2,
116            gss_retry_base_delay: Duration::from_millis(150),
117            gss_circuit_breaker_threshold: 8,
118            gss_circuit_breaker_window: Duration::from_secs(30),
119            gss_circuit_breaker_cooldown: Duration::from_secs(15),
120            auth_settings: AuthSettings::scram_only(),
121            gss_enc_mode: GssEncMode::Disable,
122            io_uring: false,
123        }
124    }
125
126    /// Create a pool configuration with **permissive** defaults for local development.
127    ///
128    /// Defaults: `tls_mode = Disable`, `auth_settings = default()` (accepts any auth).
129    /// Do NOT use in production.
130    pub fn new_dev(host: &str, port: u16, user: &str, database: &str) -> Self {
131        let mut config = Self::new(host, port, user, database);
132        config.tls_mode = TlsMode::Disable;
133        config.auth_settings = AuthSettings::default();
134        config
135    }
136
137    /// Set password for authentication.
138    pub fn password(mut self, password: &str) -> Self {
139        self.password = Some(password.to_string());
140        self
141    }
142
143    /// Set maximum simultaneous connections.
144    pub fn max_connections(mut self, max: usize) -> Self {
145        self.max_connections = max;
146        self
147    }
148
149    /// Set minimum idle connections.
150    pub fn min_connections(mut self, min: usize) -> Self {
151        self.min_connections = min;
152        self
153    }
154
155    /// Set idle timeout (connections idle longer than this are closed).
156    pub fn idle_timeout(mut self, timeout: Duration) -> Self {
157        self.idle_timeout = timeout;
158        self
159    }
160
161    /// Set acquire timeout (max wait time when getting a connection).
162    pub fn acquire_timeout(mut self, timeout: Duration) -> Self {
163        self.acquire_timeout = timeout;
164        self
165    }
166
167    /// Set connect timeout (max time to establish new connection).
168    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
169        self.connect_timeout = timeout;
170        self
171    }
172
173    /// Set maximum lifetime of a connection before recycling.
174    pub fn max_lifetime(mut self, lifetime: Duration) -> Self {
175        self.max_lifetime = Some(lifetime);
176        self
177    }
178
179    /// Set max concurrent leaked-connection cleanup tasks.
180    ///
181    /// Values <= 1 force strict fallback-destroy behavior under burst leaks.
182    pub fn leaked_cleanup_queue(mut self, max: usize) -> Self {
183        self.leaked_cleanup_queue = max;
184        self
185    }
186
187    /// Enable connection validation on acquire.
188    pub fn test_on_acquire(mut self, enabled: bool) -> Self {
189        self.test_on_acquire = enabled;
190        self
191    }
192
193    /// Set TLS mode for pool connections.
194    pub fn tls_mode(mut self, mode: TlsMode) -> Self {
195        self.tls_mode = mode;
196        self
197    }
198
199    /// Set custom CA bundle (PEM) for TLS validation.
200    pub fn tls_ca_cert_pem(mut self, ca_cert_pem: Vec<u8>) -> Self {
201        self.tls_ca_cert_pem = Some(ca_cert_pem);
202        self
203    }
204
205    /// Enable mTLS for pool connections.
206    pub fn mtls(mut self, config: TlsConfig) -> Self {
207        self.mtls = Some(config);
208        self.tls_mode = TlsMode::Require;
209        self
210    }
211
212    /// Set Kerberos/GSS/SSPI token provider callback.
213    pub fn gss_token_provider(mut self, provider: GssTokenProvider) -> Self {
214        self.gss_token_provider = Some(provider);
215        self
216    }
217
218    /// Set a stateful Kerberos/GSS/SSPI token provider.
219    pub fn gss_token_provider_ex(mut self, provider: GssTokenProviderEx) -> Self {
220        self.gss_token_provider_ex = Some(provider);
221        self
222    }
223
224    /// Set retry count for transient GSS/Kerberos connection failures.
225    pub fn gss_connect_retries(mut self, retries: usize) -> Self {
226        self.gss_connect_retries = retries;
227        self
228    }
229
230    /// Set base backoff delay for GSS/Kerberos connection retry.
231    pub fn gss_retry_base_delay(mut self, delay: Duration) -> Self {
232        self.gss_retry_base_delay = delay;
233        self
234    }
235
236    /// Set failure threshold for opening local GSS circuit breaker.
237    pub fn gss_circuit_breaker_threshold(mut self, threshold: usize) -> Self {
238        self.gss_circuit_breaker_threshold = threshold;
239        self
240    }
241
242    /// Set rolling failure window for GSS circuit breaker.
243    pub fn gss_circuit_breaker_window(mut self, window: Duration) -> Self {
244        self.gss_circuit_breaker_window = window;
245        self
246    }
247
248    /// Set cooldown duration for open GSS circuit breaker.
249    pub fn gss_circuit_breaker_cooldown(mut self, cooldown: Duration) -> Self {
250        self.gss_circuit_breaker_cooldown = cooldown;
251        self
252    }
253
254    /// Set authentication policy.
255    pub fn auth_settings(mut self, settings: AuthSettings) -> Self {
256        self.auth_settings = settings;
257        self
258    }
259
260    /// Opt into Linux io_uring for plain TCP transport.
261    ///
262    /// This only has an effect on Linux builds compiled with the `io_uring`
263    /// feature and when the connection uses plaintext TCP.
264    pub fn io_uring(mut self, enabled: bool) -> Self {
265        self.io_uring = enabled;
266        self
267    }
268
269    /// Create a `PoolConfig` from a centralized `QailConfig`.
270    ///
271    /// Parses `postgres.url` for host/port/user/database/password
272    /// and applies pool tuning from `[postgres]` section.
273    pub fn from_qail_config(qail: &qail_core::config::QailConfig) -> PgResult<Self> {
274        let pg = &qail.postgres;
275        let (host, port, user, database, password) = parse_pg_url(&pg.url)?;
276
277        let mut config = PoolConfig::new(&host, port, &user, &database)
278            .max_connections(pg.max_connections)
279            .min_connections(pg.min_connections)
280            .idle_timeout(Duration::from_secs(pg.idle_timeout_secs))
281            .acquire_timeout(Duration::from_secs(pg.acquire_timeout_secs))
282            .connect_timeout(Duration::from_secs(pg.connect_timeout_secs))
283            .test_on_acquire(pg.test_on_acquire)
284            .io_uring(pg.io_uring);
285
286        if let Some(ref pw) = password {
287            config = config.password(pw);
288        }
289
290        // Optional URL query params for enterprise auth/TLS settings.
291        if let Some((_, query)) = pg.url.split_once('?') {
292            apply_url_query_params(&mut config, query, &host)?;
293        }
294
295        Ok(config)
296    }
297
298    /// Create a pool configuration directly from a PostgreSQL URL.
299    ///
300    /// This parses the same URL shape and query parameters as
301    /// [`crate::driver::PgDriver::connect_url`], then applies the pool builder
302    /// defaults for connection limits and timeouts.
303    pub fn from_url(url: &str) -> PgResult<Self> {
304        let (host, port, user, database, password) = parse_pg_url(url)?;
305        let mut config = PoolConfig::new(&host, port, &user, &database);
306
307        if let Some(ref pw) = password {
308            config = config.password(pw);
309        }
310
311        if let Some((_, query)) = url.split_once('?') {
312            apply_url_query_params(&mut config, query, &host)?;
313        }
314
315        Ok(config)
316    }
317}
318
319/// Apply enterprise auth/TLS query parameters to a `PoolConfig`.
320///
321/// Shared between `PoolConfig::from_qail_config` and `PgDriver::connect_url`
322/// so that both paths support the same set of URL knobs.
323#[allow(unused_variables)]
324pub(crate) fn apply_url_query_params(
325    config: &mut PoolConfig,
326    query: &str,
327    host: &str,
328) -> PgResult<()> {
329    let mut sslcert: Option<String> = None;
330    let mut sslkey: Option<String> = None;
331    let mut gss_provider: Option<String> = None;
332    let mut gss_service = "postgres".to_string();
333    let mut gss_target: Option<String> = None;
334
335    for pair in query.split('&').filter(|p| !p.is_empty()) {
336        let mut kv = pair.splitn(2, '=');
337        let key = percent_decode(kv.next().unwrap_or_default().trim())?;
338        let value = percent_decode(kv.next().unwrap_or_default().trim())?;
339
340        match key.as_str() {
341            "sslmode" => {
342                let mode = TlsMode::parse_sslmode(&value).ok_or_else(|| {
343                    PgError::Connection(format!("Invalid sslmode value: {}", value))
344                })?;
345                config.tls_mode = mode;
346            }
347            "gssencmode" => {
348                let mode = GssEncMode::parse_gssencmode(&value).ok_or_else(|| {
349                    PgError::Connection(format!("Invalid gssencmode value: {}", value))
350                })?;
351                config.gss_enc_mode = mode;
352            }
353            "io_uring" => {
354                let enabled = parse_bool_param(&value).ok_or_else(|| {
355                    PgError::Connection(format!("Invalid io_uring value: {}", value))
356                })?;
357                config.io_uring = enabled;
358            }
359            "sslrootcert" => {
360                let ca_pem = std::fs::read(&value).map_err(|e| {
361                    PgError::Connection(format!("Failed to read sslrootcert '{}': {}", value, e))
362                })?;
363                config.tls_ca_cert_pem = Some(ca_pem);
364            }
365            "sslcert" => sslcert = Some(value.clone()),
366            "sslkey" => sslkey = Some(value.clone()),
367            "channel_binding" => {
368                let mode = ScramChannelBindingMode::parse(&value).ok_or_else(|| {
369                    PgError::Connection(format!("Invalid channel_binding value: {}", value))
370                })?;
371                config.auth_settings.channel_binding = mode;
372            }
373            "auth_scram" => {
374                let enabled = parse_bool_param(&value).ok_or_else(|| {
375                    PgError::Connection(format!("Invalid auth_scram value: {}", value))
376                })?;
377                config.auth_settings.allow_scram_sha_256 = enabled;
378            }
379            "auth_md5" => {
380                let enabled = parse_bool_param(&value).ok_or_else(|| {
381                    PgError::Connection(format!("Invalid auth_md5 value: {}", value))
382                })?;
383                config.auth_settings.allow_md5_password = enabled;
384            }
385            "auth_cleartext" => {
386                let enabled = parse_bool_param(&value).ok_or_else(|| {
387                    PgError::Connection(format!("Invalid auth_cleartext value: {}", value))
388                })?;
389                config.auth_settings.allow_cleartext_password = enabled;
390            }
391            "auth_kerberos" => {
392                let enabled = parse_bool_param(&value).ok_or_else(|| {
393                    PgError::Connection(format!("Invalid auth_kerberos value: {}", value))
394                })?;
395                config.auth_settings.allow_kerberos_v5 = enabled;
396            }
397            "auth_gssapi" => {
398                let enabled = parse_bool_param(&value).ok_or_else(|| {
399                    PgError::Connection(format!("Invalid auth_gssapi value: {}", value))
400                })?;
401                config.auth_settings.allow_gssapi = enabled;
402            }
403            "auth_sspi" => {
404                let enabled = parse_bool_param(&value).ok_or_else(|| {
405                    PgError::Connection(format!("Invalid auth_sspi value: {}", value))
406                })?;
407                config.auth_settings.allow_sspi = enabled;
408            }
409            "auth_mode" => {
410                if value.eq_ignore_ascii_case("scram_only") {
411                    config.auth_settings = AuthSettings::scram_only();
412                } else if value.eq_ignore_ascii_case("gssapi_only") {
413                    config.auth_settings = AuthSettings::gssapi_only();
414                } else if value.eq_ignore_ascii_case("compat")
415                    || value.eq_ignore_ascii_case("default")
416                {
417                    config.auth_settings = AuthSettings::default();
418                } else {
419                    return Err(PgError::Connection(format!(
420                        "Invalid auth_mode value: {}",
421                        value
422                    )));
423                }
424            }
425            "gss_provider" => gss_provider = Some(value.clone()),
426            "gss_service" => {
427                if value.is_empty() {
428                    return Err(PgError::Connection(
429                        "gss_service must not be empty".to_string(),
430                    ));
431                }
432                gss_service = value.clone();
433            }
434            // libpq alias for kerberos service principal name component.
435            "krbsrvname" => {
436                if value.is_empty() {
437                    return Err(PgError::Connection(
438                        "gss_service must not be empty".to_string(),
439                    ));
440                }
441                gss_service = value.clone();
442            }
443            "gss_target" => {
444                if value.is_empty() {
445                    return Err(PgError::Connection(
446                        "gss_target must not be empty".to_string(),
447                    ));
448                }
449                gss_target = Some(value.clone());
450            }
451            // libpq alias for GSS target hostname override.
452            "gsshostname" => {
453                if value.is_empty() {
454                    return Err(PgError::Connection(
455                        "gss_target must not be empty".to_string(),
456                    ));
457                }
458                gss_target = Some(value.clone());
459            }
460            // libpq compatibility knob; accepted values are validated but
461            // provider selection remains controlled by qail `gss_provider`.
462            "gsslib" => match value.trim().to_ascii_lowercase().as_str() {
463                "gssapi" | "sspi" => {}
464                _ => {
465                    return Err(PgError::Connection(format!(
466                        "Invalid gsslib value: {} (expected gssapi or sspi)",
467                        value
468                    )));
469                }
470            },
471            "gss_connect_retries" => {
472                let retries = value.parse::<usize>().map_err(|_| {
473                    PgError::Connection(format!("Invalid gss_connect_retries value: {}", value))
474                })?;
475                if retries > 20 {
476                    return Err(PgError::Connection(
477                        "gss_connect_retries must be <= 20".to_string(),
478                    ));
479                }
480                config.gss_connect_retries = retries;
481            }
482            "gss_retry_base_ms" => {
483                let delay_ms = value.parse::<u64>().map_err(|_| {
484                    PgError::Connection(format!("Invalid gss_retry_base_ms value: {}", value))
485                })?;
486                if delay_ms == 0 {
487                    return Err(PgError::Connection(
488                        "gss_retry_base_ms must be greater than 0".to_string(),
489                    ));
490                }
491                config.gss_retry_base_delay = Duration::from_millis(delay_ms);
492            }
493            "gss_circuit_threshold" => {
494                let threshold = value.parse::<usize>().map_err(|_| {
495                    PgError::Connection(format!("Invalid gss_circuit_threshold value: {}", value))
496                })?;
497                if threshold > 100 {
498                    return Err(PgError::Connection(
499                        "gss_circuit_threshold must be <= 100".to_string(),
500                    ));
501                }
502                config.gss_circuit_breaker_threshold = threshold;
503            }
504            "gss_circuit_window_ms" => {
505                let window_ms = value.parse::<u64>().map_err(|_| {
506                    PgError::Connection(format!("Invalid gss_circuit_window_ms value: {}", value))
507                })?;
508                if window_ms == 0 {
509                    return Err(PgError::Connection(
510                        "gss_circuit_window_ms must be greater than 0".to_string(),
511                    ));
512                }
513                config.gss_circuit_breaker_window = Duration::from_millis(window_ms);
514            }
515            "gss_circuit_cooldown_ms" => {
516                let cooldown_ms = value.parse::<u64>().map_err(|_| {
517                    PgError::Connection(format!("Invalid gss_circuit_cooldown_ms value: {}", value))
518                })?;
519                if cooldown_ms == 0 {
520                    return Err(PgError::Connection(
521                        "gss_circuit_cooldown_ms must be greater than 0".to_string(),
522                    ));
523                }
524                config.gss_circuit_breaker_cooldown = Duration::from_millis(cooldown_ms);
525            }
526            _ => {}
527        }
528    }
529
530    match (sslcert.as_deref(), sslkey.as_deref()) {
531        (Some(cert_path), Some(key_path)) => {
532            let mtls = TlsConfig {
533                client_cert_pem: std::fs::read(cert_path).map_err(|e| {
534                    PgError::Connection(format!("Failed to read sslcert '{}': {}", cert_path, e))
535                })?,
536                client_key_pem: std::fs::read(key_path).map_err(|e| {
537                    PgError::Connection(format!("Failed to read sslkey '{}': {}", key_path, e))
538                })?,
539                ca_cert_pem: config.tls_ca_cert_pem.clone(),
540            };
541            config.mtls = Some(mtls);
542            config.tls_mode = TlsMode::Require;
543        }
544        (Some(_), None) | (None, Some(_)) => {
545            return Err(PgError::Connection(
546                "Both sslcert and sslkey must be provided together".to_string(),
547            ));
548        }
549        (None, None) => {}
550    }
551
552    if let Some(provider) = gss_provider {
553        if provider.eq_ignore_ascii_case("linux_krb5") || provider.eq_ignore_ascii_case("builtin") {
554            #[cfg(all(feature = "enterprise-gssapi", target_os = "linux"))]
555            {
556                let provider = crate::driver::gss::linux_krb5_token_provider(
557                    crate::driver::gss::LinuxKrb5ProviderConfig {
558                        host: host.to_string(),
559                        service: gss_service,
560                        target_name: gss_target,
561                    },
562                )
563                .map_err(PgError::Auth)?;
564                config.gss_token_provider_ex = Some(provider);
565            }
566            #[cfg(not(all(feature = "enterprise-gssapi", target_os = "linux")))]
567            {
568                let _ = gss_service;
569                let _ = gss_target;
570                return Err(PgError::Connection(
571                    "gss_provider=linux_krb5 requires qail-pg feature enterprise-gssapi on Linux"
572                        .to_string(),
573                ));
574            }
575        } else if provider.eq_ignore_ascii_case("callback")
576            || provider.eq_ignore_ascii_case("custom")
577        {
578            // External callback wiring is handled by application code.
579        } else {
580            return Err(PgError::Connection(format!(
581                "Invalid gss_provider value: {}",
582                provider
583            )));
584        }
585    }
586
587    Ok(())
588}
589
590/// Parse a postgres URL into (host, port, user, database, password).
591pub(super) fn parse_pg_url(url: &str) -> PgResult<(String, u16, String, String, Option<String>)> {
592    let url = url.split('?').next().unwrap_or(url);
593    let url = if let Some(rest) = url.strip_prefix("postgres://") {
594        rest
595    } else if let Some(rest) = url.strip_prefix("postgresql://") {
596        rest
597    } else {
598        return Err(PgError::Connection(
599            "PostgreSQL URL must start with postgres:// or postgresql://".to_string(),
600        ));
601    };
602
603    let (credentials, host_part) = if let Some(at_pos) = url.rfind('@') {
604        let creds = &url[..at_pos];
605        let host = &url[at_pos + 1..];
606        (Some(creds), host)
607    } else {
608        (None, url)
609    };
610
611    let (host_port, database) = if host_part.contains('/') {
612        let mut parts = host_part.splitn(2, '/');
613        let host_port = parts.next().unwrap_or("localhost");
614        let database = percent_decode(parts.next().unwrap_or("postgres"))?;
615        if database.is_empty() {
616            return Err(PgError::Connection(
617                "Invalid PostgreSQL URL database: missing database name".to_string(),
618            ));
619        }
620        (host_port, database)
621    } else {
622        (host_part, "postgres".to_string())
623    };
624
625    let (host, port) = if host_port.starts_with('[') {
626        let end = host_port.find(']').ok_or_else(|| {
627            PgError::Connection("Invalid PostgreSQL URL IPv6 host: missing ']'".to_string())
628        })?;
629        let host = &host_port[..=end];
630        if host == "[]" {
631            return Err(PgError::Connection(
632                "Invalid PostgreSQL URL host: missing host".to_string(),
633            ));
634        }
635        let suffix = &host_port[end + 1..];
636        let port = if suffix.is_empty() {
637            5432u16
638        } else if let Some(port_str) = suffix.strip_prefix(':') {
639            if port_str.is_empty() {
640                return Err(PgError::Connection(
641                    "Invalid PostgreSQL URL port: missing port after ':'".to_string(),
642                ));
643            }
644            let p = port_str.parse::<u16>().map_err(|_| {
645                PgError::Connection(format!(
646                    "Invalid PostgreSQL URL port '{}': expected a number from 1 to 65535",
647                    port_str
648                ))
649            })?;
650            if p == 0 {
651                return Err(PgError::Connection(
652                    "Invalid PostgreSQL URL port '0': expected a number from 1 to 65535"
653                        .to_string(),
654                ));
655            }
656            p
657        } else {
658            return Err(PgError::Connection(
659                "Invalid PostgreSQL URL IPv6 host: unexpected characters after ']'".to_string(),
660            ));
661        };
662        (host.to_string(), port)
663    } else if host_port.contains(':') {
664        let mut parts = host_port.splitn(2, ':');
665        let h = parts.next().unwrap_or("localhost").to_string();
666        if h.is_empty() {
667            return Err(PgError::Connection(
668                "Invalid PostgreSQL URL host: missing host".to_string(),
669            ));
670        }
671        let port_str = parts.next().unwrap_or("");
672        if port_str.is_empty() {
673            return Err(PgError::Connection(
674                "Invalid PostgreSQL URL port: missing port after ':'".to_string(),
675            ));
676        }
677        let p = port_str.parse::<u16>().map_err(|_| {
678            PgError::Connection(format!(
679                "Invalid PostgreSQL URL port '{}': expected a number from 1 to 65535",
680                port_str
681            ))
682        })?;
683        if p == 0 {
684            return Err(PgError::Connection(
685                "Invalid PostgreSQL URL port '0': expected a number from 1 to 65535".to_string(),
686            ));
687        }
688        (h, p)
689    } else {
690        if host_port.is_empty() {
691            return Err(PgError::Connection(
692                "Invalid PostgreSQL URL host: missing host".to_string(),
693            ));
694        }
695        (host_port.to_string(), 5432u16)
696    };
697
698    let (user, password) = if let Some(creds) = credentials {
699        if creds.contains(':') {
700            let mut parts = creds.splitn(2, ':');
701            let u = percent_decode(parts.next().unwrap_or("postgres"))?;
702            if u.is_empty() {
703                return Err(PgError::Connection(
704                    "Invalid PostgreSQL URL user: missing user".to_string(),
705                ));
706            }
707            let p = parts.next().map(percent_decode).transpose()?;
708            (u, p)
709        } else {
710            let u = percent_decode(creds)?;
711            if u.is_empty() {
712                return Err(PgError::Connection(
713                    "Invalid PostgreSQL URL user: missing user".to_string(),
714                ));
715            }
716            (u, None)
717        }
718    } else {
719        ("postgres".to_string(), None)
720    };
721
722    Ok((host, port, user, database, password))
723}
724
725fn percent_decode(s: &str) -> PgResult<String> {
726    fn hex_value(byte: u8) -> Option<u8> {
727        match byte {
728            b'0'..=b'9' => Some(byte - b'0'),
729            b'a'..=b'f' => Some(byte - b'a' + 10),
730            b'A'..=b'F' => Some(byte - b'A' + 10),
731            _ => None,
732        }
733    }
734
735    let bytes = s.as_bytes();
736    let mut decoded = Vec::with_capacity(bytes.len());
737    let mut i = 0;
738
739    while i < bytes.len() {
740        if bytes[i] == b'%' {
741            if i + 2 >= bytes.len() {
742                return Err(PgError::Connection(
743                    "Invalid PostgreSQL URL percent-encoding: '%' must be followed by two hex digits"
744                        .to_string(),
745                ));
746            }
747            let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) else {
748                return Err(PgError::Connection(
749                    "Invalid PostgreSQL URL percent-encoding: '%' must be followed by two hex digits"
750                        .to_string(),
751                ));
752            };
753            decoded.push((hi << 4) | lo);
754            i += 3;
755        } else {
756            decoded.push(bytes[i]);
757            i += 1;
758        }
759    }
760
761    String::from_utf8(decoded).map_err(|_| {
762        PgError::Connection(
763            "Invalid PostgreSQL URL percent-encoding: decoded value is not UTF-8".to_string(),
764        )
765    })
766}
767
768pub(super) fn parse_bool_param(value: &str) -> Option<bool> {
769    match value.trim().to_ascii_lowercase().as_str() {
770        "1" | "true" | "yes" | "on" => Some(true),
771        "0" | "false" | "no" | "off" => Some(false),
772        _ => None,
773    }
774}