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