Skip to content

Commit 1e1c962

Browse files
fix: encode credentials in MultiHostUrl builder (#1829)
Co-authored-by: David Hewitt <mail@davidhewitt.dev>
1 parent 534b6c2 commit 1e1c962

File tree

6 files changed

+49
-8
lines changed

6 files changed

+49
-8
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ num-traits = "0.2.19"
4646
uuid = "1.18.1"
4747
jiter = { version = "0.11.0", features = ["python"] }
4848
hex = "0.4.3"
49+
percent-encoding = "2.3.1"
4950

5051
[lib]
5152
name = "_pydantic_core"

src/errors/validation_exception.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,7 @@ impl ValidationError {
176176
use pyo3::exceptions::PyImportError;
177177
match py.import("exceptiongroup") {
178178
Ok(py_mod) => match py_mod.getattr("ExceptionGroup") {
179-
Ok(group_cls) => match group_cls.call1((title, user_py_errs)) {
180-
Ok(group_instance) => Some(group_instance),
181-
Err(_) => None,
182-
},
179+
Ok(group_cls) => group_cls.call1((title, user_py_errs)).ok(),
183180
Err(_) => None,
184181
},
185182
Err(_) => return Some(PyImportError::new_err("validation_error_cause flag requires the exceptiongroup module backport to be installed when used on Python <3.11.")),

src/input/shared.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ pub fn fraction_as_int<'py>(input: &Bound<'py, PyAny>) -> ValResult<EitherInt<'p
232232
#[cfg(Py_3_12)]
233233
let is_integer = input.call_method0("is_integer")?.extract::<bool>()?;
234234
#[cfg(not(Py_3_12))]
235-
let is_integer = input.getattr("denominator")?.extract::<i64>().map_or(false, |d| d == 1);
235+
let is_integer = input.getattr("denominator")?.extract::<i64>().is_ok_and(|d| d == 1);
236236

237237
if is_integer {
238238
#[cfg(Py_3_11)]

src/url.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::sync::OnceLock;
77

88
use idna::punycode::decode_to_string;
99
use jiter::{PartialMode, StringCacheMode};
10+
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
1011
use pyo3::exceptions::PyValueError;
1112
use pyo3::pyclass::CompareOp;
1213
use pyo3::sync::OnceLockExt;
@@ -536,9 +537,14 @@ impl FromPyObject<'_> for UrlHostParts {
536537
impl fmt::Display for UrlHostParts {
537538
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
538539
match (&self.username, &self.password) {
539-
(Some(username), None) => write!(f, "{username}@")?,
540-
(None, Some(password)) => write!(f, ":{password}@")?,
541-
(Some(username), Some(password)) => write!(f, "{username}:{password}@")?,
540+
(Some(username), None) => write!(f, "{}@", encode_userinfo_component(username))?,
541+
(None, Some(password)) => write!(f, ":{}@", encode_userinfo_component(password))?,
542+
(Some(username), Some(password)) => write!(
543+
f,
544+
"{}:{}@",
545+
encode_userinfo_component(username),
546+
encode_userinfo_component(password)
547+
)?,
542548
(None, None) => {}
543549
}
544550
if let Some(host) = &self.host {
@@ -596,6 +602,14 @@ fn is_punnycode_domain(lib_url: &Url, domain: &str) -> bool {
596602
scheme_is_special(lib_url.scheme()) && domain.split('.').any(|part| part.starts_with(PUNYCODE_PREFIX))
597603
}
598604

605+
fn encode_userinfo_component(value: &str) -> Cow<'_, str> {
606+
let encoded = percent_encode(value.as_bytes(), NON_ALPHANUMERIC).to_string();
607+
if encoded == value {
608+
Cow::Borrowed(value)
609+
} else {
610+
Cow::Owned(encoded)
611+
}
612+
}
599613
// based on https://github.com/servo/rust-url/blob/1c1e406874b3d2aa6f36c5d2f3a5c2ea74af9efb/url/src/parser.rs#L161-L167
600614
pub fn scheme_is_special(scheme: &str) -> bool {
601615
matches!(scheme, "http" | "https" | "ws" | "wss" | "ftp" | "file")

tests/validators/test_url.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,6 +1318,34 @@ def test_multi_url_build() -> None:
13181318
assert str(url) == 'postgresql://testuser:testpassword@127.0.0.1:5432/database?sslmode=require#test'
13191319

13201320

1321+
def test_multi_url_build_encodes_credentials() -> None:
1322+
url = MultiHostUrl.build(
1323+
scheme='postgresql',
1324+
username='user name',
1325+
password='p@ss/word?#',
1326+
host='example.com',
1327+
port=5432,
1328+
)
1329+
assert url == MultiHostUrl('postgresql://user%20name:p%40ss%2Fword%3F%23@example.com:5432')
1330+
assert str(url) == 'postgresql://user%20name:p%40ss%2Fword%3F%23@example.com:5432'
1331+
assert url.hosts() == [
1332+
{'username': 'user%20name', 'password': 'p%40ss%2Fword%3F%23', 'host': 'example.com', 'port': 5432}
1333+
]
1334+
1335+
1336+
def test_multi_url_build_hosts_encodes_credentials() -> None:
1337+
hosts = [
1338+
{'host': 'example.com', 'password': 'p@ss/word?#', 'username': 'user name', 'port': 5431},
1339+
{'host': 'example.org', 'password': 'pa%ss', 'username': 'other', 'port': 5432},
1340+
]
1341+
url = MultiHostUrl.build(scheme='postgresql', hosts=hosts)
1342+
assert str(url) == 'postgresql://user%20name:p%40ss%2Fword%3F%23@example.com:5431,other:pa%25ss@example.org:5432'
1343+
assert url.hosts() == [
1344+
{'username': 'user%20name', 'password': 'p%40ss%2Fword%3F%23', 'host': 'example.com', 'port': 5431},
1345+
{'username': 'other', 'password': 'pa%25ss', 'host': 'example.org', 'port': 5432},
1346+
]
1347+
1348+
13211349
@pytest.mark.parametrize('field', ['host', 'password', 'username', 'port'])
13221350
def test_multi_url_build_hosts_set_with_single_value(field) -> None:
13231351
"""Hosts can't be provided with any single url values."""

0 commit comments

Comments
 (0)