From e2b5720a8eeab6928c059678a33757bad75cc568 Mon Sep 17 00:00:00 2001 From: Louis Christopher Date: Tue, 16 Jun 2026 20:36:31 +0530 Subject: [PATCH 1/5] feat: Expose proxy and custom-CA client options --- CHANGELOG.md | 4 + obstore/python/obstore/_store/_client.pyi | 28 ++++- pyo3-object_store/src/aws/store.rs | 2 +- pyo3-object_store/src/azure/store.rs | 2 +- pyo3-object_store/src/client.rs | 46 ++++++-- pyo3-object_store/src/gcp/store.rs | 2 +- pyo3-object_store/src/http.rs | 2 +- tests/store/test_client_options.py | 129 ++++++++++++++++++++++ 8 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 tests/store/test_client_options.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 471ce80b..e13d3e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### What's Changed + +* feat: Expose `proxy_excludes`, `proxy_ca_certificate`, and `root_certificate` client options + ## [0.10.1] - 2026-06-09 ### What's Changed diff --git a/obstore/python/obstore/_store/_client.pyi b/obstore/python/obstore/_store/_client.pyi index 6f1f0593..7878b21c 100644 --- a/obstore/python/obstore/_store/_client.pyi +++ b/obstore/python/obstore/_store/_client.pyi @@ -94,6 +94,33 @@ class ClientConfig(TypedDict, total=False): pool_max_idle_per_host: str """Maximum number of idle connections per host.""" proxy_url: str + """HTTP proxy to use for requests.""" + proxy_ca_certificate: str + """PEM-formatted CA certificate for the proxy set via `proxy_url`. + + This is the certificate that signs the proxy's own TLS certificate, used to + trust an HTTPS proxy fronted by a private CA. Only consulted when `proxy_url` + is set. + """ + proxy_excludes: str + """Comma-separated list of hosts that bypass `proxy_url` (`NO_PROXY` semantics). + + Each entry may be a hostname, a domain suffix (e.g. `.internal.example.com`), + an IP address, or a CIDR block, for example + `"localhost,127.0.0.1,.svc.cluster.local"`. Only takes effect when `proxy_url` + is set. + """ + root_certificate: str | bytes + """A custom root CA certificate to trust for TLS, as PEM `str` or `bytes`. + + Use this to reach stores fronted by a private CA (e.g. a self-hosted MinIO) + without disabling verification through `allow_invalid_certificates`. The value + may concatenate several certificates; all are parsed. + + The certificate(s) are added to the trusted roots rather than replacing them, + so the system roots remain in effect and public endpoints continue to + validate alongside the custom CA. + """ randomize_addresses: bool """Randomize order addresses that the DNS resolution yields. @@ -118,7 +145,6 @@ class ClientConfig(TypedDict, total=False): Default is disabled (no read timeout). """ - """HTTP proxy to use for requests.""" timeout: str | timedelta """Set timeout for the overall request diff --git a/pyo3-object_store/src/aws/store.rs b/pyo3-object_store/src/aws/store.rs index f8041c4a..a5094e95 100644 --- a/pyo3-object_store/src/aws/store.rs +++ b/pyo3-object_store/src/aws/store.rs @@ -107,7 +107,7 @@ impl PyS3Store { let mut combined_config = combine_config_kwargs(config, kwargs)?; if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.into()) + builder = builder.with_client_options(client_options.try_into()?) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/pyo3-object_store/src/azure/store.rs b/pyo3-object_store/src/azure/store.rs index 97420416..784b52f5 100644 --- a/pyo3-object_store/src/azure/store.rs +++ b/pyo3-object_store/src/azure/store.rs @@ -113,7 +113,7 @@ impl PyAzureStore { let mut combined_config = combine_config_kwargs(Some(config), kwargs)?; if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.into()) + builder = builder.with_client_options(client_options.try_into()?) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/pyo3-object_store/src/client.rs b/pyo3-object_store/src/client.rs index 9fe159fb..952cce37 100644 --- a/pyo3-object_store/src/client.rs +++ b/pyo3-object_store/src/client.rs @@ -2,15 +2,24 @@ use std::collections::HashMap; use std::str::FromStr; use http::{HeaderMap, HeaderName, HeaderValue}; -use object_store::{ClientConfigKey, ClientOptions}; +use object_store::{Certificate, ClientConfigKey, ClientOptions}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::pybacked::{PyBackedBytes, PyBackedStr}; -use pyo3::types::{PyDict, PyString}; +use pyo3::types::{PyBytes, PyDict, PyString}; use crate::config::PyConfigValue; use crate::error::PyObjectStoreError; +fn extract_pem(value: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(bytes) = value.extract::() { + Ok(bytes.as_ref().to_vec()) + } else { + let s = value.extract::()?; + Ok(s.as_bytes().to_vec()) + } +} + /// A wrapper around `ClientConfigKey` that implements [`FromPyObject`]. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct PyClientConfigKey(ClientConfigKey); @@ -50,6 +59,9 @@ impl<'py> IntoPyObject<'py> for &PyClientConfigKey { pub struct PyClientOptions { string_options: HashMap, default_headers: Option, + // Stored as raw PEM so it round-trips through `IntoPyObject`; parsed on the + // way into `ClientOptions`. + root_certificate: Option>, } impl<'py> FromPyObject<'_, 'py> for PyClientOptions { @@ -60,16 +72,17 @@ impl<'py> FromPyObject<'_, 'py> for PyClientOptions { let dict = obj.extract::>()?; let mut string_options = HashMap::new(); let mut default_headers = None; + let mut root_certificate = None; for (key, value) in dict.iter() { if let Ok(key) = key.extract::() { string_options.insert(key, value.extract::()?); } else { let key = key.extract::()?; - if &key == "default_headers" { - default_headers = Some(value.extract::()?); - } else { - return Err(PyValueError::new_err(format!("Invalid key: {key}."))); + match &*key { + "default_headers" => default_headers = Some(value.extract::()?), + "root_certificate" => root_certificate = Some(extract_pem(&value)?), + _ => return Err(PyValueError::new_err(format!("Invalid key: {key}."))), } } } @@ -77,6 +90,7 @@ impl<'py> FromPyObject<'_, 'py> for PyClientOptions { Ok(Self { string_options, default_headers, + root_certificate, }) } } @@ -91,6 +105,9 @@ impl<'py> IntoPyObject<'py> for PyClientOptions { if let Some(headers) = self.default_headers { dict.set_item("default_headers", headers)?; } + if let Some(pem) = self.root_certificate { + dict.set_item("root_certificate", PyBytes::new(py, &pem))?; + } Ok(dict) } } @@ -105,12 +122,17 @@ impl<'py> IntoPyObject<'py> for &PyClientOptions { if let Some(headers) = &self.default_headers { dict.set_item("default_headers", headers)?; } + if let Some(pem) = &self.root_certificate { + dict.set_item("root_certificate", PyBytes::new(py, pem))?; + } Ok(dict.clone()) } } -impl From for ClientOptions { - fn from(value: PyClientOptions) -> Self { +impl TryFrom for ClientOptions { + type Error = PyObjectStoreError; + + fn try_from(value: PyClientOptions) -> Result { let mut options = ClientOptions::new(); for (key, value) in value.string_options.into_iter() { options = options.with_config(key.0, value.0); @@ -120,7 +142,13 @@ impl From for ClientOptions { options = options.with_default_headers(headers.0); } - options + if let Some(pem) = value.root_certificate { + for certificate in Certificate::from_pem_bundle(&pem)? { + options = options.with_root_certificate(certificate); + } + } + + Ok(options) } } diff --git a/pyo3-object_store/src/gcp/store.rs b/pyo3-object_store/src/gcp/store.rs index 260708df..80176e8b 100644 --- a/pyo3-object_store/src/gcp/store.rs +++ b/pyo3-object_store/src/gcp/store.rs @@ -103,7 +103,7 @@ impl PyGCSStore { let combined_config = combine_config_kwargs(Some(config), kwargs)?; builder = combined_config.clone().apply_config(builder); if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.into()) + builder = builder.with_client_options(client_options.try_into()?) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/pyo3-object_store/src/http.rs b/pyo3-object_store/src/http.rs index 10d4a7f8..1acfcf3e 100644 --- a/pyo3-object_store/src/http.rs +++ b/pyo3-object_store/src/http.rs @@ -67,7 +67,7 @@ impl PyHttpStore { ) -> PyObjectStoreResult { let mut builder = HttpBuilder::new().with_url(url.clone()); if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.into()) + builder = builder.with_client_options(client_options.try_into()?) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/tests/store/test_client_options.py b/tests/store/test_client_options.py new file mode 100644 index 00000000..8432363c --- /dev/null +++ b/tests/store/test_client_options.py @@ -0,0 +1,129 @@ +import pickle + +import pytest + +from obstore.exceptions import BaseError +from obstore.store import HTTPStore + +# Self-signed certificates used to exercise single- and multi-cert PEM parsing. +CERT_1 = b"""-----BEGIN CERTIFICATE----- +MIIDGzCCAgOgAwIBAgIUBqLgQSw4wiW06IhiKHNN5NyztG8wDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRb2JzdG9yZS10ZXN0LWNhLTEwIBcNMjYwNjE2MTQxMjEz +WhgPMjEyNjA1MjMxNDEyMTNaMBwxGjAYBgNVBAMMEW9ic3RvcmUtdGVzdC1jYS0x +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxw/NGsR0jwDKOSyXsfeT +WvniRh+bozmvQuyxW/qZwuNrgrkPdZbSM3j3eaqFOa9iqSds17u3bTYnC08SGyjo +hecOuja2KP5lYUJ31Vfad4KlkYVuNBjQ3FZb71jbwXuwKnhcrYlMUM8Vt/Oay/q+ +uKDG0kMXTdjXButCFE+s8oTk0dlGEjQ6IuI3/0l5cv631iMIaIW13CDG1mMJwu9Q +yco8/J6aK6ZQFa0T9TRm2v3Y/3qiX+WryQakX72IY9DbRtxwjh0Lm81M46DChsE3 +FyZB69aaKPdSkWUmNAWBa3H8cQziUv2SLz6DAG7r5cJZ0xkbqgiaQR8P1ud8yeL+ +LwIDAQABo1MwUTAdBgNVHQ4EFgQUJGhd1ORfcw1Ov3/fljWfEoUJSx8wHwYDVR0j +BBgwFoAUJGhd1ORfcw1Ov3/fljWfEoUJSx8wDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEAurAH4Gvomh4QffpZf6s3/TPXHGM2wuuuOKB7QD7sfp8V +nlFthbcpEd0SRaH2T4fIeVDuHcMx/F7GYBaVOjXqRq9N6+zeutpPvu7YAoE4zbLz +Wnrn2fZG5uAO+HW26QXOsZU2zJHpHzZWZ3G1dV/C97k8hmSjkH7OOZSlZ/qIN4+p +5t5qBUJek66luxnEyfOdFitiJe9Ri6sj2ffT0aXejZCu2boO+3Szm6boLu3kCC7l +Latj8uDxEm6HsD4gxn61zMmmSjitTJYJt+lW8+3tlSf17tXd1TLG/cF/0gD28OhL +UmOHI1HSNtVKwotBkpOrlby1hWOX7IMtqPRmEJzz6Q== +-----END CERTIFICATE----- +""" + +CERT_2 = b"""-----BEGIN CERTIFICATE----- +MIIDGzCCAgOgAwIBAgIUTxck2vXxjoPXiA51CCIuOodX9AMwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRb2JzdG9yZS10ZXN0LWNhLTIwIBcNMjYwNjE2MTQxMjEz +WhgPMjEyNjA1MjMxNDEyMTNaMBwxGjAYBgNVBAMMEW9ic3RvcmUtdGVzdC1jYS0y +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAswXoqgwFuG/CD9IYB+Jt +mCU7gPinn3XLUNVo9K+8Z+C1AvZvuW0EIxmbE+vUjM3u0PbNf0auATOdfIeT30uo +Rr69C1Qu56I5gJP8nWPscDXyEyMOSDxlhSycObwlKM9dlCIz3fwYlU5ukPw+e+tU +kBPuHFbtElXM4u68BdTTjWNZzA4xplSFc5RCcrNnhYZPCINPRRkrjA27F7o1lwQ+ +ibzg5CquFSnDdojN8edzIE+xGLznOpTiuu17VqljIkT1bxm9qUZ5fOxsKfTHMfQ2 +gBM7lzF1suVWjhCjxKtJwd5JWQyo/ynR2FsAMfxtUXH1GVYrfEm6RUAQQ2hnzFue +wwIDAQABo1MwUTAdBgNVHQ4EFgQU/48jUGMAG9VCesLJ7lSh5TOs+wgwHwYDVR0j +BBgwFoAU/48jUGMAG9VCesLJ7lSh5TOs+wgwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEAiJvYmLrqG/bMWyb3u77tLSfwxGftIo5HApgKt6vgtUZr +0qhgf1scto1lIworYX+MJe/dqHVZC6IjiXOO77HCJlOQoPd3byvV+fu3sU1P79ah +tIFKyRN6cGfAXlPYhR3oB30KEHIMZKve/Y9c1IGv7cogO0/VDXXTe3YcWDLtjzd8 +2wGP3vlKhbzV2ZigRtO37fCTBGJRlpmUT+KWQ5roSSbutyYNhAAAOMoM+M6uD4Mm +H3ZwuubjcsNBfC/YQabjFuDrIUAYUS7CWDVv1Qi/26PlRQJEFoVayDT4aO86cAop +Tg1+pQESP0OOub4TswJ0qs0Xx2umJdos/XOaf3xfpQ== +-----END CERTIFICATE----- +""" + +CERT_BUNDLE = CERT_1 + CERT_2 + + +def test_proxy_excludes(): + HTTPStore.from_url( + "https://example.com", + client_options={ + "proxy_url": "https://proxy.example.com:8080", + "proxy_excludes": "localhost,127.0.0.1,.svc.cluster.local", + }, + ) + + +def test_proxy_ca_certificate(): + HTTPStore.from_url( + "https://example.com", + client_options={ + "proxy_url": "https://proxy.example.com:8080", + "proxy_ca_certificate": CERT_1.decode(), + }, + ) + + +def test_root_certificate_bytes(): + HTTPStore.from_url( + "https://example.com", + client_options={"root_certificate": CERT_1}, + ) + + +def test_root_certificate_str(): + HTTPStore.from_url( + "https://example.com", + client_options={"root_certificate": CERT_1.decode()}, + ) + + +def test_root_certificate_bundle(): + HTTPStore.from_url( + "https://example.com", + client_options={"root_certificate": CERT_BUNDLE}, + ) + + +def test_root_certificate_additive_to_public_host(): + HTTPStore.from_url( + "https://s3.amazonaws.com", + client_options={"root_certificate": CERT_BUNDLE}, + ) + + +def test_root_certificate_no_pem_blocks_is_noop(): + HTTPStore.from_url( + "https://example.com", + client_options={"root_certificate": b"not a real certificate"}, + ) + + +def test_root_certificate_malformed_block_raises(): + with pytest.raises(BaseError): + HTTPStore.from_url( + "https://example.com", + client_options={ + "root_certificate": ( + b"-----BEGIN CERTIFICATE-----\n" + b"not-valid-base64!!!\n" + b"-----END CERTIFICATE-----\n" + ), + }, + ) + + +def test_root_certificate_roundtrip_pickle(): + store = HTTPStore.from_url( + "https://example.com", + client_options={"root_certificate": CERT_BUNDLE}, + ) + restored: HTTPStore = pickle.loads(pickle.dumps(store)) + assert restored.url == store.url From fbe0a522bfc23821b01c47bda5c564fd03c33e0c Mon Sep 17 00:00:00 2001 From: Louis Christopher Date: Wed, 17 Jun 2026 09:18:34 +0530 Subject: [PATCH 2/5] refactor: Parse root certificate into a PyCertificate wrapper --- pyo3-object_store/src/aws/store.rs | 2 +- pyo3-object_store/src/azure/store.rs | 2 +- pyo3-object_store/src/client.rs | 72 ++++++++++++++++++++-------- pyo3-object_store/src/gcp/store.rs | 2 +- pyo3-object_store/src/http.rs | 2 +- tests/store/test_client_options.py | 3 +- 6 files changed, 57 insertions(+), 26 deletions(-) diff --git a/pyo3-object_store/src/aws/store.rs b/pyo3-object_store/src/aws/store.rs index a5094e95..f8041c4a 100644 --- a/pyo3-object_store/src/aws/store.rs +++ b/pyo3-object_store/src/aws/store.rs @@ -107,7 +107,7 @@ impl PyS3Store { let mut combined_config = combine_config_kwargs(config, kwargs)?; if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.try_into()?) + builder = builder.with_client_options(client_options.into()) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/pyo3-object_store/src/azure/store.rs b/pyo3-object_store/src/azure/store.rs index 784b52f5..97420416 100644 --- a/pyo3-object_store/src/azure/store.rs +++ b/pyo3-object_store/src/azure/store.rs @@ -113,7 +113,7 @@ impl PyAzureStore { let mut combined_config = combine_config_kwargs(Some(config), kwargs)?; if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.try_into()?) + builder = builder.with_client_options(client_options.into()) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/pyo3-object_store/src/client.rs b/pyo3-object_store/src/client.rs index 952cce37..93ccb1cf 100644 --- a/pyo3-object_store/src/client.rs +++ b/pyo3-object_store/src/client.rs @@ -11,12 +11,44 @@ use pyo3::types::{PyBytes, PyDict, PyString}; use crate::config::PyConfigValue; use crate::error::PyObjectStoreError; -fn extract_pem(value: &Bound<'_, PyAny>) -> PyResult> { - if let Ok(bytes) = value.extract::() { - Ok(bytes.as_ref().to_vec()) - } else { - let s = value.extract::()?; - Ok(s.as_bytes().to_vec()) +/// A wrapper around one or more `Certificate`s parsed from PEM input. +/// +/// The original PEM is retained so the value round-trips through +/// [`IntoPyObject`]; parsing happens once, on extraction. +#[derive(Clone, Debug)] +struct PyCertificate { + pem: Vec, + certificates: Vec, +} + +impl PartialEq for PyCertificate { + fn eq(&self, other: &Self) -> bool { + self.pem == other.pem + } +} + +impl<'py> FromPyObject<'_, 'py> for PyCertificate { + type Error = PyErr; + + fn extract(obj: Borrowed<'_, 'py, pyo3::PyAny>) -> PyResult { + let pem = if let Ok(bytes) = obj.extract::() { + bytes.as_ref().to_vec() + } else { + obj.extract::()?.as_bytes().to_vec() + }; + let certificates = + Certificate::from_pem_bundle(&pem).map_err(PyObjectStoreError::ObjectStoreError)?; + Ok(Self { pem, certificates }) + } +} + +impl<'py> IntoPyObject<'py> for &PyCertificate { + type Target = PyBytes; + type Output = Bound<'py, PyBytes>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + Ok(PyBytes::new(py, &self.pem)) } } @@ -59,9 +91,7 @@ impl<'py> IntoPyObject<'py> for &PyClientConfigKey { pub struct PyClientOptions { string_options: HashMap, default_headers: Option, - // Stored as raw PEM so it round-trips through `IntoPyObject`; parsed on the - // way into `ClientOptions`. - root_certificate: Option>, + root_certificate: Option, } impl<'py> FromPyObject<'_, 'py> for PyClientOptions { @@ -81,7 +111,9 @@ impl<'py> FromPyObject<'_, 'py> for PyClientOptions { let key = key.extract::()?; match &*key { "default_headers" => default_headers = Some(value.extract::()?), - "root_certificate" => root_certificate = Some(extract_pem(&value)?), + "root_certificate" => { + root_certificate = Some(value.extract::()?) + } _ => return Err(PyValueError::new_err(format!("Invalid key: {key}."))), } } @@ -105,8 +137,8 @@ impl<'py> IntoPyObject<'py> for PyClientOptions { if let Some(headers) = self.default_headers { dict.set_item("default_headers", headers)?; } - if let Some(pem) = self.root_certificate { - dict.set_item("root_certificate", PyBytes::new(py, &pem))?; + if let Some(certificate) = &self.root_certificate { + dict.set_item("root_certificate", certificate)?; } Ok(dict) } @@ -122,17 +154,15 @@ impl<'py> IntoPyObject<'py> for &PyClientOptions { if let Some(headers) = &self.default_headers { dict.set_item("default_headers", headers)?; } - if let Some(pem) = &self.root_certificate { - dict.set_item("root_certificate", PyBytes::new(py, pem))?; + if let Some(certificate) = &self.root_certificate { + dict.set_item("root_certificate", certificate)?; } Ok(dict.clone()) } } -impl TryFrom for ClientOptions { - type Error = PyObjectStoreError; - - fn try_from(value: PyClientOptions) -> Result { +impl From for ClientOptions { + fn from(value: PyClientOptions) -> Self { let mut options = ClientOptions::new(); for (key, value) in value.string_options.into_iter() { options = options.with_config(key.0, value.0); @@ -142,13 +172,13 @@ impl TryFrom for ClientOptions { options = options.with_default_headers(headers.0); } - if let Some(pem) = value.root_certificate { - for certificate in Certificate::from_pem_bundle(&pem)? { + if let Some(certificate) = value.root_certificate { + for certificate in certificate.certificates { options = options.with_root_certificate(certificate); } } - Ok(options) + options } } diff --git a/pyo3-object_store/src/gcp/store.rs b/pyo3-object_store/src/gcp/store.rs index 80176e8b..260708df 100644 --- a/pyo3-object_store/src/gcp/store.rs +++ b/pyo3-object_store/src/gcp/store.rs @@ -103,7 +103,7 @@ impl PyGCSStore { let combined_config = combine_config_kwargs(Some(config), kwargs)?; builder = combined_config.clone().apply_config(builder); if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.try_into()?) + builder = builder.with_client_options(client_options.into()) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/pyo3-object_store/src/http.rs b/pyo3-object_store/src/http.rs index 1acfcf3e..10d4a7f8 100644 --- a/pyo3-object_store/src/http.rs +++ b/pyo3-object_store/src/http.rs @@ -67,7 +67,7 @@ impl PyHttpStore { ) -> PyObjectStoreResult { let mut builder = HttpBuilder::new().with_url(url.clone()); if let Some(client_options) = client_options.clone() { - builder = builder.with_client_options(client_options.try_into()?) + builder = builder.with_client_options(client_options.into()) } if let Some(retry_config) = retry_config.clone() { builder = builder.with_retry(retry_config.into()) diff --git a/tests/store/test_client_options.py b/tests/store/test_client_options.py index 8432363c..4982c76a 100644 --- a/tests/store/test_client_options.py +++ b/tests/store/test_client_options.py @@ -5,7 +5,8 @@ from obstore.exceptions import BaseError from obstore.store import HTTPStore -# Self-signed certificates used to exercise single- and multi-cert PEM parsing. +# Self-signed CAs generated with: +# openssl req -x509 -newkey rsa:2048 -nodes -days 36500 -subj /CN=obstore-test-ca-N CERT_1 = b"""-----BEGIN CERTIFICATE----- MIIDGzCCAgOgAwIBAgIUBqLgQSw4wiW06IhiKHNN5NyztG8wDQYJKoZIhvcNAQEL BQAwHDEaMBgGA1UEAwwRb2JzdG9yZS10ZXN0LWNhLTEwIBcNMjYwNjE2MTQxMjEz From 1baefabe3d537c1e873943c50bdfc624fbf6e84a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 25 Jun 2026 10:22:26 -0400 Subject: [PATCH 3/5] cleaner extraction --- pyo3-object_store/src/client.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyo3-object_store/src/client.rs b/pyo3-object_store/src/client.rs index 93ccb1cf..b4f1e25f 100644 --- a/pyo3-object_store/src/client.rs +++ b/pyo3-object_store/src/client.rs @@ -31,10 +31,10 @@ impl<'py> FromPyObject<'_, 'py> for PyCertificate { type Error = PyErr; fn extract(obj: Borrowed<'_, 'py, pyo3::PyAny>) -> PyResult { - let pem = if let Ok(bytes) = obj.extract::() { - bytes.as_ref().to_vec() + let pem = if let Ok(bytes) = obj.extract::>() { + bytes } else { - obj.extract::()?.as_bytes().to_vec() + obj.extract::()?.into_bytes() }; let certificates = Certificate::from_pem_bundle(&pem).map_err(PyObjectStoreError::ObjectStoreError)?; From e750bc532b29731870b38b03b29613edf46e03c9 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 25 Jun 2026 10:24:00 -0400 Subject: [PATCH 4/5] extract PyCertificate::new --- pyo3-object_store/src/client.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pyo3-object_store/src/client.rs b/pyo3-object_store/src/client.rs index b4f1e25f..106e648b 100644 --- a/pyo3-object_store/src/client.rs +++ b/pyo3-object_store/src/client.rs @@ -10,6 +10,7 @@ use pyo3::types::{PyBytes, PyDict, PyString}; use crate::config::PyConfigValue; use crate::error::PyObjectStoreError; +use crate::PyObjectStoreResult; /// A wrapper around one or more `Certificate`s parsed from PEM input. /// @@ -21,6 +22,13 @@ struct PyCertificate { certificates: Vec, } +impl PyCertificate { + fn new(pem: Vec) -> PyObjectStoreResult { + let certificates = Certificate::from_pem_bundle(&pem)?; + Ok(Self { pem, certificates }) + } +} + impl PartialEq for PyCertificate { fn eq(&self, other: &Self) -> bool { self.pem == other.pem @@ -36,9 +44,7 @@ impl<'py> FromPyObject<'_, 'py> for PyCertificate { } else { obj.extract::()?.into_bytes() }; - let certificates = - Certificate::from_pem_bundle(&pem).map_err(PyObjectStoreError::ObjectStoreError)?; - Ok(Self { pem, certificates }) + Ok(Self::new(pem)?) } } From 289d0b4d37ce6efaf1b6d1dd3d129feb01fa68ae Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 25 Jun 2026 10:47:14 -0400 Subject: [PATCH 5/5] error on empty certificates --- pyo3-object_store/src/client.rs | 6 ++++++ tests/store/test_client_options.py | 13 ++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pyo3-object_store/src/client.rs b/pyo3-object_store/src/client.rs index 106e648b..95c2c367 100644 --- a/pyo3-object_store/src/client.rs +++ b/pyo3-object_store/src/client.rs @@ -25,6 +25,12 @@ struct PyCertificate { impl PyCertificate { fn new(pem: Vec) -> PyObjectStoreResult { let certificates = Certificate::from_pem_bundle(&pem)?; + if certificates.is_empty() { + return Err(PyValueError::new_err( + "No certificates found in `root_certificate` input; expected one or more PEM-encoded certificates.", + ) + .into()); + } Ok(Self { pem, certificates }) } } diff --git a/tests/store/test_client_options.py b/tests/store/test_client_options.py index 4982c76a..1d3936ab 100644 --- a/tests/store/test_client_options.py +++ b/tests/store/test_client_options.py @@ -100,11 +100,14 @@ def test_root_certificate_additive_to_public_host(): ) -def test_root_certificate_no_pem_blocks_is_noop(): - HTTPStore.from_url( - "https://example.com", - client_options={"root_certificate": b"not a real certificate"}, - ) +def test_root_certificate_no_pem_blocks_raises(): + # Input that contains no PEM blocks parses to zero certificates; rather than + # silently trusting nothing, this should surface as an error. + with pytest.raises(ValueError, match="No certificates found"): + HTTPStore.from_url( + "https://example.com", + client_options={"root_certificate": b"not a real certificate"}, + ) def test_root_certificate_malformed_block_raises():