From 56173ff0073ec79ff78b3287982b548cd078f113 Mon Sep 17 00:00:00 2001 From: William Walker Date: Wed, 8 Oct 2025 17:32:59 -0400 Subject: [PATCH 1/3] fix: encode credentials in MultiHostUrl builder --- Cargo.lock | 1 + Cargo.toml | 1 + src/errors/validation_exception.rs | 5 +---- src/input/shared.rs | 2 +- src/url.rs | 21 ++++++++++++++++++--- tests/validators/test_url.py | 28 ++++++++++++++++++++++++++++ 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1d0e4429..951c0102f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,7 @@ dependencies = [ "jiter", "num-bigint", "num-traits", + "percent-encoding", "pyo3", "pyo3-build-config", "regex", diff --git a/Cargo.toml b/Cargo.toml index df9003113..15a64d9b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ num-traits = "0.2.19" uuid = "1.18.1" jiter = { version = "0.11.0", features = ["python"] } hex = "0.4.3" +percent-encoding = "2.3.1" [lib] name = "_pydantic_core" diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index 461b26b6d..2475d79dd 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -176,10 +176,7 @@ impl ValidationError { use pyo3::exceptions::PyImportError; match py.import("exceptiongroup") { Ok(py_mod) => match py_mod.getattr("ExceptionGroup") { - Ok(group_cls) => match group_cls.call1((title, user_py_errs)) { - Ok(group_instance) => Some(group_instance), - Err(_) => None, - }, + Ok(group_cls) => group_cls.call1((title, user_py_errs)).ok(), Err(_) => None, }, Err(_) => return Some(PyImportError::new_err("validation_error_cause flag requires the exceptiongroup module backport to be installed when used on Python <3.11.")), diff --git a/src/input/shared.rs b/src/input/shared.rs index f192c97ef..d623c1aeb 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -232,7 +232,7 @@ pub fn fraction_as_int<'py>(input: &Bound<'py, PyAny>) -> ValResult()?; #[cfg(not(Py_3_12))] - let is_integer = input.getattr("denominator")?.extract::().map_or(false, |d| d == 1); + let is_integer = input.getattr("denominator")?.extract::().is_ok_and(|d| d == 1); if is_integer { #[cfg(Py_3_11)] diff --git a/src/url.rs b/src/url.rs index 97df3760a..68d84520c 100644 --- a/src/url.rs +++ b/src/url.rs @@ -7,6 +7,7 @@ use std::sync::OnceLock; use idna::punycode::decode_to_string; use jiter::{PartialMode, StringCacheMode}; +use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use pyo3::exceptions::PyValueError; use pyo3::pyclass::CompareOp; use pyo3::sync::OnceLockExt; @@ -536,9 +537,14 @@ impl FromPyObject<'_> for UrlHostParts { impl fmt::Display for UrlHostParts { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match (&self.username, &self.password) { - (Some(username), None) => write!(f, "{username}@")?, - (None, Some(password)) => write!(f, ":{password}@")?, - (Some(username), Some(password)) => write!(f, "{username}:{password}@")?, + (Some(username), None) => write!(f, "{}@", encode_userinfo_component(username))?, + (None, Some(password)) => write!(f, ":{}@", encode_userinfo_component(password))?, + (Some(username), Some(password)) => write!( + f, + "{}:{}@", + encode_userinfo_component(username), + encode_userinfo_component(password) + )?, (None, None) => {} } if let Some(host) = &self.host { @@ -596,6 +602,15 @@ fn is_punnycode_domain(lib_url: &Url, domain: &str) -> bool { scheme_is_special(lib_url.scheme()) && domain.split('.').any(|part| part.starts_with(PUNYCODE_PREFIX)) } +fn encode_userinfo_component(value: &str) -> Cow<'_, str> { + let encoded = percent_encode(value.as_bytes(), NON_ALPHANUMERIC).to_string(); + if encoded == value { + Cow::Borrowed(value) + } else { + Cow::Owned(encoded) + } +} + // based on https://github.com/servo/rust-url/blob/1c1e406874b3d2aa6f36c5d2f3a5c2ea74af9efb/url/src/parser.rs#L161-L167 pub fn scheme_is_special(scheme: &str) -> bool { matches!(scheme, "http" | "https" | "ws" | "wss" | "ftp" | "file") diff --git a/tests/validators/test_url.py b/tests/validators/test_url.py index 1b1107f43..799668e27 100644 --- a/tests/validators/test_url.py +++ b/tests/validators/test_url.py @@ -1318,6 +1318,34 @@ def test_multi_url_build() -> None: assert str(url) == 'postgresql://testuser:testpassword@127.0.0.1:5432/database?sslmode=require#test' +def test_multi_url_build_encodes_credentials() -> None: + url = MultiHostUrl.build( + scheme='postgresql', + username='user name', + password='p@ss/word?#', + host='example.com', + port=5432, + ) + assert url == MultiHostUrl('postgresql://user%20name:p%40ss%2Fword%3F%23@example.com:5432') + assert str(url) == 'postgresql://user%20name:p%40ss%2Fword%3F%23@example.com:5432' + assert url.hosts() == [ + {'username': 'user%20name', 'password': 'p%40ss%2Fword%3F%23', 'host': 'example.com', 'port': 5432} + ] + + +def test_multi_url_build_hosts_encodes_credentials() -> None: + hosts = [ + {'host': 'example.com', 'password': 'p@ss/word?#', 'username': 'user name', 'port': 5431}, + {'host': 'example.org', 'password': 'pa%ss', 'username': 'other', 'port': 5432}, + ] + url = MultiHostUrl.build(scheme='postgresql', hosts=hosts) + assert str(url) == 'postgresql://user%20name:p%40ss%2Fword%3F%23@example.com:5431,other:pa%25ss@example.org:5432' + assert url.hosts() == [ + {'username': 'user%20name', 'password': 'p%40ss%2Fword%3F%23', 'host': 'example.com', 'port': 5431}, + {'username': 'other', 'password': 'pa%25ss', 'host': 'example.org', 'port': 5432}, + ] + + @pytest.mark.parametrize('field', ['host', 'password', 'username', 'port']) def test_multi_url_build_hosts_set_with_single_value(field) -> None: """Hosts can't be provided with any single url values.""" From 1e61d745f6ad3c4a03aa60a16b2fd3ff567b5892 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 10 Oct 2025 06:56:16 -0400 Subject: [PATCH 2/3] Update src/url.rs Co-authored-by: David Hewitt --- src/url.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/url.rs b/src/url.rs index 68d84520c..752986365 100644 --- a/src/url.rs +++ b/src/url.rs @@ -602,13 +602,8 @@ fn is_punnycode_domain(lib_url: &Url, domain: &str) -> bool { scheme_is_special(lib_url.scheme()) && domain.split('.').any(|part| part.starts_with(PUNYCODE_PREFIX)) } -fn encode_userinfo_component(value: &str) -> Cow<'_, str> { - let encoded = percent_encode(value.as_bytes(), NON_ALPHANUMERIC).to_string(); - if encoded == value { - Cow::Borrowed(value) - } else { - Cow::Owned(encoded) - } +fn encode_userinfo_component(value: &str) -> impl Display { + utf8_percent_encode(value, NON_ALPHANUMERIC) } // based on https://github.com/servo/rust-url/blob/1c1e406874b3d2aa6f36c5d2f3a5c2ea74af9efb/url/src/parser.rs#L161-L167 From 0e120ce89fdadb6af519c32b64833e6b0d18839d Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 10 Oct 2025 08:32:36 -0400 Subject: [PATCH 3/3] fixup: revert update url.rs --- src/url.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/url.rs b/src/url.rs index 752986365..c2d9c3579 100644 --- a/src/url.rs +++ b/src/url.rs @@ -602,10 +602,14 @@ fn is_punnycode_domain(lib_url: &Url, domain: &str) -> bool { scheme_is_special(lib_url.scheme()) && domain.split('.').any(|part| part.starts_with(PUNYCODE_PREFIX)) } -fn encode_userinfo_component(value: &str) -> impl Display { - utf8_percent_encode(value, NON_ALPHANUMERIC) +fn encode_userinfo_component(value: &str) -> Cow<'_, str> { + let encoded = percent_encode(value.as_bytes(), NON_ALPHANUMERIC).to_string(); + if encoded == value { + Cow::Borrowed(value) + } else { + Cow::Owned(encoded) + } } - // based on https://github.com/servo/rust-url/blob/1c1e406874b3d2aa6f36c5d2f3a5c2ea74af9efb/url/src/parser.rs#L161-L167 pub fn scheme_is_special(scheme: &str) -> bool { matches!(scheme, "http" | "https" | "ws" | "wss" | "ftp" | "file")