"""Strict H2/TLS and H3/QUIC clients for local RustProxy acceptance tests. The generated fixture certificate is explicitly trusted. Neither transport silently falls back to HTTP/1.1 or disables certificate verification. """ from __future__ import annotations import asyncio from contextlib import asynccontextmanager from dataclasses import dataclass, field from pathlib import Path import ssl import httpx from aioquic.asyncio import QuicConnectionProtocol, connect from aioquic.h3.connection import H3Connection, MessageError from aioquic.h3.events import DataReceived, HeadersReceived from aioquic.quic.configuration import QuicConfiguration from aioquic.quic.events import ConnectionTerminated, ProtocolNegotiated, StreamReset @dataclass class Response: status: int headers: dict[str, str] body: bytes protocol: str alpn: str | None stream_id: int | None = None class H2Client: def __init__(self, client: httpx.AsyncClient, port: int): self.client, self.port = client, port async def request(self, method, path, headers=None, body=None): async def chunks(): for offset in range(0, len(body), 16384): yield body[offset:offset + 16384] await asyncio.sleep(0) response = await self.client.request( method, f"https://127.0.0.1:{self.port}{path}", headers=headers or {}, content=chunks() if body is not None else None, ) network = response.extensions.get("network_stream") tls = network.get_extra_info("ssl_object") if network else None alpn = tls.selected_alpn_protocol() if tls else None if response.http_version != "HTTP/2" or alpn != "h2": raise AssertionError(f"H2 required, got {response.http_version}, ALPN={alpn!r}") return Response(response.status_code, dict(response.headers), response.content, "HTTP/2", alpn, response.extensions.get("stream_id")) @dataclass class Pending: future: asyncio.Future status: int = 0 headers: dict[str, str] = field(default_factory=dict) body: bytearray = field(default_factory=bytearray) class HeadAwareH3Connection(H3Connection): """Supply request-method context missing from aioquic 1.7.0's length check. RFC 9110 section 8.6 permits HEAD's Content-Length to describe a GET body. Still reject any actual HEAD DATA; keep aioquic's exact length check for all other requests. This changes only the test client, never the target server. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.head_streams = set() def _check_content_length(self, stream): if stream.stream_id in self.head_streams: if stream.content_length: raise MessageError("HEAD response contained DATA") return super()._check_content_length(stream) def validate_head_adapter(): """Keep adapter checks separate from the real-server acceptance totals.""" from types import SimpleNamespace connection = HeadAwareH3Connection.__new__(HeadAwareH3Connection) connection.head_streams = {0} for stream_id, declared, received, valid in [(0, 1040, 0, True), (0, 1040, 1, False), (4, 1040, 1040, True), (4, 1040, 0, False)]: stream = SimpleNamespace(stream_id=stream_id, expected_content_length=declared, content_length=received) try: connection._check_content_length(stream) accepted = True except MessageError: accepted = False assert accepted == valid, "H3 fixture HEAD adapter changed ordinary framing validation" return {"cases": 4, "passed": 4, "kind": "test-client self-test, not RustProxy coverage"} class H3Client(QuicConnectionProtocol): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.http = HeadAwareH3Connection(self._quic) self.pending: dict[int, Pending] = {} self.alpn = None self.authority = None def quic_event_received(self, event): if isinstance(event, ProtocolNegotiated): self.alpn = event.alpn_protocol if isinstance(event, ConnectionTerminated): for item in self.pending.values(): if not item.future.done(): item.future.set_exception(ConnectionError( f"QUIC closed code={event.error_code}: {event.reason_phrase}")) if isinstance(event, StreamReset): item = self.pending.get(event.stream_id) if item is not None and not item.future.done(): item.future.set_exception(ConnectionError( f"H3 stream reset code={event.error_code}")) for http_event in self.http.handle_event(event): if not isinstance(http_event, (HeadersReceived, DataReceived)): continue item = self.pending.get(http_event.stream_id) if item is None or item.future.done(): continue if isinstance(http_event, HeadersReceived): for name, value in http_event.headers: if name == b":status": item.status = int(value) elif not name.startswith(b":"): item.headers[name.decode("ascii").lower()] = value.decode("latin-1") else: item.body.extend(http_event.data) if len(item.body) > 4 * 1024 * 1024: item.future.set_exception(ValueError("fixture response exceeded 4 MiB bound")) continue if http_event.stream_ended: item.future.set_result(Response(item.status, item.headers, bytes(item.body), "HTTP/3", self.alpn, http_event.stream_id)) async def request(self, method, path, headers=None, body=None): if self.alpn != "h3": raise AssertionError(f"H3 required, got ALPN={self.alpn!r}") stream_id = self._quic.get_next_available_stream_id() item = Pending(asyncio.get_running_loop().create_future()) self.pending[stream_id] = item if method == "HEAD": self.http.head_streams.add(stream_id) fields = [(b":method", method.encode("ascii")), (b":scheme", b"https"), (b":authority", self.authority.encode("ascii")), (b":path", path.encode("ascii"))] fields += [(name.lower().encode("ascii"), value.encode("latin-1")) for name, value in (headers or {}).items()] try: self.http.send_headers(stream_id, fields, end_stream=body is None) self.transmit() if body is not None: if not body: self.http.send_data(stream_id, b"", end_stream=True) self.transmit() for offset in range(0, len(body), 16384): end = min(offset + 16384, len(body)) self.http.send_data(stream_id, body[offset:end], end_stream=end == len(body)) self.transmit() await asyncio.sleep(0) return await asyncio.wait_for(item.future, timeout=10) finally: self.pending.pop(stream_id, None) self.http.head_streams.discard(stream_id) @asynccontextmanager async def client(protocol: str, port: int, certificate: Path): if protocol == "h2": context = ssl.create_default_context(cafile=str(certificate)) async with httpx.AsyncClient(http2=True, verify=context, trust_env=False, timeout=10, limits=httpx.Limits(max_connections=1)) as session: yield H2Client(session, port) elif protocol == "h3": config = QuicConfiguration(is_client=True, alpn_protocols=["h3"], server_name="localhost", idle_timeout=10) config.verify_mode = ssl.CERT_REQUIRED config.load_verify_locations(cafile=str(certificate)) async with connect("127.0.0.1", port, configuration=config, create_protocol=H3Client) as session: session.authority = f"127.0.0.1:{port}" yield session else: raise ValueError(f"unsupported fixture protocol {protocol!r}")