Skip to main content

pg_client/
url.rs

1use crate::{Config, Database, Endpoint, Host, Password, Port, SslMode, SslRootCert, User};
2use fluent_uri::pct_enc::EStr;
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
7pub enum ParseError {
8    #[error("Invalid URL: {0}")]
9    InvalidUrl(#[from] ::fluent_uri::ParseError),
10    #[error("Invalid URL scheme: expected 'postgres' or 'postgresql', got '{0}'")]
11    InvalidScheme(String),
12    #[error("Invalid URL fragment: '{0}'")]
13    InvalidFragment(String),
14    #[error("Missing host in URL")]
15    MissingHost,
16    #[error("Missing required parameter '{0}' in URL")]
17    MissingParameter(&'static str),
18    #[error("Parameter '{0}' specified in both URL and query string")]
19    ConflictingParameter(&'static str),
20    #[error("Unknown query parameter: '{0}'")]
21    InvalidQueryParameter(String),
22    #[error("Invalid query parameter encoding: {0}")]
23    InvalidQueryParameterEncoding(std::str::Utf8Error),
24    #[error("{0}")]
25    Field(#[from] FieldError),
26    #[error("Unsupported parameter for socket path connection: '{0}'")]
27    UnsupportedSocketPathParameter(&'static str),
28    #[error("Invalid port: {0}")]
29    InvalidPort(#[from] std::num::ParseIntError),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum FieldSource {
34    Authority,
35    Path,
36    QueryParam,
37}
38
39impl fmt::Display for FieldSource {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            FieldSource::Authority => f.write_str("authority"),
43            FieldSource::Path => f.write_str("path"),
44            FieldSource::QueryParam => f.write_str("query"),
45        }
46    }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Field {
51    User,
52    Password,
53    Database,
54    Host,
55    HostAddr,
56    SslMode,
57    SslRootCert,
58    ApplicationName,
59    ChannelBinding,
60}
61
62impl fmt::Display for Field {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Field::User => f.write_str("user"),
66            Field::Password => f.write_str("password"),
67            Field::Database => f.write_str("dbname"),
68            Field::Host => f.write_str("host"),
69            Field::HostAddr => f.write_str("hostaddr"),
70            Field::SslMode => f.write_str("sslmode"),
71            Field::SslRootCert => f.write_str("sslrootcert"),
72            Field::ApplicationName => f.write_str("application_name"),
73            Field::ChannelBinding => f.write_str("channel_binding"),
74        }
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum FieldErrorCause {
80    InvalidUtf8(std::str::Utf8Error),
81    InvalidIdentifier(crate::identifier::ParseError),
82    InvalidValue(String),
83}
84
85impl fmt::Display for FieldErrorCause {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            FieldErrorCause::InvalidUtf8(error) => {
89                write!(f, "invalid utf-8 encoding: {error}")
90            }
91            FieldErrorCause::InvalidIdentifier(error) => {
92                write!(f, "invalid value: {error}")
93            }
94            FieldErrorCause::InvalidValue(error) if error.is_empty() => {
95                f.write_str("invalid value")
96            }
97            FieldErrorCause::InvalidValue(error) => write!(f, "invalid value: {error}"),
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
103#[error("Invalid {field} in {origin}: {cause}")]
104pub struct FieldError {
105    pub origin: FieldSource,
106    pub field: Field,
107    pub cause: FieldErrorCause,
108}
109
110/// Parse a PostgreSQL connection URL into a Config.
111///
112/// Supports both `postgres://` and `postgresql://` schemes.
113///
114/// When the URL does not specify `sslmode`, it defaults to `verify-full`
115/// to ensure secure connections by default.
116///
117/// # URL Format
118///
119/// Network connections:
120/// ```text
121/// postgres://username:password@host:port/database?params
122/// ```
123///
124/// Socket connections (via query params when host starts with `/` or `@`):
125/// ```text
126/// postgres://?host=/path/to/socket&user=username&dbname=database
127/// ```
128///
129/// Cloud SQL connections (user/password/database in URL, socket path in query):
130/// ```text
131/// postgres://username:password@/database?host=/cloudsql/project:region:instance
132/// ```
133///
134/// # Query Parameters
135///
136/// - `sslmode`: SSL mode (allow, disable, prefer, require, verify-ca, verify-full)
137/// - `sslrootcert`: Path to SSL root certificate or "system"
138/// - `application_name`: Application name
139/// - `hostaddr`: IP address for the host
140/// - `channel_binding`: Channel binding (disable, prefer, require)
141/// - `host`: Socket path (when URL has no host component)
142/// - `user`: User (when URL has no username component)
143/// - `dbname`: Database name (when URL has no path component)
144/// - `password`: Password (when URL has no password component)
145///
146/// # Errors
147///
148/// Returns an error if the same parameter is specified both in the URL
149/// components and as a query parameter (e.g., password in both places).
150///
151/// # Example
152///
153/// ```
154/// use pg_client::{Config, SslMode};
155///
156/// let config = pg_client::url::parse(
157///     "postgres://user@localhost:5432/mydb",
158/// ).unwrap();
159///
160/// assert_eq!(config.user.as_str(), "user");
161/// assert_eq!(config.database.as_str(), "mydb");
162/// assert_eq!(config.ssl_mode, SslMode::VerifyFull);
163/// ```
164pub fn parse(url: &str) -> Result<Config, ParseError> {
165    let uri = ::fluent_uri::Uri::parse(url)?;
166
167    // Validate scheme
168    let scheme = uri.scheme().as_str();
169    if scheme != "postgres" && scheme != "postgresql" {
170        return Err(ParseError::InvalidScheme(scheme.to_string()));
171    }
172
173    if let Some(fragment) = uri.fragment() {
174        return Err(ParseError::InvalidFragment(fragment.as_str().to_string()));
175    }
176
177    // Parse query string into decoded key-value map
178    let query_map = parse_query(uri.query())?;
179    let mut query_params = QueryParams::new(&query_map);
180
181    // Extract userinfo (user and password from URL authority)
182    let authority = uri.authority();
183    let (url_user, url_password) = extract_userinfo(authority.as_ref())?;
184
185    // Extract database from URL path
186    let url_database = decode_path_database(uri.path())?;
187
188    // Resolve endpoint from authority host or query host parameter
189    let query_host = query_params.take("host");
190
191    let endpoint = match authority.as_ref() {
192        Some(authority) if !authority.host().is_empty() => {
193            if query_host.is_some() {
194                return Err(ParseError::ConflictingParameter("host"));
195            }
196
197            let host = match authority.host_parsed() {
198                fluent_uri::component::Host::RegName(name) => {
199                    let decoded = decode_to_string(name).map_err(|error| FieldError {
200                        origin: FieldSource::Authority,
201                        field: Field::Host,
202                        cause: FieldErrorCause::InvalidUtf8(error),
203                    })?;
204                    decoded.parse::<Host>().map_err(|error: &str| FieldError {
205                        origin: FieldSource::Authority,
206                        field: Field::Host,
207                        cause: FieldErrorCause::InvalidValue(error.to_string()),
208                    })?
209                }
210                fluent_uri::component::Host::Ipv4(addr) => Host::IpAddr(addr.into()),
211                fluent_uri::component::Host::Ipv6(addr) => Host::IpAddr(addr.into()),
212                _ => {
213                    let host = authority.host();
214                    let message = if host.starts_with("[v") || host.starts_with("[V") {
215                        "unsupported host type: ipvfuture"
216                    } else {
217                        "unsupported host type"
218                    };
219                    return Err(FieldError {
220                        origin: FieldSource::Authority,
221                        field: Field::Host,
222                        cause: FieldErrorCause::InvalidValue(message.to_string()),
223                    }
224                    .into());
225                }
226            };
227
228            let host_addr = match query_params.take("hostaddr") {
229                Some(addr_str) => Some(addr_str.parse().map_err(|error: &str| FieldError {
230                    origin: FieldSource::QueryParam,
231                    field: Field::HostAddr,
232                    cause: FieldErrorCause::InvalidValue(error.to_string()),
233                })?),
234                None => None,
235            };
236
237            let channel_binding = match query_params.take("channel_binding") {
238                Some(binding_str) => Some(binding_str.parse().map_err(|_| FieldError {
239                    origin: FieldSource::QueryParam,
240                    field: Field::ChannelBinding,
241                    cause: FieldErrorCause::InvalidValue(binding_str.to_string()),
242                })?),
243                None => None,
244            };
245
246            let port = authority.port_to_u16()?.map(Port::new);
247
248            Endpoint::Network {
249                host,
250                channel_binding,
251                host_addr,
252                port,
253            }
254        }
255        _ => {
256            let host = query_host.ok_or(ParseError::MissingHost)?;
257
258            if !host.starts_with('/') && !host.starts_with('@') {
259                return Err(FieldError {
260                    origin: FieldSource::QueryParam,
261                    field: Field::Host,
262                    cause: FieldErrorCause::InvalidValue(
263                        "query host must be a socket path (start with / or @)".to_string(),
264                    ),
265                }
266                .into());
267            }
268
269            for name in ["channel_binding", "hostaddr"] {
270                if query_params.take(name).is_some() {
271                    return Err(ParseError::UnsupportedSocketPathParameter(name));
272                }
273            }
274
275            Endpoint::SocketPath(host.into())
276        }
277    };
278
279    let user_value = access_field(
280        "user",
281        url_user.map(|value| FieldValue::new(FieldSource::Authority, value)),
282        &mut query_params,
283    )?
284    .ok_or(ParseError::MissingParameter("user"))?;
285    if user_value.value.is_empty() {
286        return Err(ParseError::MissingParameter("user"));
287    }
288    let user: User = user_value.value.parse().map_err(|error| FieldError {
289        origin: user_value.origin,
290        field: Field::User,
291        cause: FieldErrorCause::InvalidIdentifier(error),
292    })?;
293
294    let password: Option<Password> = match access_field(
295        "password",
296        url_password.map(|value| FieldValue::new(FieldSource::Authority, value)),
297        &mut query_params,
298    )? {
299        Some(value) => Some(value.value.parse().map_err(|error: String| FieldError {
300            origin: value.origin,
301            field: Field::Password,
302            cause: FieldErrorCause::InvalidValue(error.to_string()),
303        })?),
304        None => None,
305    };
306
307    let database_value = access_field(
308        "dbname",
309        url_database.map(|value| FieldValue::new(FieldSource::Path, value)),
310        &mut query_params,
311    )?
312    .ok_or(ParseError::MissingParameter("dbname"))?;
313    let database: Database = database_value.value.parse().map_err(|error| FieldError {
314        origin: database_value.origin,
315        field: Field::Database,
316        cause: FieldErrorCause::InvalidIdentifier(error),
317    })?;
318
319    // Parse sslmode, defaulting to verify-full for secure connections
320    let ssl_mode = match query_params.take("sslmode") {
321        Some(mode_str) => mode_str.parse().map_err(|_| FieldError {
322            origin: FieldSource::QueryParam,
323            field: Field::SslMode,
324            cause: FieldErrorCause::InvalidValue(mode_str.to_string()),
325        })?,
326        None => SslMode::VerifyFull,
327    };
328
329    // Parse sslrootcert
330    let ssl_root_cert = query_params.take("sslrootcert").map(|cert_str| {
331        if cert_str == "system" {
332            SslRootCert::System
333        } else {
334            SslRootCert::File(cert_str.to_string().into())
335        }
336    });
337
338    // Parse application_name
339    let application_name = match query_params.take("application_name") {
340        Some(name_str) => Some(name_str.parse().map_err(|error: String| FieldError {
341            origin: FieldSource::QueryParam,
342            field: Field::ApplicationName,
343            cause: FieldErrorCause::InvalidValue(error),
344        })?),
345        None => None,
346    };
347
348    if let Some(unknown) = query_params.unknown_param() {
349        return Err(ParseError::InvalidQueryParameter(unknown.to_string()));
350    }
351
352    Ok(Config {
353        application_name,
354        database,
355        endpoint,
356        password,
357        ssl_mode,
358        ssl_root_cert,
359        user,
360    })
361}
362
363fn extract_userinfo(
364    authority: Option<&fluent_uri::component::Authority<'_>>,
365) -> Result<(Option<String>, Option<String>), ParseError> {
366    let userinfo = match authority.and_then(|authority| authority.userinfo()) {
367        Some(info) => info,
368        None => return Ok((None, None)),
369    };
370
371    match userinfo.split_once(':') {
372        Some((user_enc, pass_enc)) => {
373            let user = decode_to_string(user_enc).map_err(|error| FieldError {
374                origin: FieldSource::Authority,
375                field: Field::User,
376                cause: FieldErrorCause::InvalidUtf8(error),
377            })?;
378            let password = decode_to_string(pass_enc).map_err(|error| FieldError {
379                origin: FieldSource::Authority,
380                field: Field::Password,
381                cause: FieldErrorCause::InvalidUtf8(error),
382            })?;
383            let user = non_empty(user);
384            let password = non_empty(password);
385            Ok((user, password))
386        }
387        None => {
388            let user = decode_to_string(userinfo).map_err(|error| FieldError {
389                origin: FieldSource::Authority,
390                field: Field::User,
391                cause: FieldErrorCause::InvalidUtf8(error),
392            })?;
393            Ok((non_empty(user), None))
394        }
395    }
396}
397
398fn decode_to_string<E: fluent_uri::pct_enc::Encoder>(
399    estr: &EStr<E>,
400) -> Result<String, std::str::Utf8Error> {
401    let bytes = estr.decode().to_bytes();
402    String::from_utf8(bytes.into_owned()).map_err(|error| error.utf8_error())
403}
404
405fn non_empty(value: String) -> Option<String> {
406    if value.is_empty() { None } else { Some(value) }
407}
408
409fn decode_path_database(
410    path: &EStr<fluent_uri::pct_enc::encoder::Path>,
411) -> Result<Option<String>, ParseError> {
412    let decoded = decode_to_string(path).map_err(|error| FieldError {
413        origin: FieldSource::Path,
414        field: Field::Database,
415        cause: FieldErrorCause::InvalidUtf8(error),
416    })?;
417
418    let stripped = decoded.strip_prefix('/').unwrap_or(&decoded);
419
420    Ok(non_empty(stripped.to_string()))
421}
422
423fn parse_query(
424    query: Option<&EStr<fluent_uri::pct_enc::encoder::Query>>,
425) -> Result<BTreeMap<String, String>, ParseError> {
426    let query = match query {
427        Some(query) => query,
428        None => return Ok(BTreeMap::new()),
429    };
430
431    query
432        .split('&')
433        .map(|pair| {
434            let (key, value) = pair.split_once('=').unwrap_or((pair, EStr::EMPTY));
435            let key = decode_to_string(key).map_err(ParseError::InvalidQueryParameterEncoding)?;
436            let field = query_field(&key);
437            let value = decode_to_string(value).map_err(|error| match field {
438                Some(field) => FieldError {
439                    origin: FieldSource::QueryParam,
440                    field,
441                    cause: FieldErrorCause::InvalidUtf8(error),
442                }
443                .into(),
444                None => ParseError::InvalidQueryParameterEncoding(error),
445            })?;
446            Ok((key, value))
447        })
448        .collect()
449}
450
451fn access_field(
452    name: &'static str,
453    url_value: Option<FieldValue>,
454    query_params: &mut QueryParams<'_>,
455) -> Result<Option<FieldValue>, ParseError> {
456    let query_value = query_params
457        .take(name)
458        .map(|value| FieldValue::new(FieldSource::QueryParam, value.to_string()));
459    match (url_value, query_value) {
460        (Some(_), Some(_)) => Err(ParseError::ConflictingParameter(name)),
461        (Some(value), None) => Ok(Some(value)),
462        (None, Some(value)) => Ok(Some(value)),
463        (None, None) => Ok(None),
464    }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
468struct FieldValue {
469    origin: FieldSource,
470    value: String,
471}
472
473impl FieldValue {
474    fn new(origin: FieldSource, value: String) -> Self {
475        Self { origin, value }
476    }
477}
478
479fn query_field(name: &str) -> Option<Field> {
480    match name {
481        "user" => Some(Field::User),
482        "password" => Some(Field::Password),
483        "dbname" => Some(Field::Database),
484        "host" => Some(Field::Host),
485        "hostaddr" => Some(Field::HostAddr),
486        "sslmode" => Some(Field::SslMode),
487        "sslrootcert" => Some(Field::SslRootCert),
488        "application_name" => Some(Field::ApplicationName),
489        "channel_binding" => Some(Field::ChannelBinding),
490        _ => None,
491    }
492}
493
494struct QueryParams<'a> {
495    params: &'a BTreeMap<String, String>,
496    remaining: BTreeSet<&'a str>,
497}
498
499impl<'a> QueryParams<'a> {
500    fn new(params: &'a BTreeMap<String, String>) -> Self {
501        let remaining = params.keys().map(|key| key.as_str()).collect();
502        Self { params, remaining }
503    }
504
505    fn take(&mut self, name: &str) -> Option<&'a str> {
506        let value = self.params.get(name).map(|value| value.as_str());
507        if value.is_some() {
508            self.remaining.remove(name);
509        }
510        value
511    }
512
513    fn unknown_param(&self) -> Option<&&'a str> {
514        self.remaining.iter().next()
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::ChannelBinding;
522    use crate::SslMode;
523
524    fn network(host: &str, port: Option<u16>, host_addr: Option<&str>) -> Endpoint {
525        Endpoint::Network {
526            host: host.parse().unwrap(),
527            channel_binding: None,
528            port: port.map(Port::new),
529            host_addr: host_addr.map(|address| address.parse().unwrap()),
530        }
531    }
532
533    fn success(
534        user: &str,
535        password: Option<&str>,
536        database: &str,
537        endpoint: Endpoint,
538        ssl_mode: SslMode,
539        ssl_root_cert: Option<SslRootCert>,
540        application_name: Option<&str>,
541    ) -> Config {
542        Config {
543            user: user.parse().unwrap(),
544            password: password.map(|value| value.parse().unwrap()),
545            database: database.parse().unwrap(),
546            endpoint,
547            ssl_mode,
548            ssl_root_cert,
549            application_name: application_name.map(|value| value.parse().unwrap()),
550        }
551    }
552
553    fn field_error(origin: FieldSource, field: Field, cause: FieldErrorCause) -> ParseError {
554        ParseError::Field(FieldError {
555            origin,
556            field,
557            cause,
558        })
559    }
560
561    #[test]
562    fn test_parse() {
563        type Expected = Result<Config, ParseError>;
564
565        let cases: Vec<(&str, &str, Expected)> = vec![
566            // Success cases
567            (
568                "basic_network",
569                "postgres://user@localhost:5432/mydb",
570                Ok(success(
571                    "user",
572                    None,
573                    "mydb",
574                    network("localhost", Some(5432), None),
575                    SslMode::VerifyFull,
576                    None,
577                    None,
578                )),
579            ),
580            (
581                "with_password",
582                "postgres://user:secret@localhost/mydb",
583                Ok(success(
584                    "user",
585                    Some("secret"),
586                    "mydb",
587                    network("localhost", None, None),
588                    SslMode::VerifyFull,
589                    None,
590                    None,
591                )),
592            ),
593            (
594                "percent_encoded_password",
595                "postgres://user:p%40ss%2Fword@localhost/mydb",
596                Ok(success(
597                    "user",
598                    Some("p@ss/word"),
599                    "mydb",
600                    network("localhost", None, None),
601                    SslMode::VerifyFull,
602                    None,
603                    None,
604                )),
605            ),
606            (
607                "with_sslmode_disable",
608                "postgres://user@localhost/mydb?sslmode=disable",
609                Ok(success(
610                    "user",
611                    None,
612                    "mydb",
613                    network("localhost", None, None),
614                    SslMode::Disable,
615                    None,
616                    None,
617                )),
618            ),
619            (
620                "with_sslmode_require",
621                "postgres://user@localhost/mydb?sslmode=require",
622                Ok(success(
623                    "user",
624                    None,
625                    "mydb",
626                    network("localhost", None, None),
627                    SslMode::Require,
628                    None,
629                    None,
630                )),
631            ),
632            (
633                "with_channel_binding",
634                "postgres://user@localhost/mydb?channel_binding=require",
635                Ok(success(
636                    "user",
637                    None,
638                    "mydb",
639                    Endpoint::Network {
640                        host: "localhost".parse().unwrap(),
641                        channel_binding: Some(ChannelBinding::Require),
642                        port: None,
643                        host_addr: None,
644                    },
645                    SslMode::VerifyFull,
646                    None,
647                    None,
648                )),
649            ),
650            (
651                "with_application_name",
652                "postgres://user@localhost/mydb?application_name=myapp",
653                Ok(success(
654                    "user",
655                    None,
656                    "mydb",
657                    network("localhost", None, None),
658                    SslMode::VerifyFull,
659                    None,
660                    Some("myapp"),
661                )),
662            ),
663            (
664                "with_hostaddr",
665                "postgres://user@example.com/mydb?hostaddr=192.168.1.1",
666                Ok(success(
667                    "user",
668                    None,
669                    "mydb",
670                    network("example.com", None, Some("192.168.1.1")),
671                    SslMode::VerifyFull,
672                    None,
673                    None,
674                )),
675            ),
676            (
677                "with_sslrootcert_file",
678                "postgres://user@localhost/mydb?sslrootcert=/path/to/cert.pem",
679                Ok(success(
680                    "user",
681                    None,
682                    "mydb",
683                    network("localhost", None, None),
684                    SslMode::VerifyFull,
685                    Some(SslRootCert::File("/path/to/cert.pem".into())),
686                    None,
687                )),
688            ),
689            (
690                "with_sslrootcert_system",
691                "postgres://user@localhost/mydb?sslrootcert=system",
692                Ok(success(
693                    "user",
694                    None,
695                    "mydb",
696                    network("localhost", None, None),
697                    SslMode::VerifyFull,
698                    Some(SslRootCert::System),
699                    None,
700                )),
701            ),
702            (
703                "socket_path",
704                "postgres://?host=/var/run/postgresql&user=postgres&dbname=mydb",
705                Ok(success(
706                    "postgres",
707                    None,
708                    "mydb",
709                    Endpoint::SocketPath("/var/run/postgresql".into()),
710                    SslMode::VerifyFull,
711                    None,
712                    None,
713                )),
714            ),
715            (
716                "socket_with_password",
717                "postgres://?host=/socket&user=user&password=pass&dbname=mydb",
718                Ok(success(
719                    "user",
720                    Some("pass"),
721                    "mydb",
722                    Endpoint::SocketPath("/socket".into()),
723                    SslMode::VerifyFull,
724                    None,
725                    None,
726                )),
727            ),
728            (
729                "abstract_socket",
730                "postgres://?host=@abstract&user=postgres&dbname=mydb",
731                Ok(success(
732                    "postgres",
733                    None,
734                    "mydb",
735                    Endpoint::SocketPath("@abstract".into()),
736                    SslMode::VerifyFull,
737                    None,
738                    None,
739                )),
740            ),
741            (
742                "postgresql_scheme",
743                "postgresql://user@localhost/mydb",
744                Ok(success(
745                    "user",
746                    None,
747                    "mydb",
748                    network("localhost", None, None),
749                    SslMode::VerifyFull,
750                    None,
751                    None,
752                )),
753            ),
754            (
755                "ipv6_host",
756                "postgres://user@[::1]:5432/mydb",
757                Ok(success(
758                    "user",
759                    None,
760                    "mydb",
761                    network("::1", Some(5432), None),
762                    SslMode::VerifyFull,
763                    None,
764                    None,
765                )),
766            ),
767            (
768                "ipv4_host",
769                "postgres://user@192.168.1.1:5432/mydb",
770                Ok(success(
771                    "user",
772                    None,
773                    "mydb",
774                    network("192.168.1.1", Some(5432), None),
775                    SslMode::VerifyFull,
776                    None,
777                    None,
778                )),
779            ),
780            (
781                "no_port",
782                "postgres://user@localhost/mydb",
783                Ok(success(
784                    "user",
785                    None,
786                    "mydb",
787                    network("localhost", None, None),
788                    SslMode::VerifyFull,
789                    None,
790                    None,
791                )),
792            ),
793            // Cloud SQL success cases
794            (
795                "cloud_sql_socket",
796                "postgres://user:secret@/main?host=/cloudsql/project:region:instance",
797                Ok(success(
798                    "user",
799                    Some("secret"),
800                    "main",
801                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
802                    SslMode::VerifyFull,
803                    None,
804                    None,
805                )),
806            ),
807            (
808                "cloud_sql_socket_no_password",
809                "postgres://user@/main?host=/cloudsql/project:region:instance",
810                Ok(success(
811                    "user",
812                    None,
813                    "main",
814                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
815                    SslMode::VerifyFull,
816                    None,
817                    None,
818                )),
819            ),
820            (
821                "cloud_sql_socket_sslmode_disable",
822                "postgres://user:secret@/main?host=/cloudsql/project:region:instance&sslmode=disable",
823                Ok(success(
824                    "user",
825                    Some("secret"),
826                    "main",
827                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
828                    SslMode::Disable,
829                    None,
830                    None,
831                )),
832            ),
833            (
834                "cloud_sql_socket_query_params",
835                "postgres://?host=/cloudsql/project:region:instance&user=user&password=secret&dbname=main",
836                Ok(success(
837                    "user",
838                    Some("secret"),
839                    "main",
840                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
841                    SslMode::VerifyFull,
842                    None,
843                    None,
844                )),
845            ),
846            // Error cases
847            (
848                "invalid_scheme",
849                "mysql://user@localhost/mydb",
850                Err(ParseError::InvalidScheme("mysql".to_string())),
851            ),
852            (
853                "missing_username",
854                "postgres://localhost/mydb",
855                Err(ParseError::MissingParameter("user")),
856            ),
857            (
858                "missing_database",
859                "postgres://user@localhost",
860                Err(ParseError::MissingParameter("dbname")),
861            ),
862            (
863                "missing_host",
864                "postgres://?user=user&dbname=mydb",
865                Err(ParseError::MissingHost),
866            ),
867            (
868                "conflicting_host",
869                "postgres://user@localhost/mydb?host=/socket",
870                Err(ParseError::ConflictingParameter("host")),
871            ),
872            (
873                "conflicting_user",
874                "postgres://user@localhost/mydb?user=other",
875                Err(ParseError::ConflictingParameter("user")),
876            ),
877            (
878                "conflicting_password",
879                "postgres://user:secret@localhost/mydb?password=other",
880                Err(ParseError::ConflictingParameter("password")),
881            ),
882            (
883                "conflicting_dbname",
884                "postgres://user@localhost/mydb?dbname=other",
885                Err(ParseError::ConflictingParameter("dbname")),
886            ),
887            (
888                "invalid_sslmode",
889                "postgres://user@localhost/mydb?sslmode=invalid",
890                Err(field_error(
891                    FieldSource::QueryParam,
892                    Field::SslMode,
893                    FieldErrorCause::InvalidValue("invalid".to_string()),
894                )),
895            ),
896            (
897                "invalid_channel_binding",
898                "postgres://user@localhost/mydb?channel_binding=invalid",
899                Err(field_error(
900                    FieldSource::QueryParam,
901                    Field::ChannelBinding,
902                    FieldErrorCause::InvalidValue("invalid".to_string()),
903                )),
904            ),
905            (
906                "invalid_hostaddr",
907                "postgres://user@localhost/mydb?hostaddr=not-an-ip",
908                Err(field_error(
909                    FieldSource::QueryParam,
910                    Field::HostAddr,
911                    FieldErrorCause::InvalidValue("invalid IP address".to_string()),
912                )),
913            ),
914            (
915                "unsupported_ipvfuture_host",
916                "postgres://user@[v1.fe80]/mydb",
917                Err(field_error(
918                    FieldSource::Authority,
919                    Field::Host,
920                    FieldErrorCause::InvalidValue("unsupported host type: ipvfuture".to_string()),
921                )),
922            ),
923            (
924                "unknown_parameter",
925                "postgres://user@localhost/mydb?unknown_parameter=1",
926                Err(ParseError::InvalidQueryParameter(
927                    "unknown_parameter".to_string(),
928                )),
929            ),
930            (
931                "fragment",
932                "postgres://user@localhost/mydb#section",
933                Err(ParseError::InvalidFragment("section".to_string())),
934            ),
935            (
936                "socket_missing_user",
937                "postgres://?host=/socket&dbname=mydb",
938                Err(ParseError::MissingParameter("user")),
939            ),
940            (
941                "socket_missing_dbname",
942                "postgres://?host=/socket&user=user",
943                Err(ParseError::MissingParameter("dbname")),
944            ),
945            (
946                "socket_with_channel_binding",
947                "postgres://?host=/socket&user=user&dbname=mydb&channel_binding=require",
948                Err(ParseError::UnsupportedSocketPathParameter(
949                    "channel_binding",
950                )),
951            ),
952            (
953                "socket_with_hostaddr",
954                "postgres://?host=/socket&user=user&dbname=mydb&hostaddr=127.0.0.1",
955                Err(ParseError::UnsupportedSocketPathParameter("hostaddr")),
956            ),
957            // Cloud SQL error cases
958            (
959                "cloud_sql_conflicting_user",
960                "postgres://user@/main?host=/cloudsql/project:region:instance&user=other",
961                Err(ParseError::ConflictingParameter("user")),
962            ),
963            (
964                "cloud_sql_conflicting_password",
965                "postgres://user:secret@/main?host=/cloudsql/project:region:instance&password=other",
966                Err(ParseError::ConflictingParameter("password")),
967            ),
968            (
969                "cloud_sql_conflicting_dbname",
970                "postgres://user@/main?host=/cloudsql/project:region:instance&dbname=other",
971                Err(ParseError::ConflictingParameter("dbname")),
972            ),
973        ];
974
975        for (name, url_str, expected) in cases {
976            let actual = parse(url_str);
977
978            assert_eq!(actual, expected, "{name}: {url_str}");
979
980            if let Ok(config) = actual {
981                let roundtrip_url = config.to_url_string();
982                let roundtrip_config = parse(&roundtrip_url).unwrap_or_else(|error| {
983                    panic!("{name}: roundtrip parse failed: {error}, url: {roundtrip_url}")
984                });
985                assert_eq!(roundtrip_config, config, "{name}: roundtrip");
986            }
987        }
988    }
989}