fix(ssl): build explicit-bundle contexts without importing truststore
_shared_context imported truststore._ssl_constants unconditionally on the ssl_ca_cert branch. truststore is a >=3.14 dependency, so a 3.11-3.13 bridge install with a provider ssl_ca_cert crashed client construction with ModuleNotFoundError. Only reach for truststore's saved original class when ssl.SSLContext has actually been replaced; otherwise the stdlib class is right there and the private import is never needed. Also pin the test env: the "missing bundle falls back to True" contract inherits the host's SSL_CERT_FILE (NixOS shells export it), which flips the return to the shared platform context — a host dependency, not an order dependency. Add the SSL_CERT_FILE contract as its own test.
This commit is contained in:
@@ -73,18 +73,33 @@ _CA_CONTEXTS: dict[str | None, ssl.SSLContext] = {}
|
||||
_CA_CONTEXTS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _stdlib_ssl_context_class() -> type[ssl.SSLContext]:
|
||||
"""The un-injected stdlib ``ssl.SSLContext``.
|
||||
|
||||
An explicit bundle must REPLACE OS trust, and truststore's context falls
|
||||
back to the OS verifier whenever the loaded bundle rejects a chain — so
|
||||
the bundle context has to be built from the stdlib class. Once injected
|
||||
(here, or by pm.launch/pm.worker before this module loads) the only
|
||||
handle on it is truststore's own saved reference; when nothing is
|
||||
injected — truststore absent or not installed — ``ssl.SSLContext`` is
|
||||
already the stdlib class and truststore must not be imported at all.
|
||||
"""
|
||||
if ssl.SSLContext.__module__ == "ssl":
|
||||
return ssl.SSLContext
|
||||
from truststore._ssl_constants import _original_SSLContext
|
||||
|
||||
return _original_SSLContext
|
||||
|
||||
|
||||
def _shared_context(ca_path: str | None) -> ssl.SSLContext:
|
||||
"""A stable context identity lets clients reuse the existing transport pool."""
|
||||
with _CA_CONTEXTS_LOCK:
|
||||
ctx = _CA_CONTEXTS.get(ca_path)
|
||||
if ctx is None:
|
||||
if ca_path is not None:
|
||||
from truststore._ssl_constants import _original_SSLContext
|
||||
|
||||
# An explicit bundle replaces OS trust, not augments it.
|
||||
# PROTOCOL_TLS_CLIENT sets hostname checking and CERT_REQUIRED;
|
||||
# assigning the original class's properties after injection recurses.
|
||||
ctx = _original_SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx = _stdlib_ssl_context_class()(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.load_verify_locations(cafile=ca_path)
|
||||
else:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
|
||||
@@ -9,13 +9,35 @@ import pytest
|
||||
from agent.ssl_verify import resolve_httpx_verify
|
||||
|
||||
|
||||
def test_missing_explicit_bundle_falls_back_to_the_platform_store(tmp_path, caplog):
|
||||
@pytest.fixture
|
||||
def no_ca_env(monkeypatch):
|
||||
"""The developer shell (NixOS exports SSL_CERT_FILE) and the gateway's
|
||||
own cert export both flip resolve_httpx_verify() from ``True`` to a
|
||||
shared context. The contract under test is the fallback, so pin the
|
||||
env the assertion assumes instead of inheriting the host's."""
|
||||
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
||||
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
||||
|
||||
|
||||
def test_missing_explicit_bundle_falls_back_to_the_platform_store(tmp_path, caplog, no_ca_env):
|
||||
missing = str(tmp_path / "nope.pem")
|
||||
|
||||
assert resolve_httpx_verify(ca_bundle=missing) is True
|
||||
assert "does not exist" in caplog.text
|
||||
|
||||
|
||||
def test_missing_bundle_under_a_cert_env_var_shares_the_platform_context(tmp_path, monkeypatch):
|
||||
"""With SSL_CERT_FILE exported the platform store is handed over as one
|
||||
shared context (httpx would otherwise read the env var itself); a missing
|
||||
bundle must land on that same object, not a second pool."""
|
||||
import certifi
|
||||
|
||||
monkeypatch.setenv("SSL_CERT_FILE", certifi.where())
|
||||
platform = resolve_httpx_verify()
|
||||
assert platform is not True
|
||||
assert resolve_httpx_verify(ca_bundle=str(tmp_path / "nope.pem")) is platform
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, "false", "0", "no", "off", "FALSE"])
|
||||
def test_insecure_disables_verification(value):
|
||||
assert resolve_httpx_verify(ssl_verify=value) is False
|
||||
@@ -51,3 +73,22 @@ with httpx.Client(verify=resolve_httpx_verify()) as client:
|
||||
"""], capture_output=True, text=True, timeout=30)
|
||||
assert child.returncode == 0, child.stderr
|
||||
assert "truststore unavailable" in child.stderr
|
||||
|
||||
|
||||
def test_explicit_bundle_works_without_truststore():
|
||||
"""A provider ``ssl_ca_cert`` on an interpreter without truststore
|
||||
(3.11–3.13 bridge installs) must still yield a verifying context built
|
||||
on that bundle, not an import error at client construction."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
child = subprocess.run([sys.executable, "-c", """
|
||||
import sys, ssl, certifi
|
||||
sys.modules['truststore'] = None
|
||||
from agent.ssl_verify import resolve_httpx_verify
|
||||
ctx = resolve_httpx_verify(ca_bundle=certifi.where())
|
||||
assert isinstance(ctx, ssl.SSLContext), ctx
|
||||
assert ctx.verify_mode == ssl.CERT_REQUIRED and ctx.check_hostname
|
||||
assert ctx.cert_store_stats()['x509_ca'] > 0
|
||||
"""], capture_output=True, text=True, timeout=30)
|
||||
assert child.returncode == 0, child.stderr
|
||||
|
||||
Reference in New Issue
Block a user