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:
ethernet
2026-09-21 18:42:05 -04:00
parent 3917f11d79
commit f69d5777c6
2 changed files with 61 additions and 5 deletions

View File

@@ -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)

View File

@@ -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