diff --git a/.github/workflows/pytest-suite.yml b/.github/workflows/pytest-suite.yml new file mode 100644 index 00000000..92485820 --- /dev/null +++ b/.github/workflows/pytest-suite.yml @@ -0,0 +1,49 @@ +name: pytest-suite + +on: + push: + paths: + - 'src/**' + - 'tests/**' + - 'pyproject.toml' + - '.github/workflows/pytest-suite.yml' + pull_request: + paths: + - 'src/**' + - 'tests/**' + - 'pyproject.toml' + - '.github/workflows/pytest-suite.yml' + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.11', '3.12'] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run pytest suite + run: uv run python -m pytest tests/ -v --tb=short --junitxml=pytest-results-${{ matrix.python-version }}.xml + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-results-${{ matrix.python-version }} + path: pytest-results-${{ matrix.python-version }}.xml diff --git a/Makefile b/Makefile index 860ca8f6..6af28749 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ -.PHONY: test certification-env-freeze phase9-release-workflow +.PHONY: test test-pytest certification-env-freeze phase9-release-workflow test: PYTHONPATH=src python -m unittest discover -s tests -p 'test_*.py' -v +test-pytest: + uv run python -m pytest tests/ -v + certification-env-freeze: PYTHONPATH=src python tools/freeze_certification_environment.py diff --git a/pyproject.toml b/pyproject.toml index 8c4b9dbd..eb1c487d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ full-featured = [ ] dev = [ "pytest>=8.0", + "pytest-asyncio>=0.23", "aioquic>=1.3.0", "h2>=4.1.0", "websockets>=12.0", @@ -75,3 +76,8 @@ tigrcorn = ["py.typed"] [tool.setuptools.packages.find] where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +python_files = ["test_*_pytest.py"] +testpaths = ["tests"] diff --git a/tests/test_additional_remaining_work.py b/tests/test_additional_remaining_work.py index b22f8bbf..209d4783 100644 --- a/tests/test_additional_remaining_work.py +++ b/tests/test_additional_remaining_work.py @@ -1,5 +1,6 @@ import asyncio import base64 +from contextlib import suppress import os import socket import unittest @@ -17,8 +18,10 @@ from tigrcorn.protocols.websocket.frames import encode_frame, read_frame -async def _start_http_server(app): +async def _start_http_server(app, websocket_compression: str | None = None): config = build_config(host='127.0.0.1', port=0, lifespan='off', http_versions=['1.1']) + if websocket_compression is not None: + config.websocket.compression = websocket_compression server = TigrCornServer(app, config) await server.start() port = server._listeners[0].server.sockets[0].getsockname()[1] @@ -114,9 +117,9 @@ async def app(scope, receive, send): await send({'type': 'websocket.send', 'text': event['text']}) await send({'type': 'websocket.close', 'code': 1000}) - server, port = await _start_http_server(app) + server, port = await _start_http_server(app, websocket_compression='permessage-deflate') try: - reader, writer = await asyncio.open_connection('127.0.0.1', port) + reader, writer = await asyncio.wait_for(asyncio.open_connection('127.0.0.1', port), 1.0) key = base64.b64encode(os.urandom(16)) writer.write( b'GET /ws HTTP/1.1\r\n' @@ -127,12 +130,12 @@ async def app(scope, receive, send): b'Sec-WebSocket-Key: ' + key + b'\r\n' b'Sec-WebSocket-Extensions: permessage-deflate\r\n\r\n' ) - await writer.drain() - response = await reader.readuntil(b'\r\n\r\n') + await asyncio.wait_for(writer.drain(), 1.0) + response = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), 1.0) self.assertIn(b'sec-websocket-extensions: permessage-deflate', response.lower()) compressed = _compress_ws_message(b'hello compressed') writer.write(encode_frame(0x1, compressed, fin=True, masked=True, rsv1=True)) - await writer.drain() + await asyncio.wait_for(writer.drain(), 1.0) frame = await asyncio.wait_for(read_frame(reader, max_payload_size=4096, expect_masked=False, allow_rsv1=True), 1.0) self.assertTrue(frame.rsv1) decompressor = zlib.decompressobj(wbits=-15) @@ -140,9 +143,11 @@ async def app(scope, receive, send): self.assertEqual(echoed, b'hello compressed') self.assertEqual(seen['event']['text'], 'hello compressed') writer.close() - await writer.wait_closed() + await asyncio.wait_for(writer.wait_closed(), 1.0) finally: - await server.close() + server.request_shutdown() + with suppress(asyncio.TimeoutError, asyncio.CancelledError): + await asyncio.wait_for(server.close(), 2.0) class RemainingWorkQuicRoutingTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/test_additional_remaining_work_pytest.py b/tests/test_additional_remaining_work_pytest.py new file mode 100644 index 00000000..a7796bc1 --- /dev/null +++ b/tests/test_additional_remaining_work_pytest.py @@ -0,0 +1,242 @@ +import asyncio +import base64 +from contextlib import suppress +import os +import zlib + +import pytest + +from tigrcorn.config.defaults import default_config +from tigrcorn.config.load import build_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.protocols.http3.handler import HTTP3DatagramHandler +from tigrcorn.protocols.http3.streams import HTTP3ConnectionCore +from tigrcorn.protocols.websocket.frames import encode_frame, read_frame +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.transports.udp.packet import UDPPacket + + +async def _start_http_server(app, websocket_compression: str | None = None): + config = build_config( + host="127.0.0.1", port=0, lifespan="off", http_versions=["1.1"] + ) + if websocket_compression is not None: + config.websocket.compression = websocket_compression + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +def _compress_ws_message(payload: bytes) -> bytes: + compressor = zlib.compressobj(wbits=-15) + compressed = compressor.compress(payload) + compressor.flush(zlib.Z_SYNC_FLUSH) + return compressed[:-4] + + +@pytest.mark.asyncio +async def test_chunked_request_trailers_are_exposed() -> None: + seen = {} + + async def app(scope, receive, send): + seen["extensions"] = scope["extensions"] + seen["events"] = [await receive(), await receive(), await receive()] + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain")], + } + ) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + server, port = await _start_http_server(app) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection("127.0.0.1", port), 1.0 + ) + writer.write( + b"POST /trailers HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"5\r\nhello\r\n" + b"0\r\nX-Trailer-One: yes\r\nX-Trailer-Two: done\r\n\r\n" + ) + await asyncio.wait_for(writer.drain(), 1.0) + await reader.readuntil(b"\r\n\r\n") + writer.close() + await writer.wait_closed() + finally: + await server.close() + assert "tigrcorn.http.request_trailers" in seen["extensions"] + assert seen["events"][0]["type"] == "http.request" + assert seen["events"][1]["type"] == "http.request" + assert not seen["events"][1]["more_body"] + assert seen["events"][2]["type"] == "http.request.trailers" + assert seen["events"][2]["trailers"] == [ + (b"x-trailer-one", b"yes"), + (b"x-trailer-two", b"done"), + ] + + +@pytest.mark.asyncio +async def test_connect_tunnel_relays_bytes() -> None: + received = bytearray() + + async def upstream_handler( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ): + data = await reader.read(1024) + received.extend(data) + writer.write(data[::-1]) + await asyncio.wait_for(writer.drain(), 1.0) + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + + async def app(scope, receive, send): + raise AssertionError( + "CONNECT tunnel should be handled before ASGI app dispatch" + ) + + server, port = await _start_http_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + f"CONNECT 127.0.0.1:{upstream_port} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode( + "ascii" + ) + ) + await writer.drain() + head = await reader.readuntil(b"\r\n\r\n") + assert b"200 Connection Established" in head + writer.write(b"abcdef") + await writer.drain() + echoed = await asyncio.wait_for(reader.readexactly(6), 1.0) + assert echoed == b"fedcba" + assert bytes(received) == b"abcdef" + writer.close() + await writer.wait_closed() + finally: + server.request_shutdown() + await server.close() + upstream.close() + await upstream.wait_closed() + + +@pytest.mark.asyncio +async def test_permessage_deflate_negotiates_and_roundtrips() -> None: + seen = {} + + async def app(scope, receive, send): + await receive() + await send( + { + "type": "websocket.accept", + "headers": [(b"sec-websocket-extensions", b"permessage-deflate")], + } + ) + event = await receive() + seen["event"] = event + await send({"type": "websocket.send", "text": event["text"]}) + await send({"type": "websocket.close", "code": 1000}) + + server, port = await _start_http_server( + app, websocket_compression="permessage-deflate" + ) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection("127.0.0.1", port), 1.0 + ) + key = base64.b64encode(os.urandom(16)) + writer.write( + b"GET /ws HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: " + key + b"\r\n" + b"Sec-WebSocket-Extensions: permessage-deflate\r\n\r\n" + ) + await asyncio.wait_for(writer.drain(), 1.0) + response = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 1.0) + assert b"sec-websocket-extensions: permessage-deflate" in response.lower() + compressed = _compress_ws_message(b"hello compressed") + writer.write(encode_frame(0x1, compressed, fin=True, masked=True, rsv1=True)) + await asyncio.wait_for(writer.drain(), 1.0) + frame = await asyncio.wait_for( + read_frame( + reader, max_payload_size=4096, expect_masked=False, allow_rsv1=True + ), + 1.0, + ) + assert frame.rsv1 + decompressor = zlib.decompressobj(wbits=-15) + echoed = decompressor.decompress(frame.payload + b"\x00\x00\xff\xff") + assert echoed == b"hello compressed" + assert seen["event"]["text"] == "hello compressed" + writer.close() + await asyncio.wait_for(writer.wait_closed(), 1.0) + finally: + server.request_shutdown() + with suppress(asyncio.TimeoutError, asyncio.CancelledError): + await asyncio.wait_for(server.close(), 2.0) + + +@pytest.mark.asyncio +async def test_http3_session_survives_address_rebinding_via_connection_id() -> None: + async def app(scope, receive, send): + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + handler = HTTP3DatagramHandler( + app=app, + config=default_config(), + listener=ListenerConfig( + kind="udp", + host="127.0.0.1", + port=1, + protocols=["http3"], + quic_secret=b"shared", + ), + access_logger=AccessLogger(configure_logging("warning"), enabled=False), + ) + + class Endpoint: + def __init__(self): + self.sent = [] + self.local_addr = ("127.0.0.1", 4433) + + def send(self, data, addr): + self.sent.append((data, addr)) + + endpoint = Endpoint() + client = QuicConnection(is_client=True, secret=b"shared", local_cid=b"cli1") + await handler.handle_packet( + UDPPacket(data=client.build_initial(), addr=("127.0.0.1", 50000)), endpoint + ) + assert len(handler.sessions_by_local_cid) == 1 + core = HTTP3ConnectionCore() + for raw, _addr in endpoint.sent: + for event in client.receive_datagram(raw): + if event.kind == "stream": + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + endpoint.sent.clear() + request_payload = core.get_request(0).encode_request( + [(b":method", b"POST"), (b":path", b"/rebind"), (b":scheme", b"https")], b"hi" + ) + await handler.handle_packet( + UDPPacket( + data=client.send_stream_data(0, request_payload, fin=True), + addr=("127.0.0.1", 50001), + ), + endpoint, + ) + assert len(handler.sessions_by_local_cid) == 1 + assert len(handler.sessions) == 1 + session = next(iter(handler.sessions.values())) + assert session.addr == ("127.0.0.1", 50001) diff --git a/tests/test_aioquic_adapter_helpers_pytest.py b/tests/test_aioquic_adapter_helpers_pytest.py new file mode 100644 index 00000000..003b461e --- /dev/null +++ b/tests/test_aioquic_adapter_helpers_pytest.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from tests.fixtures_third_party._aioquic_utils import ( + certificate_input_status, + detect_local_control_stream_id, + detect_peer_qpack_streams, + detect_retry_observed, + encode_goaway_frame, + env_flag, + header_map, + header_pairs_to_text, + path_status, + quic_varint_encode, + received_settings, + session_ticket_allows_early_data, +) + + +class _DummyH3: + def __init__(self) -> None: + self._local_control_stream_id = 2 + self._peer_qpack_encoder_stream_id = 7 + self._peer_qpack_decoder_stream_id = 11 + self.received_settings = {0x08: 1} + + +class _DummyQuicRetry: + def __init__(self) -> None: + self._retry_count = 1 + self._retry_source_connection_id = b"retry-source" + + +class _DummySessionTicket: + def __init__(self, max_early_data_size: int) -> None: + self.max_early_data_size = max_early_data_size + + +def test_env_flag_truth_table(monkeypatch) -> None: + monkeypatch.setenv("AIOQUIC_FLAG", "true") + assert env_flag("AIOQUIC_FLAG") is True + monkeypatch.setenv("AIOQUIC_FLAG", "On") + assert env_flag("AIOQUIC_FLAG") is True + monkeypatch.setenv("AIOQUIC_FLAG", "0") + assert env_flag("AIOQUIC_FLAG") is False + + +def test_quic_varint_encode_matches_rfc_examples() -> None: + assert quic_varint_encode(0) == b"\x00" + assert quic_varint_encode(63) == b"\x3f" + assert quic_varint_encode(64) == b"\x40\x40" + assert quic_varint_encode(15293) == bytes.fromhex("7bbd") + assert quic_varint_encode(494878333) == bytes.fromhex("9d7f3e7d") + assert quic_varint_encode(151288809941952652) == bytes.fromhex("c2197c5eff14e88c") + + +def test_encode_goaway_frame_includes_http3_frame_length() -> None: + assert encode_goaway_frame(0) == bytes.fromhex("070100") + assert encode_goaway_frame(4) == bytes.fromhex("070104") + + +def test_header_helpers_normalize_byte_pairs() -> None: + headers = [(b":status", b"200"), (b"server", b"tigrcorn")] + assert header_pairs_to_text(headers) == [(":status", "200"), ("server", "tigrcorn")] + assert header_map(headers) == {":status": "200", "server": "tigrcorn"} + + +def test_path_status_and_certificate_input_status_report_local_files(tmp_path) -> None: + cacert = tmp_path / "ca.pem" + client_cert = tmp_path / "client.pem" + client_key = tmp_path / "client.key" + cacert.write_text("ca", encoding="utf-8") + client_cert.write_text("cert", encoding="utf-8") + client_key.write_text("key", encoding="utf-8") + + assert path_status(cacert)["exists"] is True + assert path_status(None) == {"path": None, "exists": False, "is_file": False} + + status = certificate_input_status(cacert=cacert, client_cert=client_cert, client_key=client_key) + assert status["ready"] is True + assert status["client_material_requested"] is True + assert status["client_material_ready"] is True + assert status["ca_cert"]["path"] == str(cacert) + + +def test_http3_snapshot_helpers_detect_control_and_qpack_streams() -> None: + dummy = _DummyH3() + assert detect_local_control_stream_id(dummy) == 2 + assert detect_peer_qpack_streams(dummy) == (True, True) + assert received_settings(dummy) == {0x08: 1} + + +def test_detect_retry_observed_scans_common_aioquic_state() -> None: + assert detect_retry_observed(_DummyQuicRetry()) is True + assert detect_retry_observed(object()) is False + + +def test_session_ticket_allows_early_data_for_object_and_mapping() -> None: + assert session_ticket_allows_early_data(_DummySessionTicket(16384)) is True + assert session_ticket_allows_early_data(_DummySessionTicket(0)) is False + assert session_ticket_allows_early_data({"max_early_data_size": 8192}) is True + assert session_ticket_allows_early_data({"max_early_data_size": 0}) is False + assert session_ticket_allows_early_data({"ticket": "opaque"}) is True + assert session_ticket_allows_early_data(None) is False diff --git a/tests/test_aioquic_adapter_preflight_pytest.py b/tests/test_aioquic_adapter_preflight_pytest.py new file mode 100644 index 00000000..431f5a15 --- /dev/null +++ b/tests/test_aioquic_adapter_preflight_pytest.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +BUNDLE_ROOT = RELEASE_ROOT / 'tigrcorn-aioquic-adapter-preflight-bundle' + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + + +def test_aioquic_preflight_docs_bundle_and_notes_exist() -> None: + assert (CONFORMANCE / 'AIOQUIC_ADAPTER_PREFLIGHT.md').exists() + assert (CONFORMANCE / 'aioquic_adapter_preflight.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_AIOQUIC_ADAPTER_PREFLIGHT.md').exists() + assert BUNDLE_ROOT.exists() + assert (ROOT / 'tools' / 'preflight_aioquic_adapters.py').exists() + + manifest = _load_json(RELEASE_ROOT / 'manifest.json') + assert 'aioquic_adapter_preflight' in manifest['bundles'] + assert manifest['bundles']['aioquic_adapter_preflight']['path'] == str(BUNDLE_ROOT.relative_to(ROOT)) + + bundle_index = _load_json(BUNDLE_ROOT / 'index.json') + status = _load_json(CONFORMANCE / 'aioquic_adapter_preflight.current.json') + assert bundle_index['artifact_root'] == status['current_state']['bundle_root'] + assert bundle_index['all_adapters_passed'] == status['current_state']['all_adapters_passed'] + assert bundle_index['all_protocols_h3'] == status['current_state']['all_protocols_h3'] + + +def test_aioquic_preflight_bundle_preserves_two_direct_adapter_runs() -> None: + index = _load_json(BUNDLE_ROOT / 'index.json') + assert index['scenario_count'] == 2 + assert index['all_adapters_passed'] is True + assert index['no_peer_exit_code_2'] is True + assert index['negotiation_metadata_emitted'] is True + assert index['all_protocols_h3'] is True + assert index['all_handshakes_complete'] is True + assert index['certificate_inputs_ready'] is True + + expected = { + 'http3-server-aioquic-client-post', + 'websocket-http3-server-aioquic-client', + } + assert set(index['scenario_ids']) == expected + + +def test_aioquic_preflight_scenario_metadata_records_certificate_and_handshake_state() -> None: + status = _load_json(CONFORMANCE / 'aioquic_adapter_preflight.current.json') + records = {item['scenario_id']: item for item in status['current_state']['scenario_records']} + + http3 = records['http3-server-aioquic-client-post'] + websocket = records['websocket-http3-server-aioquic-client'] + + for record in (http3, websocket): + assert record['passed'] is True + assert record['peer_exit_code'] == 0 + assert record['protocol'] == 'h3' + assert record['handshake_complete'] is True + assert record['ca_cert_path'] == 'tests/fixtures_certs/interop-localhost-cert.pem' + assert record['ca_cert_exists'] is True + assert record['certificate_inputs_ready'] is True + assert record['negotiation_metadata_emitted'] is True + assert record['transcript_emitted'] is True + assert record['packet_trace_exists'] is True + assert record['qlog_exists'] is True + + assert http3['peer_module'] == 'tests.fixtures_third_party.aioquic_http3_client' + assert websocket['peer_module'] == 'tests.fixtures_third_party.aioquic_http3_websocket_client' + assert websocket['websocket_connect_protocol_enabled'] is True + + +def test_release_workflow_and_wrapper_require_aioquic_preflight_before_phase9_scripts() -> None: + workflow = (ROOT / '.github' / 'workflows' / 'phase9-certification-release.yml').read_text(encoding='utf-8') + wrapper = (ROOT / 'tools' / 'run_phase9_release_workflow.py').read_text(encoding='utf-8') + assert 'tools/preflight_aioquic_adapters.py' in workflow + assert '--require-pass' in workflow + assert 'preflight_aioquic_adapters.py' in wrapper + assert '--require-pass' in wrapper diff --git a/tests/test_app_loader_pytest.py b/tests/test_app_loader_pytest.py new file mode 100644 index 00000000..803244ab --- /dev/null +++ b/tests/test_app_loader_pytest.py @@ -0,0 +1,65 @@ +import os +import sys +import tempfile + +import pytest + +from tigrcorn.errors import AppLoadError +from tigrcorn.server.app_loader import load_app +from tigrcorn.utils.imports import import_from_string + + +def test_import_from_string() -> None: + obj = import_from_string("tests.fixtures_pkg.appmod:app") + assert callable(obj) + + +def test_load_app() -> None: + app = load_app("tests.fixtures_pkg.appmod:app") + assert callable(app) + + +def test_load_factory() -> None: + app = load_app("tests.fixtures_pkg.appmod:factory", factory=True) + assert callable(app) + + +def test_bad_import_raises() -> None: + with pytest.raises(AppLoadError): + load_app("tests.fixtures_pkg.appmod:missing") + + +def test_load_app_from_current_working_directory_without_app_dir() -> None: + with tempfile.TemporaryDirectory() as td: + app_path = os.path.join(td, "app.py") + with open(app_path, "w", encoding="utf-8") as handle: + handle.write("async def app(scope, receive, send):\n return None\n") + previous = os.getcwd() + original_sys_path = list(sys.path) + try: + os.chdir(td) + sys.path[:] = [entry for entry in sys.path if entry not in ("", td)] + loaded = load_app("app:app") + assert callable(loaded) + finally: + sys.modules.pop("app", None) + sys.path[:] = original_sys_path + os.chdir(previous) + + +def test_load_factory_from_current_working_directory_without_app_dir() -> None: + with tempfile.TemporaryDirectory() as td: + app_path = os.path.join(td, "app.py") + with open(app_path, "w", encoding="utf-8") as handle: + handle.write("def factory():\n async def app(scope, receive, send):\n return None\n return app\n") + previous = os.getcwd() + original_sys_path = list(sys.path) + try: + os.chdir(td) + sys.path[:] = [entry for entry in sys.path if entry not in ("", td)] + loaded = load_app("app:factory", factory=True) + assert callable(loaded) + finally: + sys.modules.pop("app", None) + sys.path[:] = original_sys_path + os.chdir(previous) diff --git a/tests/test_certification_environment_freeze_pytest.py b/tests/test_certification_environment_freeze_pytest.py new file mode 100644 index 00000000..30fd3802 --- /dev/null +++ b/tests/test_certification_environment_freeze_pytest.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from tigrcorn.compat.certification_env import ( + CertificationEnvironmentError, + build_certification_environment_snapshot, + write_certification_environment_bundle, +) + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +BUNDLE_ROOT = RELEASE_ROOT / 'tigrcorn-certification-environment-bundle' + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + + +def test_certification_environment_docs_bundle_and_workflow_exist() -> None: + assert (CONFORMANCE / 'CERTIFICATION_ENVIRONMENT_FREEZE.md').exists() + assert (CONFORMANCE / 'certification_environment_freeze.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_CERTIFICATION_ENVIRONMENT_FREEZE.md').exists() + assert BUNDLE_ROOT.exists() + assert (ROOT / '.github' / 'workflows' / 'phase9-certification-release.yml').exists() + assert (ROOT / 'tools' / 'run_phase9_release_workflow.py').exists() + + manifest = _load_json(RELEASE_ROOT / 'manifest.json') + assert 'certification_environment' in manifest['bundles'] + assert manifest['bundles']['certification_environment']['path'] == str(BUNDLE_ROOT.relative_to(ROOT)) + + bundle_index = _load_json(BUNDLE_ROOT / 'index.json') + status = _load_json(CONFORMANCE / 'certification_environment_freeze.current.json') + assert bundle_index['artifact_root'] == status['current_state']['bundle_root'] + assert bundle_index['workflow_path'] == status['current_state']['workflow_path'] + assert bundle_index['wrapper_path'] == status['current_state']['wrapper_path'] + assert bundle_index['required_imports_ready'] == status['current_state']['required_imports_ready'] + assert bundle_index['python_version_ready'] == status['current_state']['python_version_ready'] + + +def test_certification_environment_snapshot_builder_records_contract() -> None: + snapshot = build_certification_environment_snapshot(ROOT) + assert snapshot['installation_contract']['install_command'] == 'python -m pip install -e ".[certification,dev]"' + assert snapshot['installation_contract']['required_extras'] == ['certification', 'dev'] + assert snapshot['validation']['required_imports'] == ['aioquic', 'h2', 'websockets', 'wsproto'] + assert snapshot['release_workflow']['workflow_path'] == '.github/workflows/phase9-certification-release.yml' + assert snapshot['release_workflow']['wrapper_path'] == 'tools/run_phase9_release_workflow.py' + + +def test_certification_environment_bundle_writer_supports_non_ready_environments() -> None: + with tempfile.TemporaryDirectory() as tmpdir: + target = Path(tmpdir) / 'bundle' + snapshot = write_certification_environment_bundle( + ROOT, + bundle_root=target, + workflow_path='.github/workflows/phase9-certification-release.yml', + wrapper_path='tools/run_phase9_release_workflow.py', + require_ready=False, + ) + assert (target / 'environment.json').exists() + assert (target / 'manifest.json').exists() + assert (target / 'index.json').exists() + assert (target / 'summary.json').exists() + assert (target / 'bootstrap.sh').exists() + index = _load_json(target / 'index.json') + assert index['artifact_root'].endswith('bundle') + assert index['required_imports_ready'] == snapshot['validation']['required_imports_ready'] + assert index['python_version_ready'] == snapshot['validation']['python_version_ready'] + + +def test_certification_environment_bundle_writer_strict_mode_tracks_readiness() -> None: + with tempfile.TemporaryDirectory() as tmpdir: + target = Path(tmpdir) / 'bundle' + snapshot = build_certification_environment_snapshot(ROOT) + if snapshot['validation']['environment_ready_for_release_workflow']: + write_certification_environment_bundle(ROOT, bundle_root=target, require_ready=True) + assert (target / 'environment.json').exists() + else: + with pytest.raises(CertificationEnvironmentError): + write_certification_environment_bundle(ROOT, bundle_root=target, require_ready=True) + + +def test_release_workflow_and_wrapper_enforce_freeze_before_phase9_scripts() -> None: + workflow = (ROOT / '.github' / 'workflows' / 'phase9-certification-release.yml').read_text(encoding='utf-8') + wrapper = (ROOT / 'tools' / 'run_phase9_release_workflow.py').read_text(encoding='utf-8') + assert 'pip install -e ".[certification,dev]"' in workflow + assert 'tools/freeze_certification_environment.py' in workflow + assert '--require-imports' in workflow + assert 'freeze_certification_environment.py' in wrapper + assert '--require-imports' in wrapper + assert 'tools/create_phase9i_release_assembly_checkpoint.py' in wrapper diff --git a/tests/test_certification_policy_alignment_pytest.py b/tests/test_certification_policy_alignment_pytest.py new file mode 100644 index 00000000..52a1dd45 --- /dev/null +++ b/tests/test_certification_policy_alignment_pytest.py @@ -0,0 +1,32 @@ +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BOUNDARY_DOC = "docs/review/conformance/CERTIFICATION_BOUNDARY.md" +BOUNDARY_JSON = "docs/review/conformance/certification_boundary.json" +POLICY_DOC = "docs/review/conformance/CERTIFICATION_POLICY_ALIGNMENT.md" + + +def test_boundary_claim_is_per_rfc_and_authoritative() -> None: + payload = json.loads((ROOT / BOUNDARY_JSON).read_text(encoding="utf-8")) + claim = payload["claim"] + assert "required evidence tier declared per RFC" in claim + + +def test_policy_docs_name_local_tier_rfcs_explicitly() -> None: + text = (ROOT / POLICY_DOC).read_text(encoding="utf-8") + assert BOUNDARY_DOC in text + for needle in ( + "RFC 7692", + "RFC 9110 §9.3.6", + "RFC 9110 §6.5", + "RFC 9110 §8", + "RFC 7232", + "RFC 7233", + "RFC 8297", + "RFC 7838 §3", + "RFC 6960", + ): + assert needle in text + assert "certifiably fully RFC compliant" in text diff --git a/tests/test_cli_and_asgi3_pytest.py b/tests/test_cli_and_asgi3_pytest.py new file mode 100644 index 00000000..061be844 --- /dev/null +++ b/tests/test_cli_and_asgi3_pytest.py @@ -0,0 +1,32 @@ +from tigrcorn.cli import build_parser +from tigrcorn.compat.asgi3 import assert_asgi3_app, describe_app, is_http_event, is_http_scope, is_lifespan_scope, is_websocket_scope + + +def test_parser() -> None: + parser = build_parser() + ns = parser.parse_args([ + "tests.fixtures_pkg.appmod:app", + "--transport", "udp", + "--protocol", "http3", + "--http", "3", + "--ssl-ca-certs", "ca.pem", + "--ssl-require-client-cert", + ]) + assert ns.transport == "udp" + assert ns.protocols == ["http3"] + assert ns.http_versions == ["3"] + assert ns.ssl_ca_certs == "ca.pem" + assert ns.ssl_require_client_cert + + +def test_asgi3_helpers() -> None: + async def app(scope, receive, send): + return None + + info = describe_app(app) + assert info.parameter_count == 3 + assert_asgi3_app(app) + assert is_http_scope({"type": "http"}) + assert is_websocket_scope({"type": "websocket"}) + assert is_lifespan_scope({"type": "lifespan"}) + assert is_http_event({"type": "http.request"}) diff --git a/tests/test_compression_additional_pytest.py b/tests/test_compression_additional_pytest.py new file mode 100644 index 00000000..ea8745d6 --- /dev/null +++ b/tests/test_compression_additional_pytest.py @@ -0,0 +1,81 @@ +import pytest + +from tigrcorn.errors import ProtocolError +from tigrcorn.protocols.http2.hpack import HPACKDecoder, HPACKEncoder, decode_string, encode_string +from tigrcorn.protocols.http3.qpack import QpackBlocked, QpackDecoder, QpackEncoder +from tigrcorn.protocols.http3.streams import HTTP3ConnectionCore, STREAM_TYPE_QPACK_ENCODER +from tigrcorn.utils.bytes import encode_quic_varint + + +def test_huffman_string_roundtrip() -> None: + encoded = encode_string(b"custom-header-value", huffman=True) + assert encoded[0] & 0x80 + decoded, offset = decode_string(encoded, 0) + assert decoded == b"custom-header-value" + assert offset == len(encoded) + + +def test_dynamic_table_reuse_and_table_size_update() -> None: + encoder = HPACKEncoder(max_table_size=128) + decoder = HPACKDecoder(max_table_size=128) + headers = [(b"x-example", b"first"), (b"cache-control", b"no-cache")] + block1 = encoder.encode_header_block(headers) + assert decoder.decode_header_block(block1) == headers + block2 = encoder.encode_header_block(headers) + assert decoder.decode_header_block(block2) == headers + assert len(block2) < len(block1) + + encoder.set_max_table_size(64) + block3 = encoder.encode_header_block([(b"x-example", b"second")]) + assert decoder.decode_header_block(block3) == [(b"x-example", b"second")] + assert decoder.dynamic_table.max_size == 64 + + +def test_dynamic_table_roundtrip_and_blocking() -> None: + encoder = QpackEncoder(max_table_capacity=256, blocked_streams=8) + decoder = QpackDecoder(max_table_capacity=256, blocked_streams=8) + headers = [(b":method", b"GET"), (b"x-demo", b"value")] + field_section = encoder.encode_field_section(headers, stream_id=0) + with pytest.raises(QpackBlocked): + decoder.decode_field_section(field_section, stream_id=0) + encoder_stream = encoder.take_encoder_stream_data() + assert encoder_stream + decoder.receive_encoder_stream(encoder_stream) + assert decoder.decode_field_section(field_section, stream_id=0).headers == headers + decoder_stream = decoder.take_decoder_stream_data() + assert decoder_stream + encoder.receive_decoder_stream(decoder_stream) + + +def test_http3_core_unblocks_request_after_qpack_encoder_stream() -> None: + sender = HTTP3ConnectionCore() + receiver = HTTP3ConnectionCore() + receiver_settings = receiver.encode_control_stream({1: 256, 6: 1200, 7: 16}) + assert sender.receive_stream_data(3, receiver_settings, fin=False) is None + + payload = sender.get_request(0).encode_request( + [(b":method", b"GET"), (b":path", b"/"), (b":scheme", b"https"), (b"x-demo", b"value")], + b"", + ) + request_state = receiver.receive_stream_data(0, payload, fin=True) + assert request_state is not None + assert not request_state.ready + assert request_state.blocked_header_sections != [] + + encoder_stream = sender.take_encoder_stream_data() + assert encoder_stream + assert receiver.receive_stream_data( + 2, encode_quic_varint(STREAM_TYPE_QPACK_ENCODER) + encoder_stream, fin=False + ) is None + request_state = receiver.get_request(0).state + assert request_state.ready + assert (b"x-demo", b"value") in request_state.headers + assert receiver.take_decoder_stream_data() + + +def test_duplicate_qpack_encoder_stream_rejected() -> None: + core = HTTP3ConnectionCore() + core.encode_control_stream({1: 16, 6: 1200, 7: 1}) + assert core.receive_stream_data(2, encode_quic_varint(STREAM_TYPE_QPACK_ENCODER), fin=False) is None + with pytest.raises(ProtocolError): + core.receive_stream_data(6, encode_quic_varint(STREAM_TYPE_QPACK_ENCODER), fin=False) diff --git a/tests/test_config_matrix_pytest.py b/tests/test_config_matrix_pytest.py new file mode 100644 index 00000000..7702d715 --- /dev/null +++ b/tests/test_config_matrix_pytest.py @@ -0,0 +1,116 @@ +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.config.model import ListenerConfig, ServerConfig +from tigrcorn.config.validate import validate_config +from tigrcorn.errors import ConfigError + + +def test_tcp_defaults() -> None: + config = build_config(host="127.0.0.1", port=1234, lifespan="off") + listener = config.listeners[0] + assert listener.kind == "tcp" + assert "http1" in listener.enabled_protocols + assert "websocket" in listener.enabled_protocols + + +def test_udp_http3_defaults() -> None: + config = build_config( + transport="udp", + host="127.0.0.1", + port=9443, + lifespan="off", + http_versions=["3"], + protocols=["http3"], + ) + listener = config.listeners[0] + assert listener.kind == "udp" + assert listener.scheme == "https" + assert listener.enabled_protocols[:2] == ("quic", "http3") + + +def test_pipe_label() -> None: + listener = ListenerConfig(kind="pipe", path="/tmp/tigrcorn.pipe") + assert listener.label == "pipe:///tmp/tigrcorn.pipe" + assert listener.enabled_protocols == ("rawframed",) + + +def test_udp_ssl_is_accepted_for_quic_tls() -> None: + config = ServerConfig( + listeners=[ListenerConfig(kind="udp", host="127.0.0.1", port=1, ssl_certfile="x", ssl_keyfile="y")] + ) + validate_config(config) + + +def test_udp_client_auth_is_accepted_with_a_trust_store() -> None: + config = ServerConfig( + listeners=[ + ListenerConfig( + kind="udp", + host="127.0.0.1", + port=1, + ssl_certfile="x", + ssl_keyfile="y", + ssl_ca_certs="ca.pem", + ssl_require_client_cert=True, + ) + ] + ) + validate_config(config) + + +def test_udp_client_auth_requires_an_explicit_trust_store() -> None: + config = ServerConfig( + listeners=[ + ListenerConfig( + kind="udp", + host="127.0.0.1", + port=1, + ssl_certfile="x", + ssl_keyfile="y", + ssl_require_client_cert=True, + ) + ] + ) + with pytest.raises(ConfigError): + validate_config(config) + + +def test_tcp_client_auth_is_accepted_with_a_trust_store() -> None: + config = ServerConfig( + listeners=[ + ListenerConfig( + kind="tcp", + host="127.0.0.1", + port=1, + ssl_certfile="x", + ssl_keyfile="y", + ssl_ca_certs="ca.pem", + ssl_require_client_cert=True, + ) + ] + ) + validate_config(config) + + +def test_tcp_client_auth_requires_an_explicit_trust_store() -> None: + config = ServerConfig( + listeners=[ + ListenerConfig( + kind="tcp", + host="127.0.0.1", + port=1, + ssl_certfile="x", + ssl_keyfile="y", + ssl_require_client_cert=True, + ) + ] + ) + with pytest.raises(ConfigError): + validate_config(config) + + +def test_invalid_pipe_requires_path() -> None: + config = ServerConfig(listeners=[ListenerConfig(kind="pipe", path=None)]) + with pytest.raises(ConfigError): + validate_config(config) diff --git a/tests/test_conformance_corpus_pytest.py b/tests/test_conformance_corpus_pytest.py new file mode 100644 index 00000000..a4488e01 --- /dev/null +++ b/tests/test_conformance_corpus_pytest.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from tigrcorn.compat.interop import load_vectors + + +ROOT = Path(__file__).resolve().parents[1] +CORPUS_PATH = ROOT / "docs/review/conformance/corpus.json" +RFC_REVIEW_PATH = ROOT / "docs/review/rfc_compliance_review.md" +EXPECTED_VECTOR_CATALOG = { + "7541": {"name": "hpack-dynamic-state", "protocol": "http2-hpack", "fixture": "tests/test_http2_hpack.py"}, + "7692": {"name": "websocket-permessage-deflate", "protocol": "websocket-compression", "fixture": "tests/test_websocket_rfc7692.py"}, + "8441": {"name": "http2-websocket-extended-connect", "protocol": "http2-websocket", "fixture": "tests/test_http2_websocket_rfc8441.py"}, + "8446": {"name": "tls13-package-subsystem", "protocol": "tls13", "fixture": "tests/test_tls13_engine_upgrade.py"}, + "6455": {"name": "websocket-core", "protocol": "websocket", "fixture": "tests/test_websocket_rfc6455.py"}, + "9000": {"name": "quic-packet-codec", "protocol": "quic-transport", "fixture": "tests/test_quic_packets_rfc9000.py"}, + "9001": {"name": "quic-tls-initial-vectors", "protocol": "quic-tls", "fixture": "tests/test_quic_tls_rfc9001.py"}, + "9002": {"name": "quic-recovery", "protocol": "quic-recovery", "fixture": "tests/test_quic_recovery_rfc9002.py"}, + "9110-connect": {"name": "http-connect-relay", "protocol": "http-connect", "fixture": "tests/test_connect_rfc9110.py"}, + "9110-trailers": {"name": "http-trailer-fields", "protocol": "http-trailers", "fixture": "tests/test_trailers_rfc9110.py"}, + "9110-content-coding": {"name": "http-content-coding", "protocol": "http-content-coding", "fixture": "tests/test_http_content_coding_rfc9110.py"}, + "7232": {"name": "http-conditional-requests", "protocol": "http-conditional", "fixture": "tests/test_rfc7232_conditional_requests.py"}, + "7233": {"name": "http-byte-ranges", "protocol": "http-range", "fixture": "tests/test_rfc7233_range_requests.py"}, + "8297": {"name": "http-early-hints", "protocol": "http-early-hints", "fixture": "tests/test_rfc8297_early_hints.py"}, + "RFC 7838 §3": {"name": "http-alt-svc-header-advertisement", "protocol": "http-alt-svc", "fixture": "tests/test_rfc7838_alt_svc.py"}, + "9112": {"name": "http11-server-surface", "protocol": "http1", "fixture": "tests/test_http1_rfc9112.py"}, + "9113": {"name": "http2-server-surface", "protocol": "http2", "fixture": "tests/test_http2_rfc9113.py"}, + "9114": {"name": "http3-server-surface", "protocol": "http3", "fixture": "tests/test_http3_rfc9114.py"}, + "9204": {"name": "qpack-dynamic-state", "protocol": "http3-qpack", "fixture": "tests/test_qpack_completion.py"}, + "9220": {"name": "http3-websocket-extended-connect", "protocol": "http3-websocket", "fixture": "tests/test_http3_websocket_rfc9220.py"}, + "5280": {"name": "x509-path-validation", "protocol": "x509-validation", "fixture": "tests/test_x509_webpki_validation.py"}, + "6960": {"name": "ocsp-revocation-validation", "protocol": "x509-revocation", "fixture": "tests/test_x509_webpki_validation.py"}, + "7301": {"name": "tls-alpn-negotiation", "protocol": "tls13-alpn", "fixture": "tests/test_tls_alpn_rfc7301.py"}, +} +DOCUMENTED_RFC_EXTRAS = {"6455", "9000", "9001"} + + +def _load_corpus(): + return load_vectors(CORPUS_PATH) + + +def _base_rfc(rfc: str) -> str: + return rfc.split("-", 1)[0] + + +def test_corpus_matches_expected_vector_catalog() -> None: + vectors = _load_corpus() + assert len(vectors) == len(EXPECTED_VECTOR_CATALOG) + seen_vector_ids: set[str] = set() + for vector in vectors: + assert vector.rfc not in seen_vector_ids, vector.rfc + seen_vector_ids.add(vector.rfc) + assert vector.rfc in EXPECTED_VECTOR_CATALOG + expected = EXPECTED_VECTOR_CATALOG[vector.rfc] + assert vector.name == expected["name"] + assert vector.protocol == expected["protocol"] + assert vector.fixture == expected["fixture"] + assert len(vector.description.split()) >= 8, vector.rfc + assert seen_vector_ids == set(EXPECTED_VECTOR_CATALOG) + + +def test_documented_review_surface_is_a_subset_of_the_corpus() -> None: + vectors = _load_corpus() + corpus_rfcs = {_base_rfc(vector.rfc) for vector in vectors} + documented_rfcs = set(re.findall(r"RFC\s+(\d+)", RFC_REVIEW_PATH.read_text())) + required_rfcs = documented_rfcs | DOCUMENTED_RFC_EXTRAS + assert not (required_rfcs - corpus_rfcs), sorted(required_rfcs - corpus_rfcs) + + +def test_vector_names_and_protocol_rfc_pairs_are_unique() -> None: + vectors = _load_corpus() + assert len({vector.name for vector in vectors}) == len(vectors) + assert len({(vector.protocol, vector.rfc) for vector in vectors}) == len(vectors) + + +def test_every_vector_points_to_a_real_test_fixture() -> None: + vectors = _load_corpus() + for vector in vectors: + fixture = ROOT / vector.fixture + assert fixture.exists(), vector.fixture + contents = fixture.read_text() + assert ("def test_" in contents or "class " in contents), vector.fixture diff --git a/tests/test_connect_rfc9110_pytest.py b/tests/test_connect_rfc9110_pytest.py new file mode 100644 index 00000000..5f0ea52f --- /dev/null +++ b/tests/test_connect_rfc9110_pytest.py @@ -0,0 +1,196 @@ +import asyncio +import socket + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, serialize_settings +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.http3.codec import FRAME_DATA as H3_FRAME_DATA, encode_frame as encode_h3_frame +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server(*, http_versions: list[str], transport: str = "tcp"): + async def app(scope, receive, send): + raise AssertionError("CONNECT tunneling should not dispatch to the ASGI app") + + kwargs = {"host": "127.0.0.1", "port": 0, "lifespan": "off", "http_versions": http_versions} + if transport == "udp": + kwargs.update({"transport": "udp", "protocols": ["http3"], "quic_secret": b"shared"}) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == "udp": + port = server._listeners[0].transport.get_extra_info("sockname")[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +@pytest.mark.asyncio +async def test_http11_connect_relays_bidirectionally() -> None: + received = bytearray() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + data = await reader.read(1024) + received.extend(data) + writer.write(data[::-1]) + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + server, port = await _start_server(http_versions=["1.1"]) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(f"CONNECT 127.0.0.1:{upstream_port} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode("ascii")) + await writer.drain() + head = await reader.readuntil(b"\r\n\r\n") + assert b"200 Connection Established" in head + writer.write(b"abcdef") + await writer.drain() + echoed = await asyncio.wait_for(reader.readexactly(6), 1.0) + assert echoed == b"fedcba" + assert bytes(received) == b"abcdef" + writer.close() + await writer.wait_closed() + finally: + server.request_shutdown() + await server.close() + upstream.close() + await upstream.wait_closed() + + +@pytest.mark.asyncio +async def test_http2_connect_relays_bidirectionally() -> None: + received = bytearray() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + data = await reader.read(1024) + received.extend(data) + writer.write(data[::-1]) + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + server, port = await _start_server(http_versions=["2"]) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + frame_writer = FrameWriter() + request_headers = encode_header_block( + [ + (b":method", b"CONNECT"), + (b":authority", f"127.0.0.1:{upstream_port}".encode("ascii")), + ] + ) + writer.write(frame_writer.headers(1, request_headers, end_stream=False)) + await writer.drain() + + buf = FrameBuffer() + response_headers: list[tuple[bytes, bytes]] = [] + while not response_headers: + data = await asyncio.wait_for(reader.read(65535), 1.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + continue + if frame.frame_type == FRAME_HEADERS and frame.stream_id == 1: + response_headers.extend(decode_header_block(frame.payload)) + break + assert (b":status", b"200") in response_headers + writer.write(frame_writer.data(1, b"abcdef", end_stream=True)) + await writer.drain() + + echoed = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 1.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_DATA and frame.stream_id == 1: + echoed.extend(frame.payload) + if frame.flags & 0x1: + ended = True + assert bytes(echoed) == b"fedcba" + assert bytes(received) == b"abcdef" + writer.close() + await writer.wait_closed() + finally: + await server.close() + upstream.close() + await upstream.wait_closed() + + +@pytest.mark.asyncio +async def test_http3_connect_relays_bidirectionally() -> None: + received = bytearray() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + data = await reader.read(1024) + received.extend(data) + writer.write(data[::-1]) + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + server, port = await _start_server(http_versions=["3"], transport="udp") + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b"shared", local_cid=b"cli-connect") + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ("127.0.0.1", port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + connect_payload = core.get_request(0).encode_request( + [ + (b":method", b"CONNECT"), + (b":authority", f"127.0.0.1:{upstream_port}".encode("ascii")), + ] + ) + sock.sendto(client.send_stream_data(0, connect_payload, fin=False), ("127.0.0.1", port)) + + response_state = None + while response_state is None or (b":status", b"200") not in response_state.headers: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if event.stream_id == 0: + response_state = state + assert response_state is not None + assert (b":status", b"200") in response_state.headers + + tunnel_payload = encode_h3_frame(H3_FRAME_DATA, b"abcdef") + sock.sendto(client.send_stream_data(0, tunnel_payload, fin=True), ("127.0.0.1", port)) + + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream" and event.stream_id == 0: + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + assert response_state is not None + assert response_state.body == b"fedcba" + assert bytes(received) == b"abcdef" + finally: + sock.close() + await server.close() + upstream.close() + await upstream.wait_closed() diff --git a/tests/test_connect_tunnel_h2_h3_pytest.py b/tests/test_connect_tunnel_h2_h3_pytest.py new file mode 100644 index 00000000..bbb80603 --- /dev/null +++ b/tests/test_connect_tunnel_h2_h3_pytest.py @@ -0,0 +1,184 @@ +import asyncio +import socket + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import ( + FRAME_DATA, + FRAME_HEADERS, + FRAME_SETTINGS, + FrameBuffer, + FrameWriter, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.http3.codec import FRAME_DATA as H3_FRAME_DATA, encode_frame as encode_h3_frame +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_h2_server(app): + config = build_config(host="127.0.0.1", port=0, lifespan="off", http_versions=["2"]) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _start_h3_server(app): + config = build_config( + transport="udp", + host="127.0.0.1", + port=0, + lifespan="off", + http_versions=["3"], + protocols=["http3"], + quic_secret=b"shared", + ) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].transport.get_extra_info("sockname")[1] + return server, port + + +@pytest.mark.asyncio +async def test_http2_connect_relays_bidirectionally() -> None: + received = bytearray() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + data = await reader.read(1024) + received.extend(data) + writer.write(data[::-1]) + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + + async def app(scope, receive, send): + raise AssertionError("HTTP/2 CONNECT should not dispatch to the ASGI app") + + server, port = await _start_h2_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + frame_writer = FrameWriter() + request_headers = encode_header_block( + [ + (b":method", b"CONNECT"), + (b":authority", f"127.0.0.1:{upstream_port}".encode("ascii")), + ] + ) + writer.write(frame_writer.headers(1, request_headers, end_stream=False)) + await writer.drain() + + buf = FrameBuffer() + response_headers: list[tuple[bytes, bytes]] = [] + while not response_headers: + data = await asyncio.wait_for(reader.read(65535), 1.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + continue + if frame.frame_type == FRAME_HEADERS and frame.stream_id == 1: + response_headers.extend(decode_header_block(frame.payload)) + break + assert (b":status", b"200") in response_headers + + writer.write(frame_writer.data(1, b"abcdef", end_stream=True)) + await writer.drain() + + echoed = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 1.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_DATA and frame.stream_id == 1: + echoed.extend(frame.payload) + if frame.flags & 0x1: + ended = True + assert bytes(echoed) == b"fedcba" + assert bytes(received) == b"abcdef" + writer.close() + await writer.wait_closed() + finally: + await server.close() + upstream.close() + await upstream.wait_closed() + + +@pytest.mark.asyncio +async def test_http3_connect_relays_bidirectionally() -> None: + received = bytearray() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + data = await reader.read(1024) + received.extend(data) + writer.write(data[::-1]) + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + + async def app(scope, receive, send): + raise AssertionError("HTTP/3 CONNECT should not dispatch to the ASGI app") + + server, port = await _start_h3_server(app) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b"shared", local_cid=b"cli1") + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ("127.0.0.1", port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + connect_payload = core.get_request(0).encode_request( + [ + (b":method", b"CONNECT"), + (b":authority", f"127.0.0.1:{upstream_port}".encode("ascii")), + ] + ) + sock.sendto(client.send_stream_data(0, connect_payload, fin=False), ("127.0.0.1", port)) + + response_state = None + while response_state is None or (b":status", b"200") not in response_state.headers: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if event.stream_id == 0: + response_state = state + assert response_state is not None + assert (b":status", b"200") in response_state.headers + + tunnel_payload = encode_h3_frame(H3_FRAME_DATA, b"abcdef") + sock.sendto(client.send_stream_data(0, tunnel_payload, fin=True), ("127.0.0.1", port)) + + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream" and event.stream_id == 0: + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + assert response_state is not None + assert response_state.body == b"fedcba" + assert bytes(received) == b"abcdef" + finally: + sock.close() + await server.close() + upstream.close() + await upstream.wait_closed() diff --git a/tests/test_content_coding_policy_local_pytest.py b/tests/test_content_coding_policy_local_pytest.py new file mode 100644 index 00000000..c8de7ff8 --- /dev/null +++ b/tests/test_content_coding_policy_local_pytest.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import ( + FRAME_DATA, + FRAME_HEADERS, + FRAME_SETTINGS, + FrameBuffer, + FrameWriter, + decode_settings, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server(*, http_versions: list[str], policy: str, transport: str = "tcp"): + async def app(scope, receive, send): + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain; charset=utf-8")], + } + ) + await send({"type": "http.response.body", "body": b"compress-me", "more_body": False}) + + kwargs = { + "host": "127.0.0.1", + "port": 0, + "lifespan": "off", + "http_versions": http_versions, + "config": {"http": {"content_coding_policy": policy}}, + } + if transport == "udp": + kwargs.update({"transport": "udp", "protocols": ["http3"], "quic_secret": b"shared"}) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == "udp": + port = server._listeners[0].transport.get_extra_info("sockname")[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _read_http1_response(reader: asyncio.StreamReader) -> tuple[bytes, bytes]: + head = await reader.readuntil(b"\r\n\r\n") + headers = {} + for line in head.split(b"\r\n")[1:]: + if not line: + continue + name, value = line.split(b":", 1) + headers[name.strip().lower()] = value.strip() + length = int(headers.get(b"content-length", b"0")) + body = await reader.readexactly(length) if length else b"" + return head, body + + +@pytest.mark.asyncio +async def test_http11_identity_only_forbidden_returns_406() -> None: + server, port = await _start_server(http_versions=["1.1"], policy="identity-only") + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"GET / HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Accept-Encoding: identity;q=0,*;q=0\r\n\r\n" + ) + await writer.drain() + head, body = await _read_http1_response(reader) + assert b" 406 " in head + assert b"vary: accept-encoding" in head.lower() + assert body == b"not acceptable" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http11_strict_unsupported_encoding_returns_406() -> None: + server, port = await _start_server(http_versions=["1.1"], policy="strict") + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"GET / HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Accept-Encoding: zstd\r\n\r\n" + ) + await writer.drain() + head, body = await _read_http1_response(reader) + assert b" 406 " in head + assert b"vary: accept-encoding" in head.lower() + assert body == b"not acceptable" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_identity_only_forbidden_returns_406() -> None: + server, port = await _start_server(http_versions=["2"], policy="identity-only") + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + headers = encode_header_block( + [ + (b":method", b"GET"), + (b":scheme", b"http"), + (b":path", b"/"), + (b":authority", b"localhost"), + (b"accept-encoding", b"identity;q=0,*;q=0"), + ] + ) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, headers, end_stream=True)) + await writer.drain() + buf = FrameBuffer() + response_headers = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if response_headers and ended: + break + assert (b":status", b"406") in response_headers + assert (b"vary", b"accept-encoding") in response_headers + assert bytes(body) == b"not acceptable" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_strict_unsupported_encoding_returns_406() -> None: + server, port = await _start_server(http_versions=["2"], policy="strict") + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + headers = encode_header_block( + [ + (b":method", b"GET"), + (b":scheme", b"http"), + (b":path", b"/"), + (b":authority", b"localhost"), + (b"accept-encoding", b"zstd"), + ] + ) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, headers, end_stream=True)) + await writer.drain() + buf = FrameBuffer() + response_headers = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if response_headers and ended: + break + assert (b":status", b"406") in response_headers + assert (b"vary", b"accept-encoding") in response_headers + assert bytes(body) == b"not acceptable" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_identity_only_forbidden_returns_406() -> None: + server, port = await _start_server(http_versions=["3"], policy="identity-only", transport="udp") + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b"shared", local_cid=b"cli-identity") + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ("127.0.0.1", port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + request_payload = core.get_request(0).encode_request( + [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":path", b"/"), + (b":authority", b"localhost"), + (b"accept-encoding", b"identity;q=0,*;q=0"), + ] + ) + sock.sendto(client.send_stream_data(0, request_payload, fin=True), ("127.0.0.1", port)) + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream" and event.stream_id == 0: + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + assert response_state is not None + assert (b":status", b"406") in response_state.headers + assert (b"vary", b"accept-encoding") in response_state.headers + assert response_state.body == b"not acceptable" + finally: + sock.close() + await server.close() + + +@pytest.mark.asyncio +async def test_http3_strict_unsupported_encoding_returns_406() -> None: + server, port = await _start_server(http_versions=["3"], policy="strict", transport="udp") + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b"shared", local_cid=b"cli-strict") + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ("127.0.0.1", port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + request_payload = core.get_request(0).encode_request( + [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":path", b"/"), + (b":authority", b"localhost"), + (b"accept-encoding", b"zstd"), + ] + ) + sock.sendto(client.send_stream_data(0, request_payload, fin=True), ("127.0.0.1", port)) + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream" and event.stream_id == 0: + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + assert response_state is not None + assert (b":status", b"406") in response_state.headers + assert (b"vary", b"accept-encoding") in response_state.headers + assert response_state.body == b"not acceptable" + finally: + sock.close() + await server.close() diff --git a/tests/test_dependency_declaration_reconciliation_checkpoint_pytest.py b/tests/test_dependency_declaration_reconciliation_checkpoint_pytest.py new file mode 100644 index 00000000..bf081aec --- /dev/null +++ b/tests/test_dependency_declaration_reconciliation_checkpoint_pytest.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tigrcorn.config.files import ConfigFileError, load_config_file +from tigrcorn.protocols.content_coding import encode_content + +ROOT = Path(__file__).resolve().parents[1] + + +def test_pyproject_declares_optional_install_paths() -> None: + payload = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + extras = payload["project"]["optional-dependencies"] + + assert "config-yaml" in extras + assert any(dep.startswith("PyYAML") for dep in extras["config-yaml"]) + + assert "compression" in extras + assert any(dep.startswith("brotli") for dep in extras["compression"]) + + assert "runtime-uvloop" in extras + assert any(dep.startswith("uvloop") for dep in extras["runtime-uvloop"]) + + assert "runtime-trio" in extras + assert any(dep.startswith("trio") for dep in extras["runtime-trio"]) + + assert "full-featured" in extras + full_featured = extras["full-featured"] + assert any(dep.startswith("PyYAML") for dep in full_featured) + assert any(dep.startswith("brotli") for dep in full_featured) + assert any(dep.startswith("uvloop") for dep in full_featured) + assert not any(dep.startswith("trio") for dep in full_featured) + + dev = extras["dev"] + assert any(dep.startswith("pytest") for dep in dev) + assert any(dep.startswith("PyYAML") for dep in dev) + assert any(dep.startswith("brotli") for dep in dev) + assert any(dep.startswith("uvloop") for dep in dev) + + +def test_docs_reference_declared_optional_surfaces() -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + optional_doc = (ROOT / "docs/review/conformance/OPTIONAL_DEPENDENCY_SURFACE.md").read_text(encoding="utf-8") + docs_readme = (ROOT / "docs/review/conformance/README.md").read_text(encoding="utf-8") + pairing = (ROOT / "examples/PHASE4_PROTOCOL_PAIRING.md").read_text(encoding="utf-8") + + for token in ("config-yaml", "compression", "runtime-uvloop", "runtime-trio", "full-featured"): + assert token in readme + assert token in optional_doc + assert "OPTIONAL_DEPENDENCY_SURFACE.md" in docs_readme + assert "runtime `trio` is **not** part of the supported public runtime surface" in pairing + assert "surfaced-but-not-yet-wired execution mode" not in pairing + + +def test_optional_dependency_error_hints_point_to_declared_extras() -> None: + yaml_config = ROOT / "tests/fixtures_pkg/phase1_yaml_missing.yaml" + yaml_config.write_text("app:\n target: tests.fixtures_pkg.appmod:app\n", encoding="utf-8") + try: + with patch("tigrcorn.config.files.yaml", None): + with pytest.raises(ConfigFileError) as ctx: + load_config_file(yaml_config) + assert "tigrcorn[config-yaml]" in str(ctx.value) + finally: + yaml_config.unlink(missing_ok=True) + + with patch("tigrcorn.protocols.content_coding.brotli", None): + with pytest.raises(RuntimeError) as ctx: + encode_content("br", b"payload") + assert "tigrcorn[compression]" in str(ctx.value) + + bootstrap_source = (ROOT / "src/tigrcorn/server/bootstrap.py").read_text(encoding="utf-8") + assert "tigrcorn[runtime-uvloop]" in bootstrap_source diff --git a/tests/test_documentation_reconciliation_pytest.py b/tests/test_documentation_reconciliation_pytest.py new file mode 100644 index 00000000..5fef7af5 --- /dev/null +++ b/tests/test_documentation_reconciliation_pytest.py @@ -0,0 +1,88 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CANONICAL_RELEASE_ROOT = "docs/review/conformance/releases/0.3.9/release-0.3.9/" +BOUNDARY_DOC = "docs/review/conformance/CERTIFICATION_BOUNDARY.md" + + +def _read(relative_path: str) -> str: + return (ROOT / relative_path).read_text(encoding="utf-8") + + +def _assert_contains_all(text: str, *needles: str) -> None: + for needle in needles: + assert needle in text + + +def test_readme_references_canonical_boundary_release_root_and_current_state() -> None: + text = _read("README.md") + _assert_contains_all( + text, + BOUNDARY_DOC, + CANONICAL_RELEASE_ROOT, + "external_matrix.same_stack_replay.json", + "external_matrix.release.json", + "external_matrix.current_release.json", + "certifiably fully RFC compliant under the authoritative certification boundary", + "docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md", + ) + + +def test_http3_quic_and_websocket_docs_reference_boundary_and_current_blockers() -> None: + http3 = _read("docs/protocols/http3.md") + quic = _read("docs/protocols/quic.md") + websocket = _read("docs/protocols/websocket.md") + for text in (http3, quic, websocket): + assert BOUNDARY_DOC in text + assert CANONICAL_RELEASE_ROOT in text + _assert_contains_all( + http3, + "preserved passing third-party HTTP/3 request/response", + "RFC 9220 scenarios", + "package-owned TCP/TLS condition", + ) + _assert_contains_all( + quic, + "OpenSSL QUIC handshake", + "preserves passing third-party HTTP/3 feature-axis scenarios", + "package-wide RFC 8446 target is no longer blocked by the public TCP/TLS listener path", + ) + _assert_contains_all( + websocket, + "RFC 8441 WebSocket-over-HTTP/2", + "RFC 9220 WebSocket-over-HTTP/3", + "RFC 7692 across carriers", + ) + + +def test_conformance_boundary_and_status_docs_are_aligned() -> None: + boundary = _read("docs/review/conformance/CERTIFICATION_BOUNDARY.md") + conformance = _read("docs/review/conformance/README.md") + status = _read("docs/review/conformance/reports/RFC_CERTIFICATION_STATUS.md") + current = _read("docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md") + hardening = _read("docs/review/conformance/reports/RFC_HARDENING_REPORT.md") + for text in (boundary, conformance, status, current, hardening): + assert BOUNDARY_DOC in text + _assert_contains_all( + conformance, + CANONICAL_RELEASE_ROOT, + "certifiably fully RFC compliant under the authoritative certification boundary", + ) + _assert_contains_all( + current, + "certifiably fully RFC compliant", + "evaluate_release_gates", + "RFC 9220", + ) + _assert_contains_all( + status, + "certifiably fully RFC compliant", + "independent-certification", + "aioquic", + ) + _assert_contains_all( + hardening, + "preserved third-party `aioquic` HTTP/3 request/response artifacts", + "package-owned TCP/TLS listener-path integration", + ) diff --git a/tests/test_documentation_truth_normalization_checkpoint_pytest.py b/tests/test_documentation_truth_normalization_checkpoint_pytest.py new file mode 100644 index 00000000..a89d7751 --- /dev/null +++ b/tests/test_documentation_truth_normalization_checkpoint_pytest.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, evaluate_promotion_target + +ROOT = Path(__file__).resolve().parents[1] + + +def _load(path: str) -> dict: + return json.loads((ROOT / path).read_text(encoding='utf-8')) + + +def test_canonical_current_state_chain_exists_and_is_explicit() -> None: + payload = _load('docs/review/conformance/current_state_chain.current.json') + assert payload['document_role'] == 'canonical_current_state_source' + assert payload['current_truth_source'] is True + assert payload['exit_criteria']['one_canonical_current_state_chain'] is True + assert 'docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md' in payload['canonical_human_current_state_chain'] + assert 'docs/review/conformance/package_compliance_review_phase9i.current.json' in payload['canonical_machine_current_state_chain'] + assert payload['example_path_policy']['canonical_current_phase4_example_tree'] == 'examples/advanced_delivery/' + assert payload['example_path_policy']['retained_archival_feature_specific_tree'] == 'examples/advanced_protocol_delivery/' + + +def test_scoped_current_audits_are_non_canonical() -> None: + for rel in [ + 'docs/review/conformance/http_integrity_caching_signatures_status.current.json', + 'docs/review/conformance/rfc_applicability_and_competitor_status.current.json', + 'docs/review/conformance/rfc_applicability_and_competitor_support.current.json', + ]: + payload = _load(rel) + assert payload['document_role'] == 'scoped_current_audit_not_package_wide_truth_source' + assert payload['current_truth_source'] is False + assert payload['canonical_current_state_chain'] == 'docs/review/conformance/current_state_chain.current.json' + + +def test_archival_current_aliases_are_labeled() -> None: + for rel in [ + 'docs/review/conformance/phase1_surface_parity_checkpoint.current.json', + 'docs/review/conformance/phase4_advanced_protocol_delivery_checkpoint.current.json', + 'docs/review/conformance/phase9a_promotion_contract.current.json', + 'docs/review/conformance/promotion_artifact_reconciliation_checkpoint.current.json', + 'docs/review/conformance/documentation_truth_normalization_checkpoint.current.json', + ]: + payload = _load(rel) + assert payload['document_role'] == 'archival_named_current_snapshot_for_stability' + assert payload['current_truth_source'] is False + + +def test_example_path_docs_are_normalized() -> None: + examples_readme = (ROOT / 'examples' / 'README.md').read_text(encoding='utf-8') + advanced_protocol_readme = (ROOT / 'examples' / 'advanced_protocol_delivery' / 'README.md').read_text(encoding='utf-8') + pairing = (ROOT / 'examples' / 'PHASE4_PROTOCOL_PAIRING.md').read_text(encoding='utf-8') + archival_matrix = _load('docs/review/conformance/phase4_advanced_protocol_delivery/example_matrix.json') + current_matrix = _load('docs/review/conformance/phase4_advanced_delivery/examples_matrix.json') + assert 'examples/advanced_delivery/' in examples_readme + assert 'examples/advanced_protocol_delivery/' in examples_readme + assert 'canonical current integrated Phase 4 example tree' in advanced_protocol_readme + assert 'canonical integrated example tree' in pairing + assert archival_matrix['document_role'] == 'archival_phase4_checkpoint_example_matrix' + assert archival_matrix['current_truth_source'] is False + assert current_matrix['document_role'] == 'current_subsystem_truth_source' + assert current_matrix['current_truth_source'] is True + + +def test_package_review_and_current_state_record_normalization() -> None: + review = _load('docs/review/conformance/package_compliance_review_phase9i.current.json') + current = (ROOT / 'docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md').read_text(encoding='utf-8') + assert review['summary']['documentation_truth_normalized'] is True + assert review['summary']['canonical_current_state_chain_defined'] is True + assert review['summary']['historical_current_aliases_labeled'] is True + assert review['summary']['canonical_phase4_example_tree'] == 'examples/advanced_delivery/' + assert 'Canonical current-state chain' in current + + +def test_release_gates_and_promotion_remain_green_after_doc_truth_normalization() -> None: + authoritative = evaluate_release_gates(ROOT) + strict = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + promotion = evaluate_promotion_target(ROOT) + assert authoritative.passed, authoritative.failures + assert strict.passed, strict.failures + assert promotion.passed, promotion.failures diff --git a/tests/test_external_current_release_matrix_pytest.py b/tests/test_external_current_release_matrix_pytest.py new file mode 100644 index 00000000..e8082610 --- /dev/null +++ b/tests/test_external_current_release_matrix_pytest.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + +from tigrcorn.compat.interop_runner import ExternalInteropRunner, load_external_matrix, summarize_matrix_dimensions + +ROOT = Path(__file__).resolve().parents[1] +MATRIX_PATH = ROOT / "docs/review/conformance/external_matrix.current_release.json" +RELEASE_ROOT = ROOT / "docs/review/conformance/releases/0.3.9/release-0.3.9/tigrcorn-mixed-compatibility-release-matrix" +EXPECTED_SCENARIO_IDS = { + "http1-server-curl-client", + "http2-server-h2-client", + "http2-tls-server-h2-client", + "websocket-server-websockets-client", + "websocket-http2-server-h2-client", + "http3-server-public-client-post", + "http3-server-public-client-post-mtls", + "http3-server-public-client-post-retry", + "http3-server-public-client-post-resumption", + "http3-server-public-client-post-zero-rtt", + "http3-server-public-client-post-migration", + "http3-server-public-client-post-goaway-qpack", + "websocket-http3-server-public-client", + "websocket-http3-server-public-client-mtls", +} + + +def test_current_release_matrix_document_covers_expected_peers_and_dimensions() -> None: + matrix = load_external_matrix(MATRIX_PATH) + assert matrix.name == "tigrcorn-current-release-matrix" + assert {scenario.id for scenario in matrix.scenarios} == EXPECTED_SCENARIO_IDS + assert {scenario.peer for scenario in matrix.scenarios} == {"curl", "python-h2", "tigrcorn-public-client", "websockets"} + assert matrix.metadata["evidence_tier"] == "mixed" + assert matrix.metadata["canonical_release_root"] == "docs/review/conformance/releases/0.3.9/release-0.3.9/tigrcorn-mixed-compatibility-release-matrix" + assert {scenario.evidence_tier for scenario in matrix.scenarios} == {"independent_certification", "same_stack_replay"} + assert all(s.peer_process.provenance_kind == "same_stack_fixture" for s in matrix.scenarios if s.peer == "tigrcorn-public-client") + + dimensions = summarize_matrix_dimensions(matrix) + assert dimensions["evidence_tier"] == ["independent_certification", "same_stack_replay"] + assert dimensions["retry"] == [False, True] + assert dimensions["resumption"] == [False, True] + assert dimensions["zero_rtt"] == [False, True] + assert dimensions["migration"] == [False, True] + assert dimensions["goaway"] == [False, True] + assert dimensions["qpack_blocking"] == [False, True] + + +def test_committed_current_release_artifact_bundle_is_present_and_passing() -> None: + assert RELEASE_ROOT.exists() + index_payload = json.loads((RELEASE_ROOT / "index.json").read_text(encoding="utf-8")) + manifest_payload = json.loads((RELEASE_ROOT / "manifest.json").read_text(encoding="utf-8")) + + assert index_payload["total"] == 14 + assert index_payload["passed"] == 14 + assert index_payload["failed"] == 0 + assert manifest_payload["environment"]["tigrcorn"]["commit_hash"] == "release-0.3.9" + assert manifest_payload["environment"]["tigrcorn"]["version"] == "0.3.9" + assert manifest_payload["bundle_kind"] == "mixed" + + scenarios = {item["id"]: item for item in index_payload["scenarios"]} + assert set(scenarios) == EXPECTED_SCENARIO_IDS + + h2_result = json.loads((RELEASE_ROOT / "http2-server-h2-client" / "result.json").read_text(encoding="utf-8")) + assert h2_result["passed"] + assert h2_result["negotiation"]["peer"]["protocol"] == "h2c" + assert h2_result["transcript"]["peer"]["response"]["body"] == "echo:hello-http2" + + h3_result = json.loads((RELEASE_ROOT / "http3-server-public-client-post" / "result.json").read_text(encoding="utf-8")) + assert h3_result["passed"] + assert h3_result["negotiation"]["peer"]["protocol"] == "h3" + assert (RELEASE_ROOT / "http3-server-public-client-post" / "qlog.json").exists() + + +def test_current_release_matrix_can_be_replayed_with_local_peers() -> None: + if os.environ.get("TIGRCORN_RUN_EXTERNAL_CURRENT_RELEASE_MATRIX") != "1": + pytest.skip("set TIGRCORN_RUN_EXTERNAL_CURRENT_RELEASE_MATRIX=1 to rerun the full current-release matrix") + if shutil.which("curl") is None: + pytest.skip("curl is not available") + if importlib.util.find_spec("websockets") is None: + pytest.skip("websockets is not available") + if importlib.util.find_spec("h2") is None: + pytest.skip("python-h2 is not available") + + with tempfile.TemporaryDirectory() as artifact_root: + prior = os.environ.get("TIGRCORN_COMMIT_HASH") + os.environ["TIGRCORN_COMMIT_HASH"] = "test-current-release-matrix" + try: + runner = ExternalInteropRunner( + matrix=load_external_matrix(MATRIX_PATH), + artifact_root=artifact_root, + source_root=ROOT, + ) + summary = runner.run() + finally: + if prior is None: + os.environ.pop("TIGRCORN_COMMIT_HASH", None) + else: + os.environ["TIGRCORN_COMMIT_HASH"] = prior + + assert summary.total == 14 + assert summary.passed == 14 + assert summary.failed == 0 + assert {item.scenario_id for item in summary.scenarios} == EXPECTED_SCENARIO_IDS diff --git a/tests/test_external_independent_peer_release_matrix_pytest.py b/tests/test_external_independent_peer_release_matrix_pytest.py new file mode 100644 index 00000000..c661bd4a --- /dev/null +++ b/tests/test_external_independent_peer_release_matrix_pytest.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from tigrcorn.compat.interop_runner import ExternalInteropRunner, load_external_matrix + +ROOT = Path(__file__).resolve().parents[1] +MATRIX_PATH = ROOT / "docs/review/conformance/external_matrix.release.json" +RELEASE_ROOT = ROOT / "docs/review/conformance/releases/0.3.9/release-0.3.9/tigrcorn-independent-certification-release-matrix" +EXPECTED_ENABLED_SCENARIO_IDS = { + "http1-server-curl-client", + "http11-connect-relay-curl-client", + "http11-content-coding-curl-client", + "http11-trailer-fields-curl-client", + "http2-connect-relay-h2-client", + "http2-content-coding-curl-client", + "http2-server-curl-client", + "http2-server-h2-client", + "http2-tls-server-curl-client", + "http2-tls-server-h2-client", + "http2-trailer-fields-h2-client", + "http3-connect-relay-aioquic-client", + "http3-content-coding-aioquic-client", + "http3-server-aioquic-client-post", + "http3-server-aioquic-client-post-goaway-qpack", + "http3-server-aioquic-client-post-migration", + "http3-server-aioquic-client-post-mtls", + "http3-server-aioquic-client-post-resumption", + "http3-server-aioquic-client-post-retry", + "http3-server-aioquic-client-post-zero-rtt", + "http3-server-openssl-quic-handshake", + "http3-trailer-fields-aioquic-client", + "tls-server-ocsp-validation-openssl-client", + "websocket-http11-server-websockets-client-permessage-deflate", + "websocket-http2-server-h2-client", + "websocket-http2-server-h2-client-permessage-deflate", + "websocket-http3-server-aioquic-client", + "websocket-http3-server-aioquic-client-mtls", + "websocket-http3-server-aioquic-client-permessage-deflate", + "websocket-server-websockets-client", +} +EXPECTED_PENDING_SCENARIO_IDS: set[str] = set() + + +def _openssl_supports_quic() -> bool: + executable = shutil.which("openssl") + if executable is None: + return False + completed = subprocess.run([executable, "s_client", "-help"], capture_output=True, text=True, timeout=10.0) + help_text = "\n".join(part for part in (completed.stdout, completed.stderr) if part) + return "-quic" in help_text + + +def test_release_matrix_document_covers_enabled_and_pending_independent_peers() -> None: + matrix = load_external_matrix(MATRIX_PATH) + assert matrix.name == "tigrcorn-independent-certification-release-matrix" + assert {scenario.id for scenario in matrix.enabled_scenarios} == EXPECTED_ENABLED_SCENARIO_IDS + assert set(matrix.metadata["preserved_enabled_scenarios"]) == EXPECTED_ENABLED_SCENARIO_IDS + assert set(matrix.metadata["pending_third_party_http3_scenarios"]) == EXPECTED_PENDING_SCENARIO_IDS + assert matrix.metadata["evidence_tier"] == "independent_certification" + assert "aioquic" in matrix.metadata["independent_peers"] + assert all(s.peer_process.provenance_kind != "same_stack_fixture" for s in matrix.scenarios) + + by_id = {scenario.id: scenario for scenario in matrix.scenarios} + for scenario_id in EXPECTED_PENDING_SCENARIO_IDS: + scenario = by_id[scenario_id] + assert not scenario.enabled + assert scenario.peer_process.provenance_kind == "third_party_library" + assert scenario.peer_process.implementation_source == "aioquic" + + for scenario_id in EXPECTED_ENABLED_SCENARIO_IDS: + scenario = by_id[scenario_id] + assert scenario.enabled + + +def test_committed_release_artifact_bundle_is_present_and_passing_for_enabled_scenarios() -> None: + assert RELEASE_ROOT.exists() + index_payload = json.loads((RELEASE_ROOT / "index.json").read_text(encoding="utf-8")) + manifest_payload = json.loads((RELEASE_ROOT / "manifest.json").read_text(encoding="utf-8")) + assert index_payload["total"] == 30 + assert index_payload["passed"] == 30 + assert index_payload["failed"] == 0 + assert manifest_payload["environment"]["tigrcorn"]["commit_hash"] == "release-0.3.9" + assert manifest_payload["environment"]["tigrcorn"]["version"] == "0.3.9" + assert manifest_payload["bundle_kind"] == "independent_certification" + assert "0.3.2" in "".join(manifest_payload["source_bundles"]) + assert "0.3.6-rfc-hardening" in "".join(manifest_payload["source_bundles"]) + assert "0.3.6-current" in "".join(manifest_payload["source_bundles"]) + + scenarios = {item["id"]: item for item in index_payload["scenarios"]} + assert set(scenarios) == EXPECTED_ENABLED_SCENARIO_IDS + + http2_tls_result = json.loads((RELEASE_ROOT / "http2-tls-server-curl-client" / "result.json").read_text(encoding="utf-8")) + assert http2_tls_result["passed"] + assert http2_tls_result["negotiation"]["peer"]["protocol"] == "h2" + + websocket_h2_result = json.loads((RELEASE_ROOT / "websocket-http2-server-h2-client" / "result.json").read_text(encoding="utf-8")) + assert websocket_h2_result["passed"] + assert websocket_h2_result["negotiation"]["peer"]["settings_enable_connect_protocol"] + + quic_artifact_dir = RELEASE_ROOT / "http3-server-openssl-quic-handshake" + assert (quic_artifact_dir / "qlog.json").exists() + quic_result = json.loads((quic_artifact_dir / "result.json").read_text(encoding="utf-8")) + assert quic_result["passed"] + assert quic_result["negotiation"]["peer"]["protocol"] == "QUICv1" + assert quic_result["negotiation"]["peer"]["alpn"] == "h3" + assert quic_result["negotiation"]["peer"]["verification"] == "OK" + + +def test_release_matrix_can_be_replayed_with_local_independent_peers() -> None: + if os.environ.get("TIGRCORN_RUN_EXTERNAL_RELEASE_MATRIX") != "1": + pytest.skip("set TIGRCORN_RUN_EXTERNAL_RELEASE_MATRIX=1 to rerun the enabled independent-peer matrix") + if shutil.which("curl") is None: + pytest.skip("curl is not available") + if not _openssl_supports_quic(): + pytest.skip("OpenSSL QUIC support is not available") + if importlib.util.find_spec("websockets") is None: + pytest.skip("websockets is not available") + if importlib.util.find_spec("h2") is None: + pytest.skip("python-h2 is not available") + + with tempfile.TemporaryDirectory() as artifact_root: + prior = os.environ.get("TIGRCORN_COMMIT_HASH") + os.environ["TIGRCORN_COMMIT_HASH"] = "test-independent-peer-release-matrix" + try: + runner = ExternalInteropRunner( + matrix=load_external_matrix(MATRIX_PATH), + artifact_root=artifact_root, + source_root=ROOT, + ) + summary = runner.run() + finally: + if prior is None: + os.environ.pop("TIGRCORN_COMMIT_HASH", None) + else: + os.environ["TIGRCORN_COMMIT_HASH"] = prior + + assert summary.total == len(EXPECTED_ENABLED_SCENARIO_IDS) + assert summary.passed == len(EXPECTED_ENABLED_SCENARIO_IDS) + assert summary.failed == 0 + assert {item.scenario_id for item in summary.scenarios} == EXPECTED_ENABLED_SCENARIO_IDS diff --git a/tests/test_external_interop_runner_matrix_pytest.py b/tests/test_external_interop_runner_matrix_pytest.py new file mode 100644 index 00000000..11c30906 --- /dev/null +++ b/tests/test_external_interop_runner_matrix_pytest.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +from tigrcorn.compat.interop_runner import ( + ExternalInteropRunner, + InteropProcessSpec, + _materialize_process_spec, + build_environment_manifest, + load_external_matrix, + summarize_matrix_dimensions, +) + +ROOT = Path(__file__).resolve().parents[1] +PYTHON = sys.executable +_TMPDIRS: list[tempfile.TemporaryDirectory] = [] + + +def _write_matrix(payload: dict) -> Path: + tmpdir = tempfile.TemporaryDirectory() + _TMPDIRS.append(tmpdir) + path = Path(tmpdir.name) / "matrix.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_load_matrix_and_dimension_summary() -> None: + matrix_path = _write_matrix( + { + "name": "dimension-check", + "scenarios": [ + { + "id": "http1-ipv4", + "protocol": "http1", + "role": "server", + "feature": "basic-get", + "peer": "fixture-http-client", + "ip_family": "ipv4", + "cipher_group": "tls13-aes128", + "sut": { + "name": "tigrcorn-http1", + "adapter": "subprocess", + "role": "server", + "command": [PYTHON, "-m", "tigrcorn", "examples.echo_http.app:app", "--host", "{bind_host}", "--port", "{bind_port}", "--protocol", "http1", "--disable-websocket", "--no-access-log", "--lifespan", "off"], + "ready_pattern": "listening on", + "version_command": [PYTHON, "-m", "tigrcorn", "--help"], + }, + "peer_process": { + "name": "fixture-http-client", + "adapter": "subprocess", + "role": "client", + "command": [PYTHON, "-m", "tests.fixtures_pkg.interop_http_client"], + "version_command": [PYTHON, "-m", "tests.fixtures_pkg.interop_http_client", "--version"], + }, + }, + { + "id": "quic-ipv6", + "protocol": "quic", + "role": "client", + "feature": "observer-qlog", + "peer": "fixture-udp-echo", + "transport": "udp", + "ip_family": "ipv6", + "cipher_group": "x25519-aes128", + "retry": True, + "resumption": True, + "zero_rtt": True, + "key_update": True, + "migration": True, + "goaway": True, + "qpack_blocking": True, + "sut": { + "name": "fixture-quic-client", + "adapter": "subprocess", + "role": "client", + "command": [PYTHON, "-m", "tests.fixtures_pkg.interop_quic_client"], + "version_command": [PYTHON, "-m", "tests.fixtures_pkg.interop_quic_client", "--version"], + }, + "peer_process": { + "name": "fixture-udp-echo", + "adapter": "subprocess", + "role": "server", + "command": [PYTHON, "-m", "tests.fixtures_pkg.interop_udp_echo_server"], + "ready_pattern": "READY", + "version_command": [PYTHON, "-m", "tests.fixtures_pkg.interop_udp_echo_server", "--version"], + }, + }, + ], + } + ) + matrix = load_external_matrix(matrix_path) + dimensions = summarize_matrix_dimensions(matrix) + assert matrix.name == "dimension-check" + assert dimensions["protocol"] == ["http1", "quic"] + assert dimensions["role"] == ["client", "server"] + assert dimensions["ip_family"] == ["ipv4", "ipv6"] + assert dimensions["retry"] == [False, True] + assert dimensions["qpack_blocking"] == [False, True] + assert dimensions["evidence_tier"] == ["mixed"] + + +def test_load_matrix_rejects_same_stack_peer_for_independent_certification() -> None: + matrix_path = _write_matrix( + { + "name": "bad-independent-matrix", + "metadata": {"evidence_tier": "independent_certification"}, + "scenarios": [ + { + "id": "bad-http3", + "protocol": "http3", + "role": "server", + "feature": "post-echo", + "peer": "tigrcorn-public-client", + "evidence_tier": "independent_certification", + "sut": { + "name": "tigrcorn-http3", + "adapter": "subprocess", + "role": "server", + "command": [PYTHON, "-m", "tigrcorn", "examples.echo_http.app:app"], + "provenance_kind": "package_owned", + "implementation_source": "tigrcorn", + "implementation_identity": "tigrcorn-http3", + }, + "peer_process": { + "name": "tigrcorn-public-client", + "adapter": "subprocess", + "role": "client", + "command": [PYTHON, "-m", "tests.fixtures_pkg.external_http3_client"], + "provenance_kind": "same_stack_fixture", + "implementation_source": "tigrcorn.tests.fixtures_pkg", + "implementation_identity": "tigrcorn-public-client", + }, + } + ], + } + ) + with pytest.raises(RuntimeError, match="requires a third-party peer"): + load_external_matrix(matrix_path) + + +def test_materialize_process_spec_rewrites_hardcoded_pyvenv_python_to_active_interop_python() -> None: + spec = InteropProcessSpec( + name="aioquic-wrapper", + adapter="subprocess", + role="client", + command=["/opt/pyvenv/bin/python", "-m", "tests.fixtures_third_party.aioquic_http3_client"], + version_command=["/opt/pyvenv/bin/python", "-m", "tests.fixtures_third_party.aioquic_http3_client", "--version"], + ) + prior = os.environ.get("TIGRCORN_INTEROP_PYTHON") + os.environ["TIGRCORN_INTEROP_PYTHON"] = "/custom/interop/python" + try: + resolved = _materialize_process_spec(spec, {}) + assert resolved.command[0] == "/custom/interop/python" + assert resolved.version_command[0] == "/custom/interop/python" + finally: + if prior is None: + os.environ.pop("TIGRCORN_INTEROP_PYTHON", None) + else: + os.environ["TIGRCORN_INTEROP_PYTHON"] = prior + + +def test_runner_generates_http_evidence_bundle() -> None: + matrix_path = _write_matrix( + { + "name": "http-evidence", + "scenarios": [ + { + "id": "http1-server-fixture-client", + "protocol": "http1", + "role": "server", + "feature": "post-echo", + "peer": "fixture-http-client", + "sut": { + "name": "tigrcorn-http1", + "adapter": "subprocess", + "role": "server", + "command": [PYTHON, "-m", "tigrcorn", "examples.echo_http.app:app", "--host", "{bind_host}", "--port", "{bind_port}", "--protocol", "http1", "--disable-websocket", "--no-access-log", "--lifespan", "off"], + "ready_pattern": "listening on", + "version_command": [PYTHON, "-m", "tigrcorn", "--help"], + }, + "peer_process": { + "name": "fixture-http-client", + "adapter": "subprocess", + "role": "client", + "command": [PYTHON, "-m", "tests.fixtures_pkg.interop_http_client"], + "version_command": [PYTHON, "-m", "tests.fixtures_pkg.interop_http_client", "--version"], + }, + "assertions": [ + {"path": "peer.exit_code", "equals": 0}, + {"path": "transcript.peer.response.status", "equals": 200}, + {"path": "transcript.peer.response.body", "equals": "echo:hello-interop"}, + {"path": "artifacts.packet_trace.exists", "equals": True}, + {"path": "artifacts.packet_trace.size", "greater_or_equal": 1}, + {"path": "artifacts.peer_transcript.exists", "equals": True}, + ], + } + ], + } + ) + with tempfile.TemporaryDirectory() as artifact_root: + prior = os.environ.get("TIGRCORN_COMMIT_HASH") + os.environ["TIGRCORN_COMMIT_HASH"] = "deadbeefcafebabe" + try: + runner = ExternalInteropRunner(matrix=load_external_matrix(matrix_path), artifact_root=artifact_root, source_root=ROOT) + summary = runner.run() + assert summary.total == 1 + assert summary.passed == 1 + result = summary.scenarios[0] + assert result.passed + assert result.transcript["peer"]["response"]["body"] == "echo:hello-interop" + assert result.sut["provenance"]["kind"] == "unspecified" + assert result.peer["provenance"]["kind"] == "unspecified" + manifest = json.loads((Path(summary.artifact_root) / "manifest.json").read_text(encoding="utf-8")) + assert manifest["commit_hash"] == "deadbeefcafebabe" + assert (Path(result.artifact_dir) / "packet_trace.jsonl").exists() + finally: + if prior is None: + os.environ.pop("TIGRCORN_COMMIT_HASH", None) + else: + os.environ["TIGRCORN_COMMIT_HASH"] = prior + + +def test_runner_generates_quic_qlog_bundle() -> None: + matrix_path = _write_matrix( + { + "name": "quic-observer", + "scenarios": [ + { + "id": "quic-client-fixture-server", + "protocol": "quic", + "transport": "udp", + "role": "client", + "feature": "initial-observer-qlog", + "peer": "fixture-udp-echo", + "sut": { + "name": "fixture-quic-client", + "adapter": "subprocess", + "role": "client", + "command": [PYTHON, "-m", "tests.fixtures_pkg.interop_quic_client"], + "version_command": [PYTHON, "-m", "tests.fixtures_pkg.interop_quic_client", "--version"], + }, + "peer_process": { + "name": "fixture-udp-echo", + "adapter": "subprocess", + "role": "server", + "command": [PYTHON, "-m", "tests.fixtures_pkg.interop_udp_echo_server"], + "ready_pattern": "READY", + "version_command": [PYTHON, "-m", "tests.fixtures_pkg.interop_udp_echo_server", "--version"], + }, + "assertions": [ + {"path": "sut.exit_code", "equals": 0}, + {"path": "artifacts.packet_trace.exists", "equals": True}, + {"path": "artifacts.packet_trace.size", "greater_or_equal": 1}, + {"path": "artifacts.qlog.exists", "equals": True}, + {"path": "negotiation.sut.alpn", "equals": "h3"}, + ], + } + ], + } + ) + with tempfile.TemporaryDirectory() as artifact_root: + runner = ExternalInteropRunner(matrix=load_external_matrix(matrix_path), artifact_root=artifact_root, source_root=ROOT) + summary = runner.run() + assert summary.passed == 1 + result = summary.scenarios[0] + qlog = json.loads((Path(result.artifact_dir) / "qlog.json").read_text(encoding="utf-8")) + assert qlog["traces"][0]["vantage_point"]["type"] == "network" + packet_events = [event for event in qlog["traces"][0]["events"] if event[2].startswith("packet_")] + assert packet_events + assert packet_events[0][3]["packets"][0]["packet_type"] == "initial" + + +def test_failed_assertions_are_recorded() -> None: + matrix_path = _write_matrix( + { + "name": "failure-path", + "scenarios": [ + { + "id": "http1-failure-recording", + "protocol": "http1", + "role": "server", + "feature": "post-echo", + "peer": "fixture-http-client", + "sut": { + "name": "tigrcorn-http1", + "adapter": "subprocess", + "role": "server", + "command": [PYTHON, "-m", "tigrcorn", "examples.echo_http.app:app", "--host", "{bind_host}", "--port", "{bind_port}", "--protocol", "http1", "--disable-websocket", "--no-access-log", "--lifespan", "off"], + "ready_pattern": "listening on", + "version_command": [PYTHON, "-m", "tigrcorn", "--help"], + }, + "peer_process": { + "name": "fixture-http-client", + "adapter": "subprocess", + "role": "client", + "command": [PYTHON, "-m", "tests.fixtures_pkg.interop_http_client"], + "version_command": [PYTHON, "-m", "tests.fixtures_pkg.interop_http_client", "--version"], + }, + "assertions": [{"path": "transcript.peer.response.status", "equals": 201}], + } + ], + } + ) + with tempfile.TemporaryDirectory() as artifact_root: + runner = ExternalInteropRunner(matrix=load_external_matrix(matrix_path), artifact_root=artifact_root, source_root=ROOT) + summary = runner.run() + assert summary.failed == 1 + result = summary.scenarios[0] + assert not result.passed + assert result.assertions_failed + assert "expected 201" in result.assertions_failed[0] + + +def test_environment_manifest_uses_env_commit_override() -> None: + prior = os.environ.get("TIGRCORN_COMMIT_HASH") + os.environ["TIGRCORN_COMMIT_HASH"] = "feedface1234" + try: + manifest = build_environment_manifest(ROOT) + finally: + if prior is None: + os.environ.pop("TIGRCORN_COMMIT_HASH", None) + else: + os.environ["TIGRCORN_COMMIT_HASH"] = prior + assert manifest["tigrcorn"]["commit_hash"] == "feedface1234" + assert "python" in manifest + assert "tools" in manifest diff --git a/tests/test_external_rfc_hardening_candidate_matrix_pytest.py b/tests/test_external_rfc_hardening_candidate_matrix_pytest.py new file mode 100644 index 00000000..acd9d2f4 --- /dev/null +++ b/tests/test_external_rfc_hardening_candidate_matrix_pytest.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + +from tigrcorn.compat.interop_runner import ExternalInteropRunner, load_external_matrix + +ROOT = Path(__file__).resolve().parents[1] +MATRIX_PATH = ROOT / "docs/review/conformance/external_matrix.rfc_hardening_candidate.json" +RELEASE_ROOT = ROOT / "docs/review/conformance/releases/0.3.6-rfc-hardening/release-0.3.6-rfc-hardening/tigrcorn-rfc-hardening-candidate-matrix" +EXPECTED_SCENARIO_IDS = { + "http2-tls-server-curl-client", + "websocket-http2-server-h2-client", +} + + +def test_candidate_matrix_document_covers_added_http2_independent_peers() -> None: + matrix = load_external_matrix(MATRIX_PATH) + assert matrix.name == "tigrcorn-rfc-hardening-candidate-matrix" + assert {scenario.id for scenario in matrix.scenarios} == EXPECTED_SCENARIO_IDS + assert {scenario.peer for scenario in matrix.scenarios} == {"curl", "python-h2"} + + +def test_committed_candidate_artifact_bundle_is_present_and_passing() -> None: + assert RELEASE_ROOT.exists() + index_payload = json.loads((RELEASE_ROOT / "index.json").read_text(encoding="utf-8")) + manifest_payload = json.loads((RELEASE_ROOT / "manifest.json").read_text(encoding="utf-8")) + assert index_payload["total"] == 2 + assert index_payload["passed"] == 2 + assert index_payload["failed"] == 0 + assert manifest_payload["environment"]["tigrcorn"]["commit_hash"] == "release-0.3.6-rfc-hardening" + assert manifest_payload["environment"]["tigrcorn"]["version"] == "0.3.6" + assert "curl" in manifest_payload["environment"]["tools"] + + scenarios = {item["id"]: item for item in index_payload["scenarios"]} + assert set(scenarios) == EXPECTED_SCENARIO_IDS + for scenario_id in EXPECTED_SCENARIO_IDS: + result = json.loads((RELEASE_ROOT / scenario_id / "result.json").read_text(encoding="utf-8")) + assert result["passed"] + assert (RELEASE_ROOT / scenario_id / "packet_trace.jsonl").exists() + assert (RELEASE_ROOT / scenario_id / "peer_transcript.json").exists() + assert result["peer"]["exit_code"] == 0 + + +def test_candidate_matrix_can_be_replayed_with_local_independent_peers() -> None: + if os.environ.get("TIGRCORN_RUN_EXTERNAL_RFC_HARDENING_MATRIX") != "1": + pytest.skip("set TIGRCORN_RUN_EXTERNAL_RFC_HARDENING_MATRIX=1 to rerun the HTTP/2 hardening matrix") + if shutil.which("curl") is None: + pytest.skip("curl is not available") + if importlib.util.find_spec("h2") is None: + pytest.skip("python-h2 is not available") + + with tempfile.TemporaryDirectory() as artifact_root: + prior = os.environ.get("TIGRCORN_COMMIT_HASH") + os.environ["TIGRCORN_COMMIT_HASH"] = "test-rfc-hardening-candidate-matrix" + try: + runner = ExternalInteropRunner( + matrix=load_external_matrix(MATRIX_PATH), + artifact_root=artifact_root, + source_root=ROOT, + ) + summary = runner.run() + finally: + if prior is None: + os.environ.pop("TIGRCORN_COMMIT_HASH", None) + else: + os.environ["TIGRCORN_COMMIT_HASH"] = prior + + assert summary.total == 2 + assert summary.passed == 2 + assert summary.failed == 0 + assert {item.scenario_id for item in summary.scenarios} == EXPECTED_SCENARIO_IDS diff --git a/tests/test_flow_scheduler_pytest.py b/tests/test_flow_scheduler_pytest.py new file mode 100644 index 00000000..d2f96bbc --- /dev/null +++ b/tests/test_flow_scheduler_pytest.py @@ -0,0 +1,79 @@ +import asyncio + +import pytest + +from tigrcorn.flow.backpressure import BackpressureState +from tigrcorn.flow.credits import CreditWindow +from tigrcorn.flow.keepalive import KeepAlivePolicy +from tigrcorn.flow.timeouts import TimeoutPolicy +from tigrcorn.flow.watermarks import Watermarks +from tigrcorn.scheduler.cancellation import cancel, cancel_many +from tigrcorn.scheduler.dispatch import TaskDispatcher +from tigrcorn.scheduler.policy import SchedulerPolicy +from tigrcorn.scheduler.quotas import Quotas +from tigrcorn.scheduler.tasks import TaskSet + + +@pytest.mark.asyncio +async def test_dispatcher_and_taskset() -> None: + dispatcher = TaskDispatcher(SchedulerPolicy(max_tasks=2)) + seen = [] + + async def job(x): + seen.append(x) + + t1 = dispatcher.spawn(job(1)) + t2 = dispatcher.spawn(job(2)) + with pytest.raises(RuntimeError): + dispatcher.spawn(job(3)) + await asyncio.gather(t1, t2) + assert seen == [1, 2] + + taskset = TaskSet() + sleeper = asyncio.create_task(asyncio.sleep(10)) + taskset.add(sleeper) + await taskset.cancel_all() + assert sleeper.cancelled() + + +@pytest.mark.asyncio +async def test_cancellation_helpers() -> None: + task = asyncio.create_task(asyncio.sleep(10)) + await cancel(task) + assert task.cancelled() + tasks = [asyncio.create_task(asyncio.sleep(10)) for _ in range(2)] + await cancel_many(tasks) + assert all(task.cancelled() for task in tasks) + + +@pytest.mark.asyncio +async def test_timeout_policy() -> None: + policy = TimeoutPolicy(read_timeout=0.1, write_timeout=0.1) + result = await policy.wait_read(asyncio.sleep(0, result=5)) + assert result == 5 + with pytest.raises(asyncio.TimeoutError): + await policy.wait_write(asyncio.sleep(1)) + + +def test_backpressure_watermarks_credits_keepalive_quotas() -> None: + bp = BackpressureState(high_water=10, low_water=3) + assert not bp.update(2) + assert bp.update(10) + assert not bp.update(3) + watermarks = Watermarks(low=2, high=5) + assert watermarks.classify(1) == "low" + assert watermarks.classify(3) == "mid" + assert watermarks.classify(5) == "high" + credits = CreditWindow(remaining=5) + credits.consume(3) + assert credits.available(2) + credits.refill(4) + assert credits.remaining == 6 + keepalive = KeepAlivePolicy(idle_timeout=10, ping_interval=5) + assert keepalive.should_ping(0, now=6) + assert keepalive.expired(0, now=11) + quotas = Quotas(max_connections=1) + assert quotas.acquire_connection() + assert not quotas.acquire_connection() + quotas.release_connection() + assert quotas.acquire_connection() diff --git a/tests/test_hpack_completion_pass_pytest.py b/tests/test_hpack_completion_pass_pytest.py new file mode 100644 index 00000000..f29025be --- /dev/null +++ b/tests/test_hpack_completion_pass_pytest.py @@ -0,0 +1,72 @@ +import importlib.util + +import pytest + +from tigrcorn.errors import ProtocolError +from tigrcorn.protocols.http2.hpack import ( + HPACKDecoder, + HPACKEncoder, + decode_header_block, + encode_header, + encode_header_block, + encode_integer, +) + + +def test_dynamic_table_size_update_must_appear_at_start_of_block() -> None: + block = encode_header(b":method", b"GET") + encode_integer(0, 5, 0x20) + with pytest.raises(ProtocolError): + HPACKDecoder().decode_header_block(block) + + +def test_header_list_size_limit_is_enforced() -> None: + block = encode_header_block([(b"x-large", b"a" * 64)]) + with pytest.raises(ProtocolError): + decode_header_block(block, max_header_list_size=32) + + +def test_malformed_integer_huffman_and_truncation_inputs_are_rejected() -> None: + decoder = HPACKDecoder() + with pytest.raises(ProtocolError): + decoder.decode_header_block(b"@") + with pytest.raises(ProtocolError): + decoder.decode_header_block(b"@\x81\xff\x00") + with pytest.raises(ProtocolError): + decoder.decode_header_block(b"\xff" + (b"\x81" * 9) + b"\x00") + + +def test_header_block_size_limit_is_enforced() -> None: + block = encode_header_block([(b"x-long", b"a" * 128)]) + with pytest.raises(ProtocolError): + HPACKDecoder(max_header_block_size=16).decode_header_block(block) + + +@pytest.mark.skipif(importlib.util.find_spec("hpack") is None, reason="third-party hpack package is not installed") +def test_differential_roundtrip_against_independent_hpack_library() -> None: + from hpack import Decoder as ExternalDecoder + from hpack import Encoder as ExternalEncoder + + def _external_to_bytes(headers): + converted = [] + for name, value in headers: + converted.append((name.encode("ascii") if isinstance(name, str) else name, value.encode("utf-8") if isinstance(value, str) else value)) + return converted + + sequence = [ + [(b":method", b"GET"), (b":path", b"/"), (b"x-test", b"alpha")], + [(b":method", b"GET"), (b":path", b"/"), (b"x-test", b"alpha")], + [(b":method", b"GET"), (b":path", b"/second"), (b"x-test", b"alpha")], + [(b":method", b"POST"), (b":path", b"/submit"), (b"content-type", b"application/json")], + ] + + external_encoder = ExternalEncoder() + external_decoder = ExternalDecoder() + local_encoder = HPACKEncoder() + local_decoder = HPACKDecoder() + + for headers in sequence: + external_block = external_encoder.encode(headers) + assert local_decoder.decode_header_block(external_block) == headers + + local_block = local_encoder.encode_header_block(headers) + assert _external_to_bytes(external_decoder.decode(local_block)) == headers diff --git a/tests/test_http1_chunked_pytest.py b/tests/test_http1_chunked_pytest.py new file mode 100644 index 00000000..2f6f10a1 --- /dev/null +++ b/tests/test_http1_chunked_pytest.py @@ -0,0 +1,6 @@ +from tigrcorn.protocols.http1.serializer import finalize_chunked_body, serialize_http11_response_chunk + + +def test_chunk_serialization() -> None: + assert serialize_http11_response_chunk(b"hello") == b"5\r\nhello\r\n" + assert finalize_chunked_body() == b"0\r\n\r\n" diff --git a/tests/test_http1_hardening_pass_pytest.py b/tests/test_http1_hardening_pass_pytest.py new file mode 100644 index 00000000..254f04f4 --- /dev/null +++ b/tests/test_http1_hardening_pass_pytest.py @@ -0,0 +1,150 @@ +import asyncio + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.errors import ProtocolError +from tigrcorn.protocols.http1.parser import read_http11_request_head +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.tcp.reader import PrebufferedReader + + +async def _start_server(app): + config = build_config(host="127.0.0.1", port=0, lifespan="off", http_versions=["1.1"]) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.server.sockets[0].getsockname()[1] + return server, port + + +async def _read_response(reader: asyncio.StreamReader, *, expect_body: bool = True) -> tuple[bytes, bytes]: + head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 1.0) + length = 0 + chunked = False + for line in head.split(b"\r\n"): + lower = line.lower() + if lower.startswith(b"content-length:"): + length = int(line.split(b":", 1)[1].strip()) + elif lower.startswith(b"transfer-encoding:") and b"chunked" in lower: + chunked = True + if chunked: + chunks = bytearray() + while True: + size_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), 1.0) + size = int(size_line[:-2], 16) + if size == 0: + await asyncio.wait_for(reader.readuntil(b"\r\n"), 1.0) + return head, bytes(chunks) + chunks.extend(await asyncio.wait_for(reader.readexactly(size), 1.0)) + self_terminator = await asyncio.wait_for(reader.readexactly(2), 1.0) + assert self_terminator == b"\r\n" + body = await asyncio.wait_for(reader.readexactly(length), 1.0) if length and expect_body else b"" + return head, body + + +@pytest.mark.asyncio +async def test_parser_rejects_invalid_header_field_name() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"GET / HTTP/1.1\r\nHost: example.com\r\nBad Header: value\r\n\r\n") + reader.feed_eof() + with pytest.raises(ProtocolError): + await read_http11_request_head(PrebufferedReader(reader)) + + +@pytest.mark.asyncio +async def test_parser_rejects_invalid_header_field_value() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"GET / HTTP/1.1\r\nHost: example.com\r\nX-Test: bad\x00value\r\n\r\n") + reader.feed_eof() + with pytest.raises(ProtocolError): + await read_http11_request_head(PrebufferedReader(reader)) + + +@pytest.mark.asyncio +async def test_http11_emits_informational_response_before_final_response() -> None: + async def app(scope, receive, send): + await receive() + await send({"type": "http.response.start", "status": 103, "headers": [(b"link", b"; rel=preload; as=style")]}) + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + interim = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 1.0) + assert b"103 Early Hints" in interim + assert b"link: ; rel=preload; as=style" in interim.lower() + head, body = await _read_response(reader) + assert b"200 OK" in head + assert body == b"ok" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_head_response_suppresses_body_and_preserves_content_length() -> None: + async def app(scope, receive, send): + await receive() + if scope["method"] == "HEAD": + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"hello", "more_body": False}) + return + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"next", "more_body": False}) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"HEAD /head HTTP/1.1\r\nHost: localhost\r\n\r\n" + b"GET /next HTTP/1.1\r\nHost: localhost\r\n\r\n" + ) + await writer.drain() + head, body = await _read_response(reader, expect_body=False) + assert b"200 OK" in head + assert b"content-length: 5" in head.lower() + assert body == b"" + head2, body2 = await _read_response(reader) + assert b"200 OK" in head2 + assert body2 == b"next" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_pipelined_requests_and_204_no_body_do_not_desynchronize_connection() -> None: + async def app(scope, receive, send): + await receive() + if scope["path"] == "/empty": + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b"should-not-be-sent", "more_body": False}) + return + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": scope["path"].encode("ascii"), "more_body": False}) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"GET /empty HTTP/1.1\r\nHost: localhost\r\n\r\n" + b"GET /after HTTP/1.1\r\nHost: localhost\r\n\r\n" + ) + await writer.drain() + head1, body1 = await _read_response(reader) + assert b"204 No Content" in head1 + assert b"should-not-be-sent" not in head1 + assert body1 == b"" + head2, body2 = await _read_response(reader) + assert b"200 OK" in head2 + assert body2 == b"/after" + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_http1_parser_pytest.py b/tests/test_http1_parser_pytest.py new file mode 100644 index 00000000..a94e17e5 --- /dev/null +++ b/tests/test_http1_parser_pytest.py @@ -0,0 +1,18 @@ +import asyncio + +import pytest + +from tigrcorn.protocols.http1.parser import read_http11_request +from tigrcorn.transports.tcp.reader import PrebufferedReader + + +@pytest.mark.asyncio +async def test_simple_request() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"GET /hello?x=1 HTTP/1.1\r\nHost: example\r\nContent-Length: 0\r\n\r\n") + reader.feed_eof() + req = await read_http11_request(PrebufferedReader(reader)) + assert req is not None + assert req.method == "GET" + assert req.path == "/hello" + assert req.query_string == b"x=1" diff --git a/tests/test_http1_rfc9112_pytest.py b/tests/test_http1_rfc9112_pytest.py new file mode 100644 index 00000000..12570152 --- /dev/null +++ b/tests/test_http1_rfc9112_pytest.py @@ -0,0 +1,140 @@ +import asyncio + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.errors import ProtocolError, UnsupportedFeature +from tigrcorn.protocols.http1.parser import read_http11_request_head +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.tcp.reader import PrebufferedReader + + +async def _start_server(app): + config = build_config(host="127.0.0.1", port=0, lifespan="off", http_versions=["1.1"]) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.server.sockets[0].getsockname()[1] + return server, port + + +async def _read_response(reader: asyncio.StreamReader) -> tuple[bytes, bytes]: + head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 1.0) + length = 0 + for line in head.split(b"\r\n"): + if line.lower().startswith(b"content-length:"): + length = int(line.split(b":", 1)[1].strip()) + break + body = await asyncio.wait_for(reader.readexactly(length), 1.0) if length else b"" + return head, body + + +@pytest.mark.asyncio +async def test_absolute_form_request_target() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"GET http://example.com/alpha?x=1 HTTP/1.1\r\nHost: example.com\r\n\r\n") + reader.feed_eof() + request = await read_http11_request_head(PrebufferedReader(reader)) + assert request is not None + assert request.path == "/alpha" + assert request.query_string == b"x=1" + assert request.target_form == "absolute" + + +@pytest.mark.asyncio +async def test_asterisk_form_restricted_to_options() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"GET * HTTP/1.1\r\nHost: example.com\r\n\r\n") + reader.feed_eof() + with pytest.raises(ProtocolError): + await read_http11_request_head(PrebufferedReader(reader)) + + +@pytest.mark.asyncio +async def test_missing_host_rejected_for_http11() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"GET / HTTP/1.1\r\n\r\n") + reader.feed_eof() + with pytest.raises(ProtocolError): + await read_http11_request_head(PrebufferedReader(reader)) + + +@pytest.mark.asyncio +async def test_unsupported_transfer_encoding_chain_rejected() -> None: + reader = asyncio.StreamReader() + reader.feed_data( + b"POST /upload HTTP/1.1\r\n" + b"Host: example.com\r\n" + b"Transfer-Encoding: gzip, chunked\r\n\r\n" + ) + reader.feed_eof() + with pytest.raises(UnsupportedFeature): + await read_http11_request_head(PrebufferedReader(reader)) + + +@pytest.mark.asyncio +async def test_expect_continue_sent_on_first_receive() -> None: + async def app(scope, receive, send): + assert scope["path"] == "/upload" + event = await receive() + assert event["body"] == b"hello" + assert not event["more_body"] + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"done", "more_body": False}) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"POST /upload HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Expect: 100-continue\r\n" + b"Content-Length: 5\r\n\r\n" + ) + await writer.drain() + interim = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 1.0) + assert b"100 Continue" in interim + writer.write(b"hello") + await writer.drain() + head, body = await _read_response(reader) + assert b"200 OK" in head + assert body == b"done" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_chunked_request_body_is_streamed_as_multiple_events() -> None: + seen_events: list[tuple[bytes, bool]] = [] + + async def app(scope, receive, send): + while True: + event = await receive() + seen_events.append((event["body"], event["more_body"])) + if not event["more_body"]: + break + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"POST /stream HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"3\r\nhel\r\n" + b"2\r\nlo\r\n" + b"0\r\nX-Trailer: yes\r\n\r\n" + ) + await writer.drain() + head, body = await _read_response(reader) + assert b"200 OK" in head + assert body == b"ok" + assert seen_events == [(b"hel", True), (b"lo", True), (b"", False)] + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_http2_hpack_pytest.py b/tests/test_http2_hpack_pytest.py new file mode 100644 index 00000000..5004abc8 --- /dev/null +++ b/tests/test_http2_hpack_pytest.py @@ -0,0 +1,37 @@ +from tigrcorn.protocols.http2.codec import ( + FrameBuffer, + FrameWriter, + decode_settings, + serialize_settings, + serialize_window_update, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block + + +def test_hpack_roundtrip() -> None: + headers = [(b":method", b"GET"), (b":path", b"/"), (b"content-type", b"text/plain")] + encoded = encode_header_block(headers) + assert decode_header_block(encoded) == headers + + +def test_frame_buffer_roundtrip() -> None: + writer = FrameWriter(max_frame_size=8) + raw = writer.headers( + 1, encode_header_block([(b":status", b"200")]), end_stream=False + ) + raw += writer.data(1, b"hello-world", end_stream=True) + buf = FrameBuffer() + buf.feed(raw) + frames = buf.pop_all() + assert len(frames) >= 2 + assert frames[0].stream_id == 1 + + +def test_settings_and_window_update() -> None: + raw = serialize_settings({1: 1024, 4: 65535}) + buf = FrameBuffer() + buf.feed(raw) + frame = buf.pop_all()[0] + settings = decode_settings(frame.payload) + assert settings[1] == 1024 + assert serialize_window_update(1, 1)[3] == 8 diff --git a/tests/test_http2_rfc9113_pytest.py b/tests/test_http2_rfc9113_pytest.py new file mode 100644 index 00000000..17f4fb8c --- /dev/null +++ b/tests/test_http2_rfc9113_pytest.py @@ -0,0 +1,133 @@ +import asyncio + +import pytest + +from tigrcorn.config.defaults import default_config +from tigrcorn.errors import ProtocolError +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.protocols.http2.codec import ( + FLAG_END_HEADERS, + FRAME_DATA, + FRAME_HEADERS, + HTTP2Frame, + decode_settings, +) +from tigrcorn.protocols.http2.handler import HTTP2ConnectionHandler +from tigrcorn.protocols.http2.state import H2StreamState + + +class _DummyReader: + async def readexactly(self, n: int) -> bytes: + raise EOFError + + +class _DummyWriter: + def write(self, data: bytes) -> None: + return None + + async def drain(self) -> None: + return None + + +def _handler() -> HTTP2ConnectionHandler: + async def app(scope, receive, send): + return None + + return HTTP2ConnectionHandler( + app=app, + config=default_config(), + access_logger=AccessLogger(configure_logging("warning"), enabled=False), + reader=_DummyReader(), + writer=_DummyWriter(), + client=None, + server=None, + scheme="http", + ) + + +def test_reject_duplicate_pseudo_header() -> None: + handler = _handler() + state = H2StreamState(1) + state.headers = [ + (b":method", b"GET"), + (b":method", b"POST"), + (b":path", b"/"), + (b":scheme", b"http"), + ] + with pytest.raises(ProtocolError): + handler._build_request(state) + + +def test_reject_pseudo_after_regular() -> None: + handler = _handler() + state = H2StreamState(1) + state.headers = [ + (b":method", b"GET"), + (b"host", b"example"), + (b":path", b"/"), + (b":scheme", b"http"), + ] + with pytest.raises(ProtocolError): + handler._build_request(state) + + +def test_reject_invalid_connection_header() -> None: + handler = _handler() + state = H2StreamState(1) + state.headers = [ + (b":method", b"GET"), + (b":path", b"/"), + (b":scheme", b"http"), + (b"connection", b"close"), + ] + with pytest.raises(ProtocolError): + handler._build_request(state) + + +def test_reject_invalid_te_header() -> None: + handler = _handler() + state = H2StreamState(1) + state.headers = [ + (b":method", b"GET"), + (b":path", b"/"), + (b":scheme", b"http"), + (b"te", b"gzip"), + ] + with pytest.raises(ProtocolError): + handler._build_request(state) + + +def test_reject_uppercase_header_field_name() -> None: + handler = _handler() + state = H2StreamState(1) + state.headers = [ + (b":method", b"GET"), + (b":path", b"/"), + (b":scheme", b"http"), + (b"Content-Type", b"text/plain"), + ] + with pytest.raises(ProtocolError): + handler._build_request(state) + + +def test_reject_even_numbered_request_stream() -> None: + handler = _handler() + frame = HTTP2Frame( + frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=2, payload=b"" + ) + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_headers(frame)) + + +def test_reject_data_before_headers() -> None: + handler = _handler() + frame = HTTP2Frame(frame_type=FRAME_DATA, flags=0, stream_id=1, payload=b"data") + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_data(frame)) + + +def test_reject_invalid_settings_values() -> None: + with pytest.raises(ProtocolError): + decode_settings((0x5).to_bytes(2, "big") + (16000).to_bytes(4, "big")) + with pytest.raises(ProtocolError): + decode_settings((0x4).to_bytes(2, "big") + (0x80000000).to_bytes(4, "big")) diff --git a/tests/test_http2_server_push_surface_pytest.py b/tests/test_http2_server_push_surface_pytest.py new file mode 100644 index 00000000..231785cf --- /dev/null +++ b/tests/test_http2_server_push_surface_pytest.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import ( + FLAG_END_STREAM, + FRAME_DATA, + FRAME_HEADERS, + FRAME_PUSH_PROMISE, + FrameBuffer, + FrameWriter, + HTTP2Frame, + SETTING_ENABLE_PUSH, + parse_push_promise, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import HPACKDecoder, HPACKEncoder +from tigrcorn.server.runner import TigrCornServer + + +async def _start_http2_server(app): + config = build_config(host="127.0.0.1", port=0, lifespan="off", http_versions=["2"]) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +def _request_headers(path: bytes) -> bytes: + encoder = HPACKEncoder() + return encoder.encode_header_block( + [ + (b":method", b"GET"), + (b":path", path), + (b":scheme", b"http"), + (b":authority", b"example"), + ] + ) + + +async def _collect_frames(reader: asyncio.StreamReader, *, need_push: bool) -> list[HTTP2Frame]: + buffer = FrameBuffer() + frames: list[HTTP2Frame] = [] + deadline = asyncio.get_running_loop().time() + 2.0 + while asyncio.get_running_loop().time() < deadline: + try: + chunk = await asyncio.wait_for(reader.read(65535), 0.1) + except asyncio.TimeoutError: + chunk = b"" + if chunk: + buffer.feed(chunk) + frames.extend(buffer.pop_all()) + have_root_end = any( + frame.frame_type == FRAME_DATA + and frame.stream_id == 1 + and bool(frame.flags & FLAG_END_STREAM) + for frame in frames + ) + have_push = any(frame.frame_type == FRAME_PUSH_PROMISE for frame in frames) + have_pushed_end = any( + frame.frame_type == FRAME_DATA + and frame.stream_id == 2 + and bool(frame.flags & FLAG_END_STREAM) + for frame in frames + ) + if have_root_end and ((not need_push) or (have_push and have_pushed_end)): + return frames + return frames + + +@pytest.mark.asyncio +async def test_http2_server_push_emits_push_promise_and_promised_response() -> None: + async def app(scope, receive, send): + assert scope["type"] == "http" + if scope["path"] == "/": + assert "http.response.push" in scope["extensions"] + await send({"type": "http.response.push", "path": "/pushed"}) + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"root"}) + return + if scope["path"] == "/pushed": + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"pushed"}) + return + raise AssertionError(f"unexpected path: {scope['path']}") + + server, port = await _start_http2_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({SETTING_ENABLE_PUSH: 1})) + writer.write(frame_writer.headers(1, _request_headers(b"/"), end_stream=True)) + await writer.drain() + + frames = await _collect_frames(reader, need_push=True) + + push_frame = next(frame for frame in frames if frame.frame_type == FRAME_PUSH_PROMISE) + promised_stream_id, promised_block = parse_push_promise(push_frame.payload, push_frame.flags) + assert promised_stream_id == 2 + + decoder = HPACKDecoder() + decoded_push_request = decoder.decode_header_block(promised_block) + assert (b":path", b"/pushed") in decoded_push_request + assert (b":method", b"GET") in decoded_push_request + + root_headers = None + pushed_headers = None + root_body = bytearray() + pushed_body = bytearray() + for frame in frames: + if frame.frame_type == FRAME_HEADERS and frame.stream_id == 1: + root_headers = decoder.decode_header_block(frame.payload) + elif frame.frame_type == FRAME_HEADERS and frame.stream_id == promised_stream_id: + pushed_headers = decoder.decode_header_block(frame.payload) + elif frame.frame_type == FRAME_DATA and frame.stream_id == 1: + root_body.extend(frame.payload) + elif frame.frame_type == FRAME_DATA and frame.stream_id == promised_stream_id: + pushed_body.extend(frame.payload) + + assert root_headers is not None + assert pushed_headers is not None + assert (b":status", b"200") in root_headers + assert (b":status", b"200") in pushed_headers + assert bytes(root_body) == b"root" + assert bytes(pushed_body) == b"pushed" + + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_server_push_is_not_advertised_when_client_disables_push() -> None: + async def app(scope, receive, send): + assert scope["type"] == "http" + assert "http.response.push" not in scope["extensions"] + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"root"}) + + server, port = await _start_http2_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({SETTING_ENABLE_PUSH: 0})) + writer.write(frame_writer.headers(1, _request_headers(b"/"), end_stream=True)) + await writer.drain() + + frames = await _collect_frames(reader, need_push=False) + assert not any(frame.frame_type == FRAME_PUSH_PROMISE for frame in frames) + assert any(frame.frame_type == FRAME_DATA and frame.stream_id == 1 for frame in frames) + + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_http2_state_machine_completion_pytest.py b/tests/test_http2_state_machine_completion_pytest.py new file mode 100644 index 00000000..b0ba382b --- /dev/null +++ b/tests/test_http2_state_machine_completion_pytest.py @@ -0,0 +1,202 @@ +import asyncio + +from tigrcorn.config.defaults import default_config +from tigrcorn.errors import ProtocolError +from tigrcorn.observability.logging import AccessLogger, configure_logging +import pytest +from tigrcorn.protocols.http2.codec import ( + FLAG_ACK, + FLAG_END_HEADERS, + FLAG_END_STREAM, + FRAME_CONTINUATION, + FRAME_DATA, + FRAME_GOAWAY, + FRAME_HEADERS, + FRAME_PRIORITY, + FRAME_PUSH_PROMISE, + FRAME_SETTINGS, + FRAME_WINDOW_UPDATE, + FrameBuffer, + HTTP2Frame, + parse_goaway, + serialize_goaway, +) +from tigrcorn.protocols.http2.handler import HTTP2ConnectionHandler +from tigrcorn.protocols.http2.hpack import encode_header_block +from tigrcorn.protocols.http2.state import H2StreamLifecycle, H2StreamState + + +class _DummyReader: + async def readexactly(self, n: int) -> bytes: + raise EOFError + + +class _CapturingWriter: + def __init__(self) -> None: + self.writes: list[bytes] = [] + + def write(self, data: bytes) -> None: + self.writes.append(data) + + async def drain(self) -> None: + return None + + + +def _handler() -> HTTP2ConnectionHandler: + async def app(scope, receive, send): + return None + + return HTTP2ConnectionHandler( + app=app, + config=default_config(), + access_logger=AccessLogger(configure_logging("warning"), enabled=False), + reader=_DummyReader(), + writer=_CapturingWriter(), + client=None, + server=None, + scheme="http", + ) + +def _request_headers(*, method: bytes = b"GET") -> bytes: + return encode_header_block([ + (b":method", method), + (b":path", b"/"), + (b":scheme", b"http"), + (b":authority", b"example"), + ]) + +def test_stream_lifecycle_transitions_are_explicit(): + state = H2StreamState(1) + assert state.lifecycle == H2StreamLifecycle.IDLE + state.open_remote(end_stream=False) + assert state.lifecycle == H2StreamLifecycle.OPEN + state.receive_end_stream() + assert state.lifecycle == H2StreamLifecycle.HALF_CLOSED_REMOTE + state.send_end_stream() + assert state.lifecycle == H2StreamLifecycle.CLOSED + assert state.closed +def test_reserved_local_stream_transitions_are_explicit(): + state = H2StreamState(2) + state.reserve_local() + assert state.lifecycle == H2StreamLifecycle.RESERVED_LOCAL + state.open_local_reserved(end_stream=False) + assert state.lifecycle == H2StreamLifecycle.HALF_CLOSED_REMOTE + state.send_end_stream() + assert state.lifecycle == H2StreamLifecycle.CLOSED + assert state.closed +def test_first_frame_after_preface_must_be_settings(): + handler = _handler() + frame = HTTP2Frame(frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=1, payload=b"") + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_frame(frame)) + +def test_max_concurrent_streams_are_enforced(): + handler = _handler() + handler.state.remote_settings_seen = True + handler.state.local_settings[0x3] = 1 + first = HTTP2Frame(frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=1, payload=_request_headers()) + second = HTTP2Frame(frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=3, payload=_request_headers()) + asyncio.run(handler._handle_headers(first)) + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_headers(second)) + +def test_continuation_does_not_interpret_end_stream_flag(): + handler = _handler() + handler.state.remote_settings_seen = True + first = HTTP2Frame( + frame_type=FRAME_HEADERS, + flags=0, + stream_id=1, + payload=_request_headers(), + ) + asyncio.run(handler._handle_headers(first)) + state = handler.streams.find(1) + assert state is not None + assert state.awaiting_continuation + cont = HTTP2Frame( + frame_type=FRAME_CONTINUATION, + flags=FLAG_END_HEADERS | FLAG_END_STREAM, + stream_id=1, + payload=b"", + ) + asyncio.run(handler._handle_continuation(cont)) + state = handler.streams.find(1) + assert state is not None + assert not (state.end_stream_received) + assert state.lifecycle == H2StreamLifecycle.OPEN +def test_priority_self_dependency_is_rejected(): + handler = _handler() + handler.state.remote_settings_seen = True + payload = (1).to_bytes(4, "big") + bytes([16]) + frame = HTTP2Frame(frame_type=FRAME_PRIORITY, flags=0, stream_id=1, payload=payload) + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_frame(frame)) + +def test_client_push_promise_is_rejected(): + handler = _handler() + handler.state.remote_settings_seen = True + frame = HTTP2Frame(frame_type=FRAME_PUSH_PROMISE, flags=0, stream_id=1, payload=b"\x00\x00\x00\x02") + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_frame(frame)) + +def test_window_update_is_thresholded_not_immediate(): + handler = _handler() + handler.state.remote_settings_seen = True + writer = handler.writer + headers = HTTP2Frame(frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=1, payload=_request_headers()) + asyncio.run(handler._handle_headers(headers)) + data_small = HTTP2Frame(frame_type=FRAME_DATA, flags=0, stream_id=1, payload=b"a" * 32_000) + asyncio.run(handler._handle_data(data_small)) + assert writer.writes == [] + data_threshold = HTTP2Frame(frame_type=FRAME_DATA, flags=0, stream_id=1, payload=b"b" * 1_000) + asyncio.run(handler._handle_data(data_threshold)) + buf = FrameBuffer() + for raw in writer.writes: + buf.feed(raw) + frames = buf.pop_all() + assert [frame.frame_type for frame in frames] == [FRAME_WINDOW_UPDATE, FRAME_WINDOW_UPDATE] + assert frames[0].stream_id == 0 + assert frames[1].stream_id == 1 +def test_stream_receive_flow_control_overflow_is_rejected(): + handler = _handler() + handler.state.remote_settings_seen = True + headers = HTTP2Frame(frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=1, payload=_request_headers()) + asyncio.run(handler._handle_headers(headers)) + state = handler.streams.find(1) + assert state is not None + state.receive_window.available = 8 + frame = HTTP2Frame(frame_type=FRAME_DATA, flags=0, stream_id=1, payload=b"0123456789") + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_data(frame)) + +def test_goaway_last_stream_id_must_not_increase(): + handler = _handler() + handler.state.remote_settings_seen = True + first = HTTP2Frame(frame_type=FRAME_GOAWAY, flags=0, stream_id=0, payload=serialize_goaway(5)[9:]) + second = HTTP2Frame(frame_type=FRAME_GOAWAY, flags=0, stream_id=0, payload=serialize_goaway(7)[9:]) + asyncio.run(handler._handle_frame(first)) + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_frame(second)) + +def test_new_stream_after_peer_goaway_is_rejected(): + handler = _handler() + handler.state.remote_settings_seen = True + goaway = HTTP2Frame(frame_type=FRAME_GOAWAY, flags=0, stream_id=0, payload=serialize_goaway(0)[9:]) + asyncio.run(handler._handle_frame(goaway)) + frame = HTTP2Frame(frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=1, payload=_request_headers()) + with pytest.raises(ProtocolError): + asyncio.run(handler._handle_headers(frame)) + +def test_window_update_on_closed_stream_is_ignored(): + handler = _handler() + handler.state.remote_settings_seen = True + headers = HTTP2Frame(frame_type=FRAME_HEADERS, flags=FLAG_END_HEADERS, stream_id=1, payload=_request_headers()) + asyncio.run(handler._handle_headers(headers)) + handler.streams.close(1) + frame = HTTP2Frame(frame_type=FRAME_WINDOW_UPDATE, flags=0, stream_id=1, payload=(1).to_bytes(4, "big")) + asyncio.run(handler._handle_frame(frame)) + +def test_goaway_payload_roundtrip_used_by_handler(): + last_stream_id, error_code, debug = parse_goaway(serialize_goaway(3, error_code=2, debug_data=b"dbg")[9:]) + assert (last_stream_id == error_code, debug), (3, 2, b"dbg") diff --git a/tests/test_http2_websocket_rfc8441_pytest.py b/tests/test_http2_websocket_rfc8441_pytest.py new file mode 100644 index 00000000..5ab2ee77 --- /dev/null +++ b/tests/test_http2_websocket_rfc8441_pytest.py @@ -0,0 +1,85 @@ +import asyncio + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, decode_settings, serialize_settings +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.websocket.frames import encode_frame, parse_frame_bytes +from tigrcorn.server.runner import TigrCornServer + + +import pytest +async def _start_server(app): + config = build_config(host='127.0.0.1', port=0, lifespan='off', http_versions=['2']) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + + +async def test_extended_connect_websocket_roundtrip(): + seen = {} + + async def app(scope, receive, send): + assert scope['type'] == 'websocket' + assert scope['http_version'] == '2' + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.accept', 'subprotocol': 'chat', 'headers': []}) + event = await receive() + seen['text'] = event['text'] + await send({'type': 'websocket.send', 'text': event['text']}) + await send({'type': 'websocket.close', 'code': 1000}) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + header_block = encode_header_block([ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'http'), + (b':path', b'/chat'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + (b'sec-websocket-protocol', b'chat'), + ]) + writer.write(frame_writer.headers(1, header_block, end_stream=False)) + writer.write(frame_writer.data(1, encode_frame(0x1, b'hello-h2-ws', masked=True), end_stream=False)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + ws_data = bytearray() + end_stream = False + while not end_stream: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + end_stream = True + elif frame.frame_type == FRAME_DATA: + ws_data.extend(frame.payload) + if frame.flags & 0x1: + end_stream = True + if response_headers and ws_data and end_stream: + break + + assert (b':status' in b'200'), response_headers + assert (b'sec-websocket-protocol' in b'chat'), response_headers + frame = parse_frame_bytes(bytes(ws_data), expect_masked=False) + assert frame.payload.decode('utf-8') == 'hello-h2-ws' + assert seen['text'] == 'hello-h2-ws' + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_http3_request_stream_state_machine_pytest.py b/tests/test_http3_request_stream_state_machine_pytest.py new file mode 100644 index 00000000..0b88eee2 --- /dev/null +++ b/tests/test_http3_request_stream_state_machine_pytest.py @@ -0,0 +1,218 @@ +import asyncio +import socket + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.protocols.http3 import ( + H3_FRAME_UNEXPECTED, + H3_ID_ERROR, + H3_MESSAGE_ERROR, + H3_MISSING_SETTINGS, + H3_SETTINGS_ERROR, + HTTP3ConnectionCore, + HTTP3ConnectionError, + HTTP3StreamError, + encode_field_section, + encode_frame, +) +from tigrcorn.protocols.http3.codec import ( + FRAME_DATA, + FRAME_GOAWAY, + FRAME_HEADERS, + FRAME_PUSH_PROMISE, + FRAME_SETTINGS, + STREAM_TYPE_CONTROL, +) +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.utils.bytes import encode_quic_varint + + +async def _start_h3_server(app): + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.transport.get_extra_info('sockname')[1] + return server, port + + +def test_request_stream_rejects_data_before_headers(): + core = HTTP3ConnectionCore(role='server') + with pytest.raises(HTTP3ConnectionError) as ctx: + core.receive_stream_data(0, encode_frame(FRAME_DATA, b'body')) + assert ctx.value.error_code == H3_FRAME_UNEXPECTED + + +def test_request_stream_rejects_data_after_trailers(): + core = HTTP3ConnectionCore(role='server') + payload = ( + encode_frame( + FRAME_HEADERS, + encode_field_section( + [(b':method', b'GET'), (b':path', b'/'), (b':scheme', b'https')] + ), + ) + + encode_frame(FRAME_HEADERS, encode_field_section([(b'checksum', b'ok')])) + + encode_frame(FRAME_DATA, b'x') + ) + with pytest.raises(HTTP3ConnectionError) as ctx: + core.receive_stream_data(0, payload) + assert ctx.value.error_code == H3_FRAME_UNEXPECTED + + +def test_request_stream_content_length_mismatch_is_stream_error(): + core = HTTP3ConnectionCore(role='server') + payload = ( + encode_frame( + FRAME_HEADERS, + encode_field_section( + [ + (b':method', b'POST'), + (b':path', b'/upload'), + (b':scheme', b'https'), + (b'content-length', b'4'), + ] + ), + ) + + encode_frame(FRAME_DATA, b'abc') + ) + with pytest.raises(HTTP3StreamError) as ctx: + core.receive_stream_data(0, payload, fin=True) + assert ctx.value.error_code == H3_MESSAGE_ERROR + + +def test_unknown_frame_type_is_ignored_on_request_stream(): + core = HTTP3ConnectionCore(role='server') + payload = ( + encode_frame( + FRAME_HEADERS, + encode_field_section( + [ + (b':method', b'POST'), + (b':path', b'/ok'), + (b':scheme', b'https'), + (b'content-length', b'1'), + ] + ), + ) + + encode_frame(0x21, b'padding') + + encode_frame(FRAME_DATA, b'a') + ) + state = core.receive_stream_data(0, payload, fin=True) + assert state.body == b'a' + assert state.ready + + +def test_control_stream_requires_settings_first(): + core = HTTP3ConnectionCore(role='client') + payload = encode_quic_varint(STREAM_TYPE_CONTROL) + encode_frame( + FRAME_GOAWAY, encode_quic_varint(0) + ) + with pytest.raises(HTTP3ConnectionError) as ctx: + core.receive_stream_data(3, payload) + assert ctx.value.error_code == H3_MISSING_SETTINGS + + +def test_reserved_settings_are_rejected(): + core = HTTP3ConnectionCore(role='client') + reserved_payload = encode_quic_varint(0x02) + encode_quic_varint(1) + payload = encode_quic_varint(STREAM_TYPE_CONTROL) + encode_frame( + FRAME_SETTINGS, reserved_payload + ) + with pytest.raises(HTTP3ConnectionError) as ctx: + core.receive_stream_data(3, payload) + assert ctx.value.error_code == H3_SETTINGS_ERROR + + +def test_push_promise_is_forbidden_on_server_request_stream(): + core = HTTP3ConnectionCore(role='server') + promise_payload = encode_quic_varint(0) + encode_field_section( + [(b':method', b'GET'), (b':path', b'/pushed')] + ) + payload = ( + encode_frame( + FRAME_HEADERS, + encode_field_section( + [(b':method', b'GET'), (b':path', b'/'), (b':scheme', b'https')] + ), + ) + + encode_frame(FRAME_PUSH_PROMISE, promise_payload) + ) + with pytest.raises(HTTP3ConnectionError) as ctx: + core.receive_stream_data(0, payload) + assert ctx.value.error_code == H3_FRAME_UNEXPECTED + + +def test_goaway_identifier_must_not_increase(): + core = HTTP3ConnectionCore(role='client') + payload = ( + encode_quic_varint(STREAM_TYPE_CONTROL) + + encode_frame(FRAME_SETTINGS, b'') + + encode_frame(FRAME_GOAWAY, encode_quic_varint(16)) + + encode_frame(FRAME_GOAWAY, encode_quic_varint(20)) + ) + with pytest.raises(HTTP3ConnectionError) as ctx: + core.receive_stream_data(3, payload) + assert ctx.value.error_code == H3_ID_ERROR + + +@pytest.mark.asyncio +async def test_server_resets_stream_for_malformed_request(): + app_called = False + + async def app(scope, receive, send): + nonlocal app_called + app_called = True + raise AssertionError('malformed request should not reach the app') + + server, port = await _start_h3_server(app) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1') + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + client.receive_datagram(data) + bad_request = ( + encode_frame( + FRAME_HEADERS, + encode_field_section( + [ + (b':method', b'POST'), + (b':path', b'/bad'), + (b':scheme', b'https'), + (b'content-length', b'4'), + ] + ), + ) + + encode_frame(FRAME_DATA, b'abc') + ) + sock.sendto(client.send_stream_data(0, bad_request, fin=True), ('127.0.0.1', port)) + reset_event = None + for _ in range(6): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'reset_stream': + reset_event = event + break + if reset_event is not None: + break + assert reset_event is not None + assert reset_event.stream_id == 0 + assert reset_event.detail.error_code == H3_MESSAGE_ERROR + assert not app_called + finally: + sock.close() + await server.close() diff --git a/tests/test_http3_rfc9114_pytest.py b/tests/test_http3_rfc9114_pytest.py new file mode 100644 index 00000000..1bb94f5a --- /dev/null +++ b/tests/test_http3_rfc9114_pytest.py @@ -0,0 +1,57 @@ + +from tigrcorn.config.defaults import default_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.errors import ProtocolError +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.protocols.http3.codec import FRAME_GOAWAY, FRAME_SETTINGS, STREAM_TYPE_CONTROL, decode_frame, decode_settings +from tigrcorn.protocols.http3.handler import HTTP3DatagramHandler +from tigrcorn.protocols.http3.streams import HTTP3ConnectionCore +from tigrcorn.utils.bytes import decode_quic_varint, encode_quic_varint + + +import pytest + +def test_control_stream_prefix_and_settings(): + core = HTTP3ConnectionCore() + payload = core.encode_control_stream({1: 0, 6: 1200}) + stream_type, offset = decode_quic_varint(payload, 0) + assert stream_type == STREAM_TYPE_CONTROL + frame, _ = decode_frame(payload, offset) + assert frame.frame_type == FRAME_SETTINGS + assert decode_settings(frame.payload) == {1: 0, 6: 1200} +def test_goaway_uses_varint_payload(): + core = HTTP3ConnectionCore() + raw = core.encode_goaway(33) + frame, _ = decode_frame(raw, 0) + assert frame.frame_type == FRAME_GOAWAY + stream_id, _ = decode_quic_varint(frame.payload, 0) + assert stream_id == 33 +def test_decode_frame_rejects_truncation(): + with pytest.raises(ProtocolError): + decode_frame(encode_quic_varint(1) + encode_quic_varint(5) + b'ab') + +def test_validate_request_headers_rejects_duplicates(): + async def app(scope, receive, send): + return None + + handler = HTTP3DatagramHandler( + app=app, + config=default_config(), + listener=ListenerConfig(kind='udp', host='127.0.0.1', port=1, protocols=['http3']), + access_logger=AccessLogger(configure_logging('warning'), enabled=False), + ) + with pytest.raises(ProtocolError): + handler._validate_request_headers([(b':method', b'GET'), (b':method', b'POST'), (b':path', b'/'), (b':scheme', b'https')]) + +def test_validate_request_headers_rejects_connection_specific(): + async def app(scope, receive, send): + return None + + handler = HTTP3DatagramHandler( + app=app, + config=default_config(), + listener=ListenerConfig(kind='udp', host='127.0.0.1', port=1, protocols=['http3']), + access_logger=AccessLogger(configure_logging('warning'), enabled=False), + ) + with pytest.raises(ProtocolError): + handler._validate_request_headers([(b':method', b'GET'), (b':path', b'/'), (b':scheme', b'https'), (b'connection', b'close')]) diff --git a/tests/test_http3_server_pytest.py b/tests/test_http3_server_pytest.py new file mode 100644 index 00000000..a1e2ce26 --- /dev/null +++ b/tests/test_http3_server_pytest.py @@ -0,0 +1,74 @@ +import asyncio +import socket + +from tigrcorn.config.load import build_config +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +import pytest +async def _start_server(app): + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.transport.get_extra_info('sockname')[1] + return server, port + + + +async def test_http3_roundtrip(): + async def app(scope, receive, send): + assert scope['type'] == 'http' + assert scope['http_version'] == '3' + assert scope['path'] == '/h3' + event = await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'echo:' + event['body'], 'more_body': False}) + + server, port = await _start_server(app) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + pre_events = [] + for _ in range(2): + data, _addr = await loop.sock_recvfrom(sock, 65535) + pre_events.extend(client.receive_datagram(data)) + assert any(event.kind == 'ack' for event in pre_events) + control_streams = [event for event in pre_events if event.kind == 'stream'] + assert len(control_streams) == 1 + assert core.receive_stream_data(control_streams[0].stream_id, control_streams[0].data, fin=control_streams[0].fin) is None + payload = core.get_request(0).encode_request( + [(b':method', b'POST'), (b':path', b'/h3'), (b':scheme', b'https')], + b'hello', + ) + sock.sendto(client.send_stream_data(0, payload, fin=True), ('127.0.0.1', port)) + response_state = None + for _ in range(3): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + break + if response_state is not None: + break + assert response_state is not None + assert response_state is not None + assert (b':status' in b'200'), response_state.headers + assert response_state.body == b'echo:hello' + finally: + sock.close() + await server.close() diff --git a/tests/test_http3_websocket_rfc9220_pytest.py b/tests/test_http3_websocket_rfc9220_pytest.py new file mode 100644 index 00000000..1aad2439 --- /dev/null +++ b/tests/test_http3_websocket_rfc9220_pytest.py @@ -0,0 +1,174 @@ +import asyncio +import socket + +from tigrcorn.config.load import build_config +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.http3.codec import SETTING_ENABLE_CONNECT_PROTOCOL +from tigrcorn.protocols.websocket.frames import decode_close_payload, encode_frame, parse_frame_bytes +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +import pytest +async def _start_server(app): + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.transport.get_extra_info('sockname')[1] + return server, port + + +def _frame_wire_length(data: bytes) -> int: + if len(data) < 2: + raise AssertionError('websocket frame is truncated') + masked = bool(data[1] & 0x80) + length = data[1] & 0x7F + pos = 2 + if length == 126: + if len(data) < pos + 2: + raise AssertionError('websocket frame is truncated') + length = int.from_bytes(data[pos:pos + 2], 'big') + pos += 2 + elif length == 127: + if len(data) < pos + 8: + raise AssertionError('websocket frame is truncated') + length = int.from_bytes(data[pos:pos + 8], 'big') + pos += 8 + if masked: + pos += 4 + total = pos + length + if len(data) < total: + raise AssertionError('websocket frame is truncated') + return total + + + +async def test_extended_connect_websocket_roundtrip(): + seen = {} + + async def app(scope, receive, send): + assert scope['type'] == 'websocket' + assert scope['http_version'] == '3' + assert scope['path'] == '/chat' + assert scope['scheme'] == 'wss' + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.accept', 'subprotocol': 'chat', 'headers': []}) + event = await receive() + seen['text'] = event['text'] + await send({'type': 'websocket.send', 'text': event['text']}) + await send({'type': 'websocket.close', 'code': 1000}) + + server, port = await _start_server(app) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(4): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if core.state.remote_settings.get(SETTING_ENABLE_CONNECT_PROTOCOL) == 1: + break + + assert core.state.remote_settings.get(SETTING_ENABLE_CONNECT_PROTOCOL) == 1 + payload = core.get_request(0).encode_request( + [ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'https'), + (b':path', b'/chat'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + (b'sec-websocket-protocol', b'chat'), + ], + encode_frame(0x1, b'hello-h3-ws', masked=True), + ) + sock.sendto(client.send_stream_data(0, payload, fin=False), ('127.0.0.1', port)) + + response_state = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if response_state is not None and response_state.ended: + break + + assert response_state is not None + assert response_state is not None + assert (b':status' in b'200'), response_state.headers + assert (b'sec-websocket-protocol' in b'chat'), response_state.headers + assert seen['text'] == 'hello-h3-ws' + first_len = _frame_wire_length(response_state.body) + message_frame = parse_frame_bytes(response_state.body[:first_len], expect_masked=False) + assert message_frame.payload.decode('utf-8') == 'hello-h3-ws' + close_frame = parse_frame_bytes(response_state.body[first_len:], expect_masked=False) + code, reason = decode_close_payload(close_frame.payload) + assert code == 1000 + assert reason == '' + finally: + sock.close() + await server.close() + +async def test_unknown_extended_connect_protocol_returns_501(): + async def app(scope, receive, send): + raise AssertionError('unsupported extended CONNECT should not dispatch to the ASGI app') + + server, port = await _start_server(app) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli2') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(4): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if core.state.remote_settings.get(SETTING_ENABLE_CONNECT_PROTOCOL) == 1: + break + + payload = core.get_request(0).encode_request( + [ + (b':method', b'CONNECT'), + (b':protocol', b'not-websocket'), + (b':scheme', b'https'), + (b':path', b'/chat'), + (b':authority', b'example'), + ], + b'', + ) + sock.sendto(client.send_stream_data(0, payload, fin=True), ('127.0.0.1', port)) + + response_state = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if response_state is not None and response_state.ended: + break + + assert response_state is not None + assert response_state is not None + assert (b':status' in b'501'), response_state.headers + assert response_state.body == b'unsupported extended connect protocol' + finally: + sock.close() + await server.close() diff --git a/tests/test_http_content_coding_rfc9110_pytest.py b/tests/test_http_content_coding_rfc9110_pytest.py new file mode 100644 index 00000000..5da15495 --- /dev/null +++ b/tests/test_http_content_coding_rfc9110_pytest.py @@ -0,0 +1,181 @@ +import asyncio +import gzip +import socket +import zlib + +import pytest +try: # pragma: no cover - optional dependency + import brotli # type: ignore[import-not-found] +except Exception: # pragma: no cover - optional dependency + brotli = None # type: ignore[assignment] + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.content_coding import apply_http_content_coding, select_content_coding +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, decode_settings, serialize_settings +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server(*, http_versions: list[str], transport: str = 'tcp'): + async def app(scope, receive, send): + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain; charset=utf-8')]}) + await send({'type': 'http.response.body', 'body': b'compress-me', 'more_body': False}) + + kwargs = {'host': '127.0.0.1', 'port': 0, 'lifespan': 'off', 'http_versions': http_versions} + if transport == 'udp': + kwargs.update({'transport': 'udp', 'protocols': ['http3'], 'quic_secret': b'shared'}) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == 'udp': + port = server._listeners[0].transport.get_extra_info('sockname')[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _read_http1_response(reader: asyncio.StreamReader) -> tuple[bytes, bytes]: + head = await reader.readuntil(b'\r\n\r\n') + headers = {} + for line in head.split(b'\r\n')[1:]: + if not line: + continue + name, value = line.split(b':', 1) + headers[name.strip().lower()] = value.strip() + length = int(headers.get(b'content-length', b'0')) + body = await reader.readexactly(length) if length else b'' + return head, body + + + +async def test_http11_gzip_negotiation(): + server, port = await _start_server(http_versions=['1.1']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write( + b'GET / HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'Accept-Encoding: gzip\r\n\r\n' + ) + await writer.drain() + head, body = await _read_http1_response(reader) + assert b'content-encoding: gzip' in head.lower() + assert b'vary: accept-encoding' in head.lower() + assert gzip.decompress(body) == b'compress-me' + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http2_gzip_negotiation(): + server, port = await _start_server(http_versions=['2']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + headers = encode_header_block([ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/'), + (b':authority', b'localhost'), + (b'accept-encoding', b'gzip'), + ]) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, headers, end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + response_headers: list[tuple[bytes, bytes]] = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if response_headers and ended: + break + assert (b'content-encoding' in b'gzip'), response_headers + assert (b'vary' in b'accept-encoding'), response_headers + assert gzip.decompress(bytes(body)) == b'compress-me' + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http3_gzip_negotiation(): + server, port = await _start_server(http_versions=['3'], transport='udp') + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-gzip') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + request_payload = core.get_request(0).encode_request([ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/'), + (b':authority', b'localhost'), + (b'accept-encoding', b'gzip'), + ]) + sock.sendto(client.send_stream_data(0, request_payload, fin=True), ('127.0.0.1', port)) + + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream' and event.stream_id == 0: + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + assert response_state is not None + assert (b'content-encoding' in b'gzip'), response_state.headers + assert (b'vary' in b'accept-encoding'), response_state.headers + assert gzip.decompress(response_state.body) == b'compress-me' + finally: + sock.close() + await server.close() + +def test_identity_forbidden_yields_406(): + status, headers, body, selection = apply_http_content_coding( + request_headers=[(b'accept-encoding', b'identity;q=0,*;q=0')], + response_headers=[(b'content-type', b'text/plain')], + body=b'compress-me', + status=200, + ) + assert status == 406 + assert selection.not_acceptable + assert (b'vary' in b'accept-encoding'), headers + assert body == b'not acceptable' +def test_brotli_selection_wins_when_preferred(): + selection = select_content_coding([(b'accept-encoding', b'gzip;q=0.3, br;q=1.0')]) + assert selection.coding == 'br' + if brotli is not None: + status, headers, body, _ = apply_http_content_coding( + request_headers=[(b'accept-encoding', b'br')], + response_headers=[(b'content-type', b'text/plain')], + body=b'compress-me', + status=200, + ) + assert status == 200 + assert (b'content-encoding' in b'br'), headers + assert brotli.decompress(body) == b'compress-me' diff --git a/tests/test_http_integrity_caching_signatures_status_pytest.py b/tests/test_http_integrity_caching_signatures_status_pytest.py new file mode 100644 index 00000000..e525e9ce --- /dev/null +++ b/tests/test_http_integrity_caching_signatures_status_pytest.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def test_http_integrity_caching_signatures_status_document_exists() -> None: + root = Path(__file__).resolve().parents[1] + report = root / 'docs' / 'review' / 'conformance' / 'http_integrity_caching_signatures_status.current.json' + assert report.exists(), 'expected focused HTTP integrity/caching/signatures status JSON to exist' + + data = json.loads(report.read_text()) + assert data['checkpoint'] == 'http_integrity_caching_signatures_audit' + + assert data['summary']['authoritative_boundary_certifiably_fully_rfc_compliant'] is True + assert data['summary']['requested_http_integrity_caching_signatures_stack_fully_supported'] is False + assert data['summary']['requested_http_integrity_caching_signatures_stack_fully_targeted'] is False + + rfc_status = data['rfc_status'] + assert rfc_status['rfc9110']['current_support'] == 'partially_supported' + assert rfc_status['rfc7232']['current_support'] == 'supported_and_targeted' + assert rfc_status['rfc7232']['targeted_by_authoritative_boundary'] is True + assert rfc_status['rfc7233']['current_support'] == 'supported_and_targeted' + assert rfc_status['rfc9111']['current_support'] == 'not_supported' + assert rfc_status['rfc9530']['current_support'] == 'not_supported' + assert rfc_status['rfc9421']['current_support'] == 'not_supported' + assert rfc_status['rfc7515']['current_support'] == 'not_supported' + assert rfc_status['rfc8152']['current_support'] == 'not_supported' + + feature_status = data['feature_status'] + assert feature_status['accept_encoding']['current_support'] == 'supported' + assert feature_status['content_encoding']['current_support'] == 'supported' + assert feature_status['vary']['current_support'] == 'supported_for_content_coding' + assert feature_status['etag']['current_support'] == 'supported' + assert feature_status['if_none_match']['current_support'] == 'supported' + assert feature_status['range']['current_support'] == 'supported' + assert feature_status['if_range']['current_support'] == 'supported' + assert feature_status['response_304']['current_support'] == 'supported_by_conditional_engine' + assert feature_status['content_digest']['current_support'] == 'not_supported' + assert feature_status['repr_digest']['current_support'] == 'not_supported' + assert feature_status['http_signatures']['current_support'] == 'not_supported' diff --git a/tests/test_import_pytest.py b/tests/test_import_pytest.py new file mode 100644 index 00000000..d1cc5149 --- /dev/null +++ b/tests/test_import_pytest.py @@ -0,0 +1,6 @@ +import importlib + + +def test_import() -> None: + mod = importlib.import_module("tigrcorn") + assert hasattr(mod, "run") diff --git a/tests/test_intermediary_proxy_corpus_pytest.py b/tests/test_intermediary_proxy_corpus_pytest.py new file mode 100644 index 00000000..d8265fd1 --- /dev/null +++ b/tests/test_intermediary_proxy_corpus_pytest.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CORPUS_ROOT = ROOT / 'docs/review/conformance/intermediary_proxy_corpus' + + +def test_index_lists_seed_corpus_cases() -> None: + payload = json.loads((CORPUS_ROOT / 'index.json').read_text(encoding='utf-8')) + ids = {entry['id'] for entry in payload['cases']} + assert ids == { + 'http11-curl-origin-form-post', + 'http11-connect-relay-local-vector', + 'http2-connect-relay-local-vector', + 'http3-connect-relay-local-vector', + } + + +def test_http11_seed_case_preserves_third_party_artifacts() -> None: + case_dir = CORPUS_ROOT / 'cases' / 'http11-curl-origin-form-post' + metadata = json.loads((case_dir / 'corpus_metadata.json').read_text(encoding='utf-8')) + result = json.loads((case_dir / 'result.json').read_text(encoding='utf-8')) + assert metadata['source_kind'] == 'independent_artifact' + assert metadata['peer'] == 'curl' + assert result['passed'] + + +def test_connect_cases_preserve_local_vector_metadata() -> None: + for carrier in ('http11', 'http2', 'http3'): + case_dir = CORPUS_ROOT / 'cases' / f'{carrier}-connect-relay-local-vector' + metadata = json.loads((case_dir / 'corpus_metadata.json').read_text(encoding='utf-8')) + vector = json.loads((case_dir / 'source_local_vector.json').read_text(encoding='utf-8')) + assert metadata['source_kind'] == 'local_vector' + assert vector['name'] == 'http-connect-relay' diff --git a/tests/test_lifespan_pytest.py b/tests/test_lifespan_pytest.py new file mode 100644 index 00000000..8ef4c6ec --- /dev/null +++ b/tests/test_lifespan_pytest.py @@ -0,0 +1,22 @@ + +from tigrcorn.protocols.lifespan.driver import LifespanManager + + +import pytest + +async def test_lifespan_start_stop(): + events = [] + + async def app(scope, receive, send): + assert scope["type"] == "lifespan" + msg = await receive() + events.append(msg["type"]) + await send({"type": "lifespan.startup.complete"}) + msg = await receive() + events.append(msg["type"]) + await send({"type": "lifespan.shutdown.complete"}) + + manager = LifespanManager(app, mode="on") + await manager.startup() + await manager.shutdown() + assert events == ["lifespan.startup", "lifespan.shutdown"] diff --git a/tests/test_observability_workers_pytest.py b/tests/test_observability_workers_pytest.py new file mode 100644 index 00000000..81dffade --- /dev/null +++ b/tests/test_observability_workers_pytest.py @@ -0,0 +1,40 @@ +import io +import logging + +from tigrcorn.observability.events import Event +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.observability.metrics import Metrics +from tigrcorn.observability.tracing import span +from tigrcorn.workers.local import LocalWorker +from tigrcorn.workers.supervisor import WorkerSupervisor + + +import pytest + +def test_logging_metrics_workers(): + logger = configure_logging('info') + stream = io.StringIO() + handler = logging.StreamHandler(stream) + logger.addHandler(handler) + try: + access = AccessLogger(logger, enabled=True) + access.log_http(('127.0.0.1', 1), 'GET', '/', 200, 'HTTP/1.1') + access.log_ws(('127.0.0.1', 1), '/ws', 'accepted') + finally: + logger.removeHandler(handler) + data = stream.getvalue() + assert 'GET / HTTP/1.1' in data + assert 'WEBSOCKET /ws' in data + metrics = Metrics(connections_opened=1, requests_served=2) + assert metrics.requests_served == 2 + event = Event(name='tick', attrs={'value': 1}) + assert event.attrs['value'] == 1 + with span('demo'): + pass + worker = LocalWorker() + sup = WorkerSupervisor() + sup.add(worker) + sup.start_all() + assert worker.running + sup.stop_all() + assert not (worker.running) \ No newline at end of file diff --git a/tests/test_phase1_surface_parity_checkpoint_pytest.py b/tests/test_phase1_surface_parity_checkpoint_pytest.py new file mode 100644 index 00000000..e82f59ec --- /dev/null +++ b/tests/test_phase1_surface_parity_checkpoint_pytest.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +import os +import sys +import tempfile +import textwrap +from pathlib import Path +from unittest.mock import patch + +from tigrcorn.cli import build_parser +from tigrcorn.config.files import load_config_source +from tigrcorn.config.load import build_config_from_namespace +from tigrcorn.protocols.websocket.handshake import ( + build_handshake_response, + websocket_accept_value, +) +from tigrcorn.static import StaticFilesApp +from tigrcorn.utils.authority import authority_allowed +from tigrcorn.utils.headers import apply_response_header_policy, get_header + + +def test_parser_and_namespace_map_new_phase1_flags(): + parser = build_parser() + with tempfile.TemporaryDirectory() as tmp: + env_file = Path(tmp) / '.env.test' + env_file.write_text('', encoding='utf-8') + ns = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--uds', '/tmp/tigrcorn.sock', + '--env-file', str(env_file), + '--runtime', 'uvloop', + '--worker-healthcheck-timeout', '9.5', + '--user', '1000', + '--group', '1001', + '--umask', '022', + '--date-header', + '--header', 'x-phase1: enabled', + '--server-name', 'example.com,api.example.com', + '--use-colors', + ] + ) + config = build_config_from_namespace(ns) + assert config.app.env_file.endswith('.env.test') + assert config.process.runtime == 'uvloop' + assert config.process.worker_healthcheck_timeout == 9.5 + assert config.listeners[0].kind == 'unix' + assert config.listeners[0].user == 1000 + assert config.listeners[0].group == 1001 + assert config.listeners[0].umask == 0o22 + assert config.include_date_header + assert config.default_response_headers == [(b'x-phase1', b'enabled')] + assert config.allowed_server_names == ('example.com', 'api.example.com') + assert config.logging.use_colors + + +def test_config_precedence_cli_over_env_over_env_file_over_file(): + parser = build_parser() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / 'tigrcorn.json' + env_path = Path(tmp) / '.env' + config_path.write_text( + '{"app": {"target": "tests.fixtures_pkg.appmod:app"}, "logging": {"level": "debug"}}', + encoding='utf-8', + ) + env_path.write_text('PHASE1_LOG_LEVEL=warning\n', encoding='utf-8') + ns = parser.parse_args( + [ + '--config', + str(config_path), + '--env-file', + str(env_path), + '--env-prefix', + 'PHASE1', + '--log-level', + 'error', + ] + ) + with patch.dict(os.environ, {'PHASE1__LOGGING__LEVEL': 'info'}, clear=False): + config = build_config_from_namespace(ns) + assert config.logging.level == 'error' + + +def test_yaml_module_and_object_config_sources_load(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + yaml_path = root / 'config.yaml' + yaml_path.write_text( + 'app:\n target: tests.fixtures_pkg.appmod:app\nproxy:\n server_names: [example.com]\n', + encoding='utf-8', + ) + module_path = root / 'phase1_cfg.py' + module_path.write_text( + textwrap.dedent( + ''' + CONFIG = { + "app": {"target": "tests.fixtures_pkg.appmod:app"}, + "logging": {"level": "warning"}, + } + OBJECT = { + "app": {"target": "tests.fixtures_pkg.appmod:app"}, + "proxy": {"include_date_header": False}, + } + ''' + ), + encoding='utf-8', + ) + with patch.object(sys, 'path', [tmp, *sys.path]): + yaml_cfg = load_config_source(str(yaml_path)) + module_cfg = load_config_source('module:phase1_cfg') + object_cfg = load_config_source('object:phase1_cfg:OBJECT') + assert yaml_cfg['proxy']['server_names'] == ['example.com'] + assert module_cfg['logging']['level'] == 'warning' + assert object_cfg['proxy']['include_date_header'] is False + + +def test_response_header_policy_and_websocket_handshake_include_phase1_headers(): + headers = apply_response_header_policy( + [(b'content-type', b'text/plain')], + server_header=b'tigrcorn', + include_date_header=True, + default_headers=['x-checkpoint: phase1'], + ) + assert get_header(headers, b'x-checkpoint') == b'phase1' + assert get_header(headers, b'server') == b'tigrcorn' + assert get_header(headers, b'date') is not None + + response = build_handshake_response( + b'dGhlIHNhbXBsZSBub25jZQ==', + subprotocol='chat', + headers=[(b'x-extra', b'1')], + server_header=b'tigrcorn', + include_date_header=True, + default_headers=[(b'x-checkpoint', b'phase1')], + ) + assert b'HTTP/1.1 101 Switching Protocols' in response + assert ( + b'sec-websocket-accept: ' + websocket_accept_value(b'dGhlIHNhbXBsZSBub25jZQ==') + in response + ) + assert b'x-checkpoint: phase1' in response.lower() + assert b'date: ' in response.lower() + + +def test_authority_allowlist_supports_exact_and_wildcard(): + assert authority_allowed(b'example.com', ['example.com']) + assert authority_allowed(b'api.example.com:443', ['*.example.com:443']) + assert not authority_allowed(b'example.net', ['*.example.com']) + assert not authority_allowed(None, ['example.com']) + + +def test_static_files_app_serves_file_and_blocks_traversal(): + async def exercise() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'hello.txt').write_text('hello world', encoding='utf-8') + app = StaticFilesApp(root) + + sent: list[dict] = [] + + async def receive() -> dict: + return {'type': 'http.request', 'body': b'', 'more_body': False} + + async def send(message: dict) -> None: + sent.append(message) + + await app({'type': 'http', 'method': 'GET', 'path': '/hello.txt'}, receive, send) + assert sent[0]['status'] == 200 + assert sent[1]['body'] == b'hello world' + + sent.clear() + await app( + {'type': 'http', 'method': 'GET', 'path': '/../secret.txt'}, + receive, + send, + ) + assert sent[0]['status'] == 404 + + sent.clear() + await app({'type': 'http', 'method': 'HEAD', 'path': '/hello.txt'}, receive, send) + assert sent[0]['status'] == 200 + assert sent[1]['body'] == b'' + + asyncio.run(exercise()) diff --git a/tests/test_phase2_cli_config_surface_pytest.py b/tests/test_phase2_cli_config_surface_pytest.py new file mode 100644 index 00000000..b55a261a --- /dev/null +++ b/tests/test_phase2_cli_config_surface_pytest.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import argparse +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +from tigrcorn.cli import build_parser +from tigrcorn.config.load import build_config_from_namespace + + +import pytest + +def test_parser_accepts_grouped_phase2_flags(): + parser = build_parser() + ns = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--factory', + '--app-dir', '.', + '--reload', + '--reload-dir', 'src', + '--reload-include', '*.py', + '--workers', '2', + '--worker-class', 'process', + '--pid', '/tmp/tigrcorn.pid', + '--bind', '127.0.0.1:9000', + '--quic-bind', '127.0.0.1:9443', + '--transport', 'udp', + '--reuse-port', + '--backlog', '100', + '--static-path-route', '/assets', + '--static-path-mount', '/srv/assets', + '--static-path-dir-to-file', + '--static-path-index-file', 'index.html', + '--static-path-expires', '3600', + '--ssl-certfile', 'cert.pem', + '--ssl-keyfile', 'key.pem', + '--ssl-ca-certs', 'ca.pem', + '--ssl-require-client-cert', + '--ssl-alpn', 'h3', + '--proxy-headers', + '--forwarded-allow-ips', '127.0.0.1,10.0.0.1', + '--root-path', '/svc', + '--server-header', 'tigrcorn-test', + '--log-level', 'debug', + '--access-log', + '--structured-log', + '--metrics', + '--metrics-bind', '127.0.0.1:9001', + '--statsd-host', '127.0.0.1:8125', + '--timeout-keep-alive', '6', + '--read-timeout', '11', + '--write-timeout', '12', + '--timeout-graceful-shutdown', '13', + '--limit-concurrency', '55', + '--max-connections', '100', + '--max-tasks', '200', + '--max-streams', '10', + '--max-body-size', '65536', + '--max-header-size', '4096', + '--websocket-max-message-size', '2048', + '--websocket-ping-interval', '20', + '--websocket-ping-timeout', '5', + '--idle-timeout', '40', + '--http', '3', + '--protocol', 'http3', + '--disable-h2c', + '--websocket-compression', 'permessage-deflate', + '--connect-policy', 'relay', + '--trailer-policy', 'strict', + '--content-coding-policy', 'allowlist', + '--content-codings', 'gzip,deflate', + '--quic-require-retry', + '--quic-max-datagram-size', '1350', + '--quic-idle-timeout', '50', + '--quic-early-data-policy', 'deny', + ] + ) + assert ns.worker_class == 'process' + assert ns.backlog == 100 + assert ns.static_path_route == '/assets' + assert ns.static_path_mount == '/srv/assets' + assert ns.static_path_dir_to_file == True + assert ns.static_path_index_file == 'index.html' + assert ns.static_path_expires == 3600 + assert ns.root_path == '/svc' + assert ns.quic_bind == ['127.0.0.1:9443'] + assert ns.content_codings == ['gzip,deflate'] +def test_build_config_from_namespace_maps_nested_submodels(): + parser = build_parser() + ns = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--transport', 'udp', + '--protocol', 'http3', + '--http', '3', + '--ssl-certfile', 'cert.pem', + '--ssl-keyfile', 'key.pem', + '--ssl-ca-certs', 'ca.pem', + '--ssl-require-client-cert', + '--ssl-alpn', 'h3', + '--max-body-size', '1024', + '--max-header-size', '512', + '--websocket-max-message-size', '4096', + '--websocket-compression', 'permessage-deflate', + '--quic-require-retry', + '--quic-max-datagram-size', '1400', + '--static-path-route', '/assets', + '--static-path-mount', '/srv/assets', + '--no-static-path-dir-to-file', + '--static-path-index-file', 'home.html', + '--static-path-expires', '90', + '--connect-policy', 'allowlist', + '--trailer-policy', 'drop', + '--content-coding-policy', 'identity-only', + '--content-codings', 'gzip', + '--limit-concurrency', '3', + '--max-streams', '2', + '--server-header', 'demo', + ] + ) + config = build_config_from_namespace(ns) + assert config.app.target == 'tests.fixtures_pkg.appmod:app' + assert config.tls.certfile == 'cert.pem' + assert config.tls.alpn_protocols == ['h3'] + assert config.http.max_body_size == 1024 + assert config.http.max_header_size == 512 + assert config.websocket.max_message_size == 4096 + assert config.websocket.compression == 'permessage-deflate' + assert config.quic.max_datagram_size == 1400 + assert config.quic.require_retry == True + assert config.static.route == '/assets' + assert config.static.mount == '/srv/assets' + assert config.static.dir_to_file == False + assert config.static.index_file == 'home.html' + assert config.static.expires == 90 + assert config.http.connect_policy == 'allowlist' + assert config.http.trailer_policy == 'drop' + assert config.http.content_coding_policy == 'identity-only' + assert config.http.content_codings == ['gzip'] + assert config.scheduler.limit_concurrency == 3 + assert config.scheduler.max_streams == 2 + assert config.server_header_value == b'demo' + assert config.listeners[0].kind == 'udp' + assert config.listeners[0].protocols[:2] == ['quic', 'http3'] +def test_config_source_precedence_cli_over_env_over_file(): + parser = build_parser() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / 'tigrcorn.json' + config_path.write_text(json.dumps({ + 'app': {'target': 'tests.fixtures_pkg.appmod:app'}, + 'logging': {'level': 'debug'}, + 'http': {'max_body_size': 99}, + })) + ns = parser.parse_args([ + '--config', str(config_path), + '--env-prefix', 'PHASE2TEST', + '--log-level', 'info', + '--max-body-size', '101', + ]) + with patch.dict(os.environ, { + 'PHASE2TEST__LOGGING__LEVEL': 'warning', + 'PHASE2TEST__HTTP__MAX_BODY_SIZE': '100', + }, clear=False): + config = build_config_from_namespace(ns) + assert config.logging.level == 'info' + assert config.http.max_body_size == 101 +def test_env_prefix_is_respected(): + parser = build_parser() + ns = parser.parse_args(['--env-prefix', 'PHASE2ALT']) + with patch.dict(os.environ, { + 'PHASE2ALT__APP__TARGET': 'tests.fixtures_pkg.appmod:app', + 'PHASE2ALT__LOGGING__LEVEL': 'warning', + }, clear=False): + config = build_config_from_namespace(ns) + assert config.app.target == 'tests.fixtures_pkg.appmod:app' + assert config.logging.level == 'warning' +def test_app_dir_round_trip(): + parser = build_parser() + ns = parser.parse_args(['tests.fixtures_pkg.appmod:app', '--app-dir', '.']) + config = build_config_from_namespace(ns) + assert config.app.app_dir == '.' +def test_cli_flag_surface_json_covers_public_parser_flags(): + parser = build_parser() + public_flags: set[str] = set() + for action in parser._actions: + if isinstance(action, argparse._HelpAction): + continue + if action.help == argparse.SUPPRESS: + continue + for flag in action.option_strings: + public_flags.add(flag) + payload = json.loads(Path('docs/review/conformance/cli_flag_surface.json').read_text(encoding='utf-8')) + documented_flags = {flag for entry in payload['flags'] for flag in entry['flags']} + missing = sorted(public_flags - documented_flags) + assert missing == [] \ No newline at end of file diff --git a/tests/test_phase2_entity_semantics_checkpoint_pytest.py b/tests/test_phase2_entity_semantics_checkpoint_pytest.py new file mode 100644 index 00000000..b8d450cb --- /dev/null +++ b/tests/test_phase2_entity_semantics_checkpoint_pytest.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import asyncio +import socket +import tempfile +from email.utils import formatdate +from pathlib import Path + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.http.conditional import apply_conditional_request +from tigrcorn.http.entity import apply_response_entity_semantics +from tigrcorn.http.etag import generate_entity_tag, parse_entity_tag, strong_compare +from tigrcorn.http.range import apply_byte_ranges +from tigrcorn.protocols.http2.codec import ( + FRAME_DATA, + FRAME_HEADERS, + FRAME_SETTINGS, + FrameBuffer, + FrameWriter, + decode_settings, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.static import StaticFilesApp +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.utils.headers import get_header + + +async def _start_server(app, *, http_versions: list[str], transport: str = 'tcp'): + kwargs = { + 'host': '127.0.0.1', + 'port': 0, + 'lifespan': 'off', + 'http_versions': http_versions, + } + if transport == 'udp': + kwargs.update({'transport': 'udp', 'protocols': ['http3'], 'quic_secret': b'shared'}) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == 'udp': + port = server._listeners[0].transport.get_extra_info('sockname')[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _read_http1_response( + reader: asyncio.StreamReader, +) -> tuple[bytes, dict[bytes, bytes], bytes]: + head = await reader.readuntil(b'\r\n\r\n') + headers: dict[bytes, bytes] = {} + for line in head.split(b'\r\n')[1:]: + if not line: + continue + name, value = line.split(b':', 1) + headers[name.strip().lower()] = value.strip() + length = int(headers.get(b'content-length', b'0')) + body = await reader.readexactly(length) if length else b'' + return head, headers, body + + +async def _read_h2_response( + reader: asyncio.StreamReader, +) -> tuple[list[tuple[bytes, bytes]], bytes]: + buf = FrameBuffer() + headers: list[tuple[bytes, bytes]] = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS and frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if headers and ended: + break + return headers, bytes(body) + + +async def _read_h3_response( + sock: socket.socket, + core: HTTP3ConnectionCore, + client: QuicConnection, +) -> tuple[list[tuple[bytes, bytes]], bytes]: + loop = asyncio.get_running_loop() + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream' and event.stream_id == 0: + response_state = core.receive_stream_data( + event.stream_id, event.data, fin=event.fin + ) + assert response_state is not None + return response_state.headers, response_state.body + + +def test_generated_etag_parses_and_strong_matches(): + tag = generate_entity_tag(b'hello world') + parsed = parse_entity_tag(tag) + assert parsed is not None + assert strong_compare(parsed, parse_entity_tag(tag)) + + +def test_conditional_engine_supports_if_none_match_if_match_and_dates(): + etag = generate_entity_tag(b'payload') + last_modified = formatdate(1_700_000_000, usegmt=True).encode('ascii') + + not_modified = apply_conditional_request( + method='GET', + request_headers=[(b'if-none-match', etag)], + response_headers=[(b'etag', etag), (b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert not_modified.status == 304 + assert not_modified.body == b'' + + precondition_failed = apply_conditional_request( + method='PUT', + request_headers=[(b'if-match', b'"other"')], + response_headers=[(b'etag', etag), (b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert precondition_failed.status == 412 + + ims = apply_conditional_request( + method='GET', + request_headers=[(b'if-modified-since', last_modified)], + response_headers=[(b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert ims.status == 304 + + ius = apply_conditional_request( + method='GET', + request_headers=[ + ( + b'if-unmodified-since', + formatdate(1_699_999_000, usegmt=True).encode('ascii'), + ) + ], + response_headers=[(b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert ius.status == 412 + + +def test_range_engine_supports_single_multi_and_unsatisfied_ranges(): + body = b'hello world' + headers = [(b'content-type', b'text/plain')] + + single = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=0-4')], + response_headers=headers, + body=body, + status=200, + ) + assert single.status == 206 + assert single.body == b'hello' + assert get_header(single.headers, b'content-range') == b'bytes 0-4/11' + + multi = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=0-1,6-10')], + response_headers=headers, + body=body, + status=200, + ) + assert multi.status == 206 + assert b'multipart/byteranges' in (get_header(multi.headers, b'content-type') or b'') + assert b'Content-Range: bytes 0-1/11' in multi.body + assert b'Content-Range: bytes 6-10/11' in multi.body + + unsatisfied = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=40-50')], + response_headers=headers, + body=body, + status=200, + ) + assert unsatisfied.status == 416 + assert get_header(unsatisfied.headers, b'content-range') == b'bytes */11' + + +def test_entity_semantics_head_and_range_interactions(): + head = apply_response_entity_semantics( + method='HEAD', + request_headers=[], + response_headers=[(b'content-type', b'text/plain')], + body=b'hello', + status=200, + apply_content_coding=False, + ) + assert head.body == b'' + assert get_header(head.headers, b'content-length') == b'5' + assert get_header(head.headers, b'etag') is not None + + partial = apply_response_entity_semantics( + method='GET', + request_headers=[(b'range', b'bytes=0-4'), (b'accept-encoding', b'gzip')], + response_headers=[(b'content-type', b'text/plain')], + body=b'hello world', + status=200, + apply_content_coding=True, + ) + assert partial.status == 206 + assert partial.body == b'hello' + assert get_header(partial.headers, b'content-encoding') is None + + +@pytest.mark.asyncio +async def test_static_files_app_supports_etag_conditionals_and_ranges(): + async def receive() -> dict: + return {'type': 'http.request', 'body': b'', 'more_body': False} + + sent: list[dict] = [] + + async def send(message: dict) -> None: + sent.append(message) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'hello.txt').write_text('hello world', encoding='utf-8') + app = StaticFilesApp(root) + + await app( + { + 'type': 'http', + 'method': 'GET', + 'path': '/hello.txt', + 'headers': [(b'range', b'bytes=0-4')], + }, + receive, + send, + ) + assert sent[0]['status'] == 206 + headers = dict(sent[0]['headers']) + assert headers[b'content-range'] == b'bytes 0-4/11' + assert sent[1]['body'] == b'hello' + etag = headers[b'etag'] + + sent.clear() + await app( + { + 'type': 'http', + 'method': 'GET', + 'path': '/hello.txt', + 'headers': [(b'if-none-match', etag)], + }, + receive, + send, + ) + assert sent[0]['status'] == 304 + assert sent[1]['body'] == b'' + + +@pytest.mark.asyncio +async def test_http11_generates_etag_and_honors_if_none_match(): + async def app(scope, receive, send): + await receive() + await send( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-type', b'text/plain')], + } + ) + await send({'type': 'http.response.body', 'body': b'entity-body', 'more_body': False}) + + server, port = await _start_server(app, http_versions=['1.1']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + _head, headers, body = await _read_http1_response(reader) + assert body == b'entity-body' + etag = headers[b'etag'] + writer.close() + await writer.wait_closed() + + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: ' + etag + b'\r\n\r\n') + await writer.drain() + _head, headers, body = await _read_http1_response(reader) + assert headers.get(b'content-length', b'0') == b'0' + assert body == b'' + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_head_preserves_content_length_and_suppresses_body(): + async def app(scope, receive, send): + await receive() + await send( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-type', b'text/plain')], + } + ) + await send({'type': 'http.response.body', 'body': b'hello', 'more_body': False}) + + server, port = await _start_server(app, http_versions=['2']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + headers = encode_header_block( + [ + (b':method', b'HEAD'), + (b':scheme', b'http'), + (b':path', b'/'), + (b':authority', b'localhost'), + ] + ) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, headers, end_stream=True)) + await writer.drain() + response_headers, body = await _read_h2_response(reader) + assert (b'content-length', b'5') in response_headers + assert body == b'' + assert dict(response_headers).get(b'etag') is not None + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_range_request_returns_partial_response(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'hello.txt').write_text('hello world', encoding='utf-8') + app = StaticFilesApp(root) + server, port = await _start_server(app, http_versions=['3'], transport='udp') + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-range') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + request_payload = core.get_request(0).encode_request( + [ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/hello.txt'), + (b':authority', b'localhost'), + (b'range', b'bytes=6-10'), + ] + ) + sock.sendto(client.send_stream_data(0, request_payload, fin=True), ('127.0.0.1', port)) + response_headers, body = await _read_h3_response(sock, core, client) + assert (b':status', b'206') in response_headers + assert (b'content-range', b'bytes 6-10/11') in response_headers + assert body == b'world' + finally: + sock.close() + await server.close() diff --git a/tests/test_phase2_rfc_boundary_formalization_checkpoint_pytest.py b/tests/test_phase2_rfc_boundary_formalization_checkpoint_pytest.py new file mode 100644 index 00000000..760d8134 --- /dev/null +++ b/tests/test_phase2_rfc_boundary_formalization_checkpoint_pytest.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, evaluate_promotion_target + + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' + + +def _load(path: str) -> dict: + return json.loads((ROOT / path).read_text(encoding='utf-8')) + + +def test_boundaries_formalize_rfc7232_and_rfc7233() -> None: + authoritative = _load('docs/review/conformance/certification_boundary.json') + strict = _load('docs/review/conformance/certification_boundary.strict_target.json') + corpus = _load('docs/review/conformance/corpus.json') + + for payload in (authoritative, strict): + assert 'RFC 7232' in payload['required_rfcs'] + assert 'RFC 7233' in payload['required_rfcs'] + assert payload['required_rfc_evidence']['RFC 7232']['highest_required_evidence_tier'] == 'local_conformance' + assert payload['required_rfc_evidence']['RFC 7233']['highest_required_evidence_tier'] == 'local_conformance' + assert payload['required_rfc_evidence']['RFC 7232']['declared_evidence']['local_conformance'] == ['http-conditional-requests'] + assert payload['required_rfc_evidence']['RFC 7233']['declared_evidence']['local_conformance'] == ['http-byte-ranges'] + + vectors = {entry['name']: entry for entry in corpus['vectors']} + assert vectors['http-conditional-requests']['rfc'] == '7232' + assert vectors['http-byte-ranges']['rfc'] == '7233' + + +def test_current_state_docs_no_longer_describe_rfc7232_or_rfc7233_as_unsupported() -> None: + audit = _load('docs/review/conformance/http_integrity_caching_signatures_status.current.json') + applicability = _load('docs/review/conformance/rfc_applicability_and_competitor_status.current.json') + + assert audit['rfc_status']['rfc7232']['current_support'] == 'supported_and_targeted' + assert audit['rfc_status']['rfc7233']['current_support'] == 'supported_and_targeted' + assert applicability['rfc_applicability']['rfc7232']['current_support'] == 'targeted_and_supported' + assert applicability['rfc_applicability']['rfc7233']['current_support'] == 'targeted_and_supported' + + +def test_release_gates_and_promotion_target_remain_green_after_boundary_formalization() -> None: + authoritative = evaluate_release_gates(ROOT) + strict = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + promotion = evaluate_promotion_target(ROOT) + assert authoritative.passed, authoritative.failures + assert strict.passed, strict.failures + assert promotion.passed, promotion.failures diff --git a/tests/test_phase2_static_delivery_surface.py b/tests/test_phase2_static_delivery_surface.py index d4dc23c8..163a3c7c 100644 --- a/tests/test_phase2_static_delivery_surface.py +++ b/tests/test_phase2_static_delivery_surface.py @@ -44,7 +44,11 @@ async def send(message: dict) -> None: await app(scope, receive, send) self.assertEqual(sent[0]['type'], 'http.response.start') self.assertEqual(sent[0]['status'], 200) - self.assertEqual(sent[1], {'type': 'http.response.pathsend', 'path': str(root / 'blob.bin')}) + self.assertEqual(sent[1]['type'], 'http.response.pathsend') + self.assertEqual( + Path(sent[1]['path']).resolve(strict=False), + (root / 'blob.bin').resolve(strict=False), + ) async def test_mount_static_app_routes_requests_and_preserves_fallback(self): fallback_events: list[dict] = [] diff --git a/tests/test_phase2_static_delivery_surface_pytest.py b/tests/test_phase2_static_delivery_surface_pytest.py new file mode 100644 index 00000000..a920f88b --- /dev/null +++ b/tests/test_phase2_static_delivery_surface_pytest.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import asyncio +import socket +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tigrcorn.cli import main as cli_main +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import FrameWriter, serialize_settings +from tigrcorn.protocols.http2.hpack import encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.static import StaticFilesApp, mount_static_app +from tigrcorn.transports.quic import QuicConnection + +from tests.test_phase2_entity_semantics_checkpoint import ( + _read_h2_response, + _read_http1_response, + _start_server, +) +from tests.test_static_delivery_productionization_checkpoint import ( + _read_h3_response_with_client_progress, +) + + +@pytest.mark.asyncio +async def test_static_files_app_prefers_standard_pathsend_when_advertised(): + async def receive() -> dict: + return {'type': 'http.request', 'body': b'', 'more_body': False} + + sent: list[dict] = [] + + async def send(message: dict) -> None: + sent.append(message) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload = b'pathsend-static-payload' + (root / 'blob.bin').write_bytes(payload) + app = StaticFilesApp(root) + scope = { + 'type': 'http', + 'method': 'GET', + 'path': '/blob.bin', + 'headers': [], + 'extensions': {'http.response.pathsend': {}}, + } + await app(scope, receive, send) + + assert sent[0]['type'] == 'http.response.start' + assert sent[0]['status'] == 200 + assert sent[1]['type'] == 'http.response.pathsend' + assert Path(sent[1]['path']).resolve(strict=False) == (root / 'blob.bin').resolve( + strict=False + ) + + +@pytest.mark.asyncio +async def test_mount_static_app_routes_requests_and_preserves_fallback(): + fallback_events: list[dict] = [] + + async def fallback(scope, receive, send) -> None: + fallback_events.append( + { + 'scope_type': scope['type'], + 'path': scope.get('path'), + 'root_path': scope.get('root_path', ''), + } + ) + await send( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-type', b'text/plain')], + } + ) + await send({'type': 'http.response.body', 'body': b'fallback'}) + + async def receive() -> dict: + return {'type': 'http.request', 'body': b'', 'more_body': False} + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'hello.txt').write_text('static hello', encoding='utf-8') + app = mount_static_app(fallback, route='/assets', directory=root) + + sent_static: list[dict] = [] + + async def send_static(message: dict) -> None: + sent_static.append(message) + + await app( + { + 'type': 'http', + 'method': 'GET', + 'path': '/assets/hello.txt', + 'raw_path': b'/assets/hello.txt', + 'headers': [], + }, + receive, + send_static, + ) + assert sent_static[0]['status'] == 200 + assert sent_static[1]['type'] == 'http.response.body' + assert sent_static[1]['body'] == b'static hello' + + sent_fallback: list[dict] = [] + + async def send_fallback(message: dict) -> None: + sent_fallback.append(message) + + await app( + {'type': 'http', 'method': 'GET', 'path': '/api', 'raw_path': b'/api', 'headers': []}, + receive, + send_fallback, + ) + assert sent_fallback[0]['status'] == 200 + assert sent_fallback[1]['body'] == b'fallback' + assert fallback_events[-1]['path'] == '/api' + + +@pytest.mark.asyncio +async def test_http11_pathsend_round_trip(): + with tempfile.TemporaryDirectory() as tmp: + payload = b'http11-pathsend-payload' * 1024 + payload_path = Path(tmp) / 'payload.bin' + payload_path.write_bytes(payload) + + async def app(scope, receive, send): + assert 'http.response.pathsend' in scope.get('extensions', {}) + await receive() + await send( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + (b'content-type', b'application/octet-stream'), + (b'content-length', str(len(payload)).encode('ascii')), + ], + } + ) + await send({'type': 'http.response.pathsend', 'path': str(payload_path)}) + + server, port = await _start_server(app, http_versions=['1.1']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + _head, headers, body = await _read_http1_response(reader) + assert headers[b'content-length'] == str(len(payload)).encode('ascii') + assert body == payload + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_pathsend_round_trip(): + with tempfile.TemporaryDirectory() as tmp: + payload = b'http2-pathsend-payload' * 2048 + payload_path = Path(tmp) / 'payload.bin' + payload_path.write_bytes(payload) + + async def app(scope, receive, send): + assert 'http.response.pathsend' in scope.get('extensions', {}) + await receive() + await send( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + (b'content-type', b'application/octet-stream'), + (b'content-length', str(len(payload)).encode('ascii')), + ], + } + ) + await send({'type': 'http.response.pathsend', 'path': str(payload_path)}) + + server, port = await _start_server(app, http_versions=['2']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + headers = encode_header_block( + [ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/'), + (b':authority', b'localhost'), + ] + ) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, headers, end_stream=True)) + await writer.drain() + response_headers, body = await _read_h2_response(reader) + assert dict(response_headers)[b':status'] == b'200' + assert body == payload + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_pathsend_round_trip(): + with tempfile.TemporaryDirectory() as tmp: + payload = b'http3-pathsend-payload' * 2048 + payload_path = Path(tmp) / 'payload.bin' + payload_path.write_bytes(payload) + + async def app(scope, receive, send): + assert 'http.response.pathsend' in scope.get('extensions', {}) + await receive() + await send( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + (b'content-type', b'application/octet-stream'), + (b'content-length', str(len(payload)).encode('ascii')), + ], + } + ) + await send({'type': 'http.response.pathsend', 'path': str(payload_path)}) + + server, port = await _start_server(app, http_versions=['3'], transport='udp') + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-pathsend') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + target = ('127.0.0.1', port) + sock.sendto(client.build_initial(), target) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + request_payload = core.get_request(0).encode_request( + [ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/'), + (b':authority', b'localhost'), + ], + body=b'x' * 6000, + ) + sock.sendto(client.send_stream_data(0, request_payload, fin=True), target) + response_headers, body = await _read_h3_response_with_client_progress( + sock, core, client, target + ) + assert dict(response_headers)[b':status'] == b'200' + assert body == payload + finally: + sock.close() + await server.close() + + +@pytest.mark.asyncio +async def test_cli_main_allows_static_only_mount_without_app_import_string(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'index.html').write_text('ok', encoding='utf-8') + with patch('tigrcorn.cli.run_config') as run_config: + rc = cli_main( + [ + '--static-path-route', + '/assets', + '--static-path-mount', + str(root), + '--static-path-expires', + '60', + ] + ) + assert rc == 0 + run_config.assert_called_once() + config = run_config.call_args.args[0] + assert config.static.route == '/assets' + assert config.static.mount == str(root) + assert config.static.expires == 60 + assert config.app.target is None diff --git a/tests/test_phase3_h1_websocket_operator_surface_pytest.py b/tests/test_phase3_h1_websocket_operator_surface_pytest.py new file mode 100644 index 00000000..96e47aab --- /dev/null +++ b/tests/test_phase3_h1_websocket_operator_surface_pytest.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import asyncio +import base64 +import logging +import os +from argparse import Namespace +from unittest.mock import patch + +from tigrcorn.asgi.receive import QueueReceive +from tigrcorn.cli import build_parser +from tigrcorn.config.load import build_config, build_config_from_namespace +from tigrcorn.errors import ProtocolError +from tigrcorn.observability.logging import AccessLogger +from tigrcorn.protocols.http1.parser import ParsedRequest, read_http11_request_head +from tigrcorn.protocols.http2.websocket import H2WebSocketSession +from tigrcorn.protocols.http3.websocket import H3WebSocketSession +from tigrcorn.protocols.websocket.handler import WebSocketConnectionHandler +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.tcp.reader import PrebufferedReader + + +import pytest +def _websocket_request_headers() -> list[tuple[bytes, bytes]]: + return [ + (b'host', b'localhost'), + (b'upgrade', b'websocket'), + (b'connection', b'Upgrade'), + (b'sec-websocket-version', b'13'), + (b'sec-websocket-key', base64.b64encode(os.urandom(16))), + ] + + +def _build_ws_request(path: str = '/ws') -> ParsedRequest: + return ParsedRequest( + method='GET', + target=path, + path=path, + raw_path=path.encode('ascii'), + query_string=b'', + http_version='1.1', + headers=_websocket_request_headers(), + body=b'', + keep_alive=True, + expect_continue=False, + websocket_upgrade=True, + ) + + +async def _start_http11_server(app, *, config_mutator=None): + config = build_config(app=None, host='127.0.0.1', port=0, lifespan='off', http_versions=['1.1']) + if config_mutator is not None: + config_mutator(config) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _read_http_response(reader: asyncio.StreamReader) -> tuple[bytes, bytes]: + head = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), 1.0) + length = 0 + for line in head.split(b'\r\n'): + if line.lower().startswith(b'content-length:'): + length = int(line.split(b':', 1)[1].strip()) + break + body = await asyncio.wait_for(reader.readexactly(length), 1.0) if length else b'' + return head, body + + + +def test_parser_accepts_phase3_flags(): + parser = build_parser() + ns = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--http1-max-incomplete-event-size', '8192', + '--http1-buffer-size', '4096', + '--http1-header-read-timeout', '2.5', + '--no-http1-keep-alive', + '--websocket-max-queue', '64', + ] + ) + assert ns.http1_max_incomplete_event_size == 8192 + assert ns.http1_buffer_size == 4096 + assert ns.http1_header_read_timeout == 2.5 + assert not (ns.http1_keep_alive) + assert ns.websocket_max_queue == 64 +def test_build_config_from_namespace_maps_phase3_submodels(): + parser = build_parser() + ns = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--http1-max-incomplete-event-size', '16384', + '--http1-buffer-size', '2048', + '--http1-header-read-timeout', '1.25', + '--no-http1-keep-alive', + '--websocket-max-message-size', '2048', + '--websocket-max-queue', '8', + ] + ) + config = build_config_from_namespace(ns) + assert config.http.http1_max_incomplete_event_size == 16384 + assert config.http.http1_buffer_size == 2048 + assert config.http.http1_header_read_timeout == 1.25 + assert not (config.http.http1_keep_alive) + assert config.websocket.max_message_size == 2048 + assert config.websocket.max_queue == 8 +def test_phase3_env_surface_is_respected(): + parser = build_parser() + ns = parser.parse_args(['--env-prefix', 'PHASE3TEST']) + with patch.dict( + os.environ, + { + 'PHASE3TEST_HTTP1_BUFFER_SIZE': '4096', + 'PHASE3TEST_HTTP1_MAX_INCOMPLETE_EVENT_SIZE': '8192', + 'PHASE3TEST_HTTP1_HEADER_READ_TIMEOUT': '0.75', + 'PHASE3TEST_HTTP1_KEEP_ALIVE': 'false', + 'PHASE3TEST_WEBSOCKET_MAX_QUEUE': '12', + }, + clear=False, + ): + config = build_config_from_namespace(ns) + assert config.http.http1_buffer_size == 4096 + assert config.http.http1_max_incomplete_event_size == 8192 + assert config.http.http1_header_read_timeout == 0.75 + assert not (config.http.http1_keep_alive) + assert config.websocket.max_queue == 12 +async def test_http11_parser_applies_incomplete_event_cap(): + reader = asyncio.StreamReader() + request = ( + b'GET / HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'X-Large: ' + (b'a' * 128) + b'\r\n\r\n' + ) + reader.feed_data(request) + reader.feed_eof() + with pytest.raises(ProtocolError): + await read_http11_request_head( + PrebufferedReader(reader), + max_header_size=4096, + max_incomplete_event_size=64, + ) + +async def test_http11_buffer_size_controls_streaming_request_chunks(): + seen_chunks: list[int] = [] + + async def app(scope, receive, send): + while True: + message = await receive() + if message['type'] != 'http.request': + break + seen_chunks.append(len(message.get('body', b''))) + if not message.get('more_body', False): + break + body = ','.join(str(size) for size in seen_chunks).encode('ascii') + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': body, 'more_body': False}) + + server, port = await _start_http11_server( + app, + config_mutator=lambda cfg: setattr(cfg.http, 'http1_buffer_size', 4), + ) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + payload = b'abcdefghij' + request = ( + b'POST /upload HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'Content-Length: 10\r\n\r\n' + payload + ) + writer.write(request) + await writer.drain() + _head, body = await _read_http_response(reader) + assert body == b'4,4,2' + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http11_keep_alive_disable_forces_connection_close(): + seen_paths: list[str] = [] + + async def app(scope, receive, send): + seen_paths.append(scope['path']) + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port = await _start_http11_server( + app, + config_mutator=lambda cfg: setattr(cfg.http, 'http1_keep_alive', False), + ) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write( + b'GET /first HTTP/1.1\r\nHost: localhost\r\n\r\n' + b'GET /second HTTP/1.1\r\nHost: localhost\r\n\r\n' + ) + await writer.drain() + head, body = await _read_http_response(reader) + assert b'connection: close' in head.lower() + assert body == b'ok' + tail = await asyncio.wait_for(reader.read(), 1.0) + assert tail == b'' + assert seen_paths == ['/first'] + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http11_header_read_timeout_tightens_generic_timeout(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + def mutate(cfg): + cfg.http.read_timeout = 5.0 + cfg.http.keep_alive_timeout = 5.0 + cfg.http.http1_header_read_timeout = 0.1 + + server, port = await _start_http11_server(app, config_mutator=mutate) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost') + await writer.drain() + data = await asyncio.wait_for(reader.read(), 1.0) + assert data == b'' + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_queue_receive_honors_max_size(): + receive = QueueReceive(max_size=1) + await receive.put({'type': 'one'}) + blocked = asyncio.create_task(receive.put({'type': 'two'})) + await asyncio.sleep(0.05) + assert not (blocked.done()) + first = await receive() + assert first['type'] == 'one' + await asyncio.wait_for(blocked, 1.0) + second = await receive() + assert second['type'] == 'two' + assert receive.max_size == 1 +def test_websocket_handlers_receive_queue_size_from_config(): + config = build_config(websocket_max_queue=7) + request = _build_ws_request() + access_logger = AccessLogger(logging.getLogger('phase3'), enabled=False) + + h1 = WebSocketConnectionHandler( + app=lambda scope, receive, send: None, + config=config, + access_logger=access_logger, + request=request, + reader=object(), + writer=object(), + client=('127.0.0.1', 1), + server=('127.0.0.1', 2), + scheme='ws', + ) + assert h1.receive.max_size == 7 + async def _send_headers(status: int, headers: list[tuple[bytes, bytes]], end_stream: bool) -> None: + return None + + async def _send_data(data: bytes, end_stream: bool) -> None: + return None + + h2 = H2WebSocketSession( + app=lambda scope, receive, send: None, + config=config, + request=request, + client=('127.0.0.1', 1), + server=('127.0.0.1', 2), + scheme='https', + send_headers=_send_headers, + send_data=_send_data, + ) + assert h2.receive.max_size == 7 + h3 = H3WebSocketSession( + app=lambda scope, receive, send: None, + config=config, + request=request, + client=('127.0.0.1', 1), + server=('127.0.0.1', 2), + scheme='https', + send_headers=_send_headers, + send_data=_send_data, + ) + assert h3.receive.max_size == 7 \ No newline at end of file diff --git a/tests/test_phase3_strict_rfc_surface_pytest.py b/tests/test_phase3_strict_rfc_surface_pytest.py new file mode 100644 index 00000000..c520de67 --- /dev/null +++ b/tests/test_phase3_strict_rfc_surface_pytest.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import asyncio +import base64 +import tempfile +import zlib +from pathlib import Path + +from tigrcorn.cli import build_parser +from tigrcorn.config.load import build_config, build_config_from_namespace +from tigrcorn.protocols.websocket.frames import encode_frame, read_frame +from tigrcorn.security.tls import build_server_ssl_context +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.security.tls13.handshake import generate_self_signed_certificate + + +import pytest +async def _start_http11_server(app, *, config_mutator=None): + config = build_config(app=None, host='127.0.0.1', port=0, lifespan='off', http_versions=['1.1']) + if config_mutator is not None: + config_mutator(config) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +def _compress_ws_message(payload: bytes) -> bytes: + compressor = zlib.compressobj(wbits=-15) + compressed = compressor.compress(payload) + compressor.flush(zlib.Z_SYNC_FLUSH) + assert compressed.endswith(b'\x00\x00\xff\xff') + return compressed[:-4] + + + +async def test_cli_phase3_flags_round_trip_into_config(): + parser = build_parser() + ns = parser.parse_args([ + 'tests.fixtures_pkg.appmod:app', + '--ssl-ocsp-mode', 'require', + '--ssl-ocsp-soft-fail', + '--ssl-ocsp-cache-size', '64', + '--ssl-ocsp-max-age', '30', + '--ssl-crl-mode', 'soft-fail', + '--ssl-revocation-fetch', 'off', + '--ssl-alpn', 'h2,http/1.1', + '--connect-policy', 'allowlist', + '--connect-allow', '127.0.0.1:443,10.0.0.0/8', + '--trailer-policy', 'drop', + '--content-coding-policy', 'strict', + '--content-codings', 'gzip,deflate', + '--websocket-compression', 'permessage-deflate', + ]) + config = build_config_from_namespace(ns) + assert config.tls.ocsp_mode == 'require' + assert config.tls.ocsp_soft_fail + assert config.tls.ocsp_cache_size == 64 + assert config.tls.ocsp_max_age == 30 + assert config.tls.crl_mode == 'soft-fail' + assert not (config.tls.revocation_fetch) + assert config.tls.alpn_protocols == ['h2', 'http/1.1'] + assert config.http.connect_policy == 'allowlist' + assert config.http.connect_allow == ['127.0.0.1:443', '10.0.0.0/8'] + assert config.http.trailer_policy == 'drop' + assert config.http.content_coding_policy == 'strict' + assert config.http.content_codings == ['gzip', 'deflate'] + assert config.websocket.compression == 'permessage-deflate' +async def test_http11_websocket_compression_auto_negotiates_when_enabled(): + seen = {} + + async def app(scope, receive, send): + await receive() + await send({'type': 'websocket.accept'}) + event = await receive() + seen['event'] = event + await send({'type': 'websocket.send', 'text': event['text']}) + await send({'type': 'websocket.close', 'code': 1000}) + + def _mutate(config): + config.websocket.compression = 'permessage-deflate' + + server, port = await _start_http11_server(app, config_mutator=_mutate) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + key = base64.b64encode(b'0123456789abcdef') + writer.write( + b'GET /ws HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'Upgrade: websocket\r\n' + b'Connection: Upgrade\r\n' + b'Sec-WebSocket-Version: 13\r\n' + b'Sec-WebSocket-Key: ' + key + b'\r\n' + b'Sec-WebSocket-Extensions: permessage-deflate\r\n\r\n' + ) + await writer.drain() + response = await reader.readuntil(b'\r\n\r\n') + assert b'sec-websocket-extensions: permessage-deflate' in response.lower() + writer.write(encode_frame(0x1, _compress_ws_message(b'hello compressed'), masked=True, rsv1=True)) + await writer.drain() + frame = await asyncio.wait_for(read_frame(reader, max_payload_size=4096, expect_masked=False, allow_rsv1=True), 1.0) + assert frame.rsv1 + echoed = zlib.decompressobj(wbits=-15).decompress(frame.payload + b'\x00\x00\xff\xff') + assert echoed == b'hello compressed' + assert seen['event']['text'] == 'hello compressed' + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http11_websocket_compression_policy_off_strips_app_requested_extension(): + async def app(scope, receive, send): + await receive() + await send({'type': 'websocket.accept', 'headers': [(b'sec-websocket-extensions', b'permessage-deflate')]}) + await send({'type': 'websocket.close', 'code': 1000}) + + def _mutate(config): + config.websocket.compression = 'off' + + server, port = await _start_http11_server(app, config_mutator=_mutate) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + key = base64.b64encode(b'fedcba9876543210') + writer.write( + b'GET /ws HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'Upgrade: websocket\r\n' + b'Connection: Upgrade\r\n' + b'Sec-WebSocket-Version: 13\r\n' + b'Sec-WebSocket-Key: ' + key + b'\r\n' + b'Sec-WebSocket-Extensions: permessage-deflate\r\n\r\n' + ) + await writer.drain() + response = await reader.readuntil(b'\r\n\r\n') + assert b'sec-websocket-extensions:' not in response.lower() + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http11_connect_policy_deny_and_allowlist(): + received = bytearray() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + data = await reader.read(1024) + received.extend(data) + writer.write(data[::-1]) + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, '127.0.0.1', 0) + upstream_port = upstream.sockets[0].getsockname()[1] + try: + async def app(scope, receive, send): + raise AssertionError('CONNECT should not dispatch to ASGI app') + + def deny(config): + config.http.connect_policy = 'deny' + + server, port = await _start_http11_server(app, config_mutator=deny) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(f'CONNECT 127.0.0.1:{upstream_port} HTTP/1.1\r\nHost: localhost\r\n\r\n'.encode('ascii')) + await writer.drain() + head = await reader.readuntil(b'\r\n\r\n') + assert b'403' in head + writer.close() + await writer.wait_closed() + finally: + await server.close() + + def allowlist(config): + config.http.connect_policy = 'allowlist' + config.http.connect_allow = [f'127.0.0.1:{upstream_port}'] + + server, port = await _start_http11_server(app, config_mutator=allowlist) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(f'CONNECT 127.0.0.1:{upstream_port} HTTP/1.1\r\nHost: localhost\r\n\r\n'.encode('ascii')) + await writer.drain() + head = await reader.readuntil(b'\r\n\r\n') + assert b'200 Connection Established' in head + writer.write(b'abcdef') + await writer.drain() + echoed = await asyncio.wait_for(reader.readexactly(6), 1.0) + assert echoed == b'fedcba' + assert bytes(received) == b'abcdef' + writer.close() + await writer.wait_closed() + finally: + await server.close() + finally: + upstream.close() + await upstream.wait_closed() + +async def test_http11_trailer_policy_drop_suppresses_trailer_event(): + seen_types = [] + + async def app(scope, receive, send): + while True: + message = await receive() + seen_types.append(message['type']) + if message['type'] == 'http.request' and not message.get('more_body', False): + break + if message['type'] == 'http.disconnect': + break + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + def _mutate(config): + config.http.trailer_policy = 'drop' + + server, port = await _start_http11_server(app, config_mutator=_mutate) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write( + b'POST / HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'Transfer-Encoding: chunked\r\n\r\n' + b'5\r\nhello\r\n' + b'0\r\nX-Test: yes\r\n\r\n' + ) + await writer.drain() + await reader.readuntil(b'\r\n\r\n') + assert 'http.request.trailers' not in seen_types + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http11_content_coding_identity_only_disables_gzip(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'compress-me', 'more_body': False}) + + def _mutate(config): + config.http.content_coding_policy = 'identity-only' + config.http.content_codings = ['gzip', 'deflate'] + + server, port = await _start_http11_server(app, config_mutator=_mutate) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write( + b'GET / HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'Accept-Encoding: gzip\r\n\r\n' + ) + await writer.drain() + head = await reader.readuntil(b'\r\n\r\n') + assert b'content-encoding: gzip' not in head.lower() + writer.close() + await writer.wait_closed() + finally: + await server.close() + +def test_build_server_ssl_context_uses_public_alpn_and_revocation_policy(): + cert_pem, key_pem = generate_self_signed_certificate('server.example') + with tempfile.TemporaryDirectory() as tmp: + cert_path = Path(tmp) / 'cert.pem' + key_path = Path(tmp) / 'key.pem' + ca_path = Path(tmp) / 'ca.pem' + cert_path.write_bytes(cert_pem) + key_path.write_bytes(key_pem) + ca_path.write_bytes(cert_pem) + from tigrcorn.config.model import ListenerConfig + listener = ListenerConfig( + kind='tcp', + host='server.example', + port=443, + ssl_certfile=str(cert_path), + ssl_keyfile=str(key_path), + ssl_ca_certs=str(ca_path), + ssl_require_client_cert=True, + alpn_protocols=['h2', 'http/1.1'], + ocsp_mode='require', + ocsp_cache_size=33, + ocsp_max_age=30.0, + crl_mode='off', + revocation_fetch=False, + ) + context = build_server_ssl_context(listener) + assert context is not None + assert context.alpn_protocols == ('h2', 'http/1.1') + assert context.validation_policy.revocation_mode.value == 'require' + assert context.validation_policy.revocation_fetch_policy is None diff --git a/tests/test_phase3_transport_core_strictness_checkpoint_pytest.py b/tests/test_phase3_transport_core_strictness_checkpoint_pytest.py new file mode 100644 index 00000000..dc246e1f --- /dev/null +++ b/tests/test_phase3_transport_core_strictness_checkpoint_pytest.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from tigrcorn.errors import ProtocolError, UnsupportedFeature +from tigrcorn.protocols.http1.parser import http11_request_head_error_matrix, read_http11_request_head +from tigrcorn.protocols.http1.serializer import http11_response_metadata_rules, response_allows_body +from tigrcorn.protocols.http2.state import H2StreamLifecycle, H2StreamState, h2_connection_rule_table, h2_stream_transition_table +from tigrcorn.protocols.http3.state import ( + HTTP3RequestPhase_DATA, + HTTP3RequestPhase_INITIAL, + HTTP3RequestPhase_TRAILERS, + http3_control_stream_rule_table, + http3_qpack_accounting_rule_table, + http3_request_transition_table, +) +from tigrcorn.security.tls13.handshake import tls13_handshake_state_table +from tigrcorn.transports.quic.connection import quic_connection_state_table, quic_transport_error_matrix +from tigrcorn.transports.quic.recovery import quic_recovery_rule_table +from tigrcorn.transports.quic.streams import ( + FRAME_ACK, + FRAME_PING, + QuicConnectionCloseFrame, + QuicHandshakeDoneFrame, + QuicNewConnectionIdFrame, + QuicPathChallengeFrame, + quic_packet_space_legality_table, + quic_packet_space_prohibitions, + validate_frame_for_packet_space, +) +from tigrcorn.transports.tcp.reader import PrebufferedReader + + +def test_http11_error_matrix_exports_core_cases(): + matrix = {entry['case']: entry for entry in http11_request_head_error_matrix()} + for case in ( + 'request_line_shape', + 'host_header_requirements', + 'transfer_encoding_chain', + 'content_length_and_chunked_conflict', + 'chunked_body_syntax', + ): + assert case in matrix + +def test_http11_response_metadata_rules_cover_bodyless_statuses(): + selectors = {entry['selector']: entry for entry in http11_response_metadata_rules()} + assert not (selectors['1xx']['allows_body']) + assert not (selectors['204']['allows_body']) + assert not (response_allows_body(103)) + assert not (response_allows_body(204)) + assert not (response_allows_body(304)) + assert response_allows_body(200) + +def test_http11_runtime_examples_match_exported_errors(): + async def invalid_request_line() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b'GET /missing-version\r\nHost: example.com\r\n\r\n') + reader.feed_eof() + with pytest.raises(ProtocolError): + await read_http11_request_head(PrebufferedReader(reader)) + + async def missing_host() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b'GET / HTTP/1.1\r\n\r\n') + reader.feed_eof() + with pytest.raises(ProtocolError): + await read_http11_request_head(PrebufferedReader(reader)) + + async def unsupported_te_chain() -> None: + reader = asyncio.StreamReader() + reader.feed_data( + b'POST /upload HTTP/1.1\r\n' + b'Host: example.com\r\n' + b'Transfer-Encoding: gzip, chunked\r\n\r\n' + ) + reader.feed_eof() + with pytest.raises(UnsupportedFeature): + await read_http11_request_head(PrebufferedReader(reader)) + + asyncio.run(invalid_request_line()) + asyncio.run(missing_host()) + asyncio.run(unsupported_te_chain()) + +def test_http2_transition_table_matches_lifecycle_methods(): + transitions = list(h2_stream_transition_table()) + assert { + 'from': 'idle', + 'event': 'remote headers', + 'to': 'open', + 'notes': 'peer opens the stream without END_STREAM', + } in transitions + assert any(entry['to'] == 'closed' for entry in transitions) + + state = H2StreamState(1) + assert state.lifecycle == H2StreamLifecycle.IDLE + state.open_remote(end_stream=False) + assert state.lifecycle == H2StreamLifecycle.OPEN + state.receive_end_stream() + assert state.lifecycle == H2StreamLifecycle.HALF_CLOSED_REMOTE + state.send_end_stream() + assert state.lifecycle == H2StreamLifecycle.CLOSED + + rules = {entry['rule'] for entry in h2_connection_rule_table()} + assert 'first-frame-after-preface-is-settings' in rules + assert 'goaway-last-stream-id-monotonic' in rules + +def test_http3_request_control_and_qpack_tables_are_explicit(): + request_table = list(http3_request_transition_table()) + assert request_table[0]['from'] == 'initial' + assert any(entry['to'] == 'ready' for entry in request_table) + assert HTTP3RequestPhase_INITIAL == 'initial' + assert HTTP3RequestPhase_DATA == 'data' + assert HTTP3RequestPhase_TRAILERS == 'trailers' + + control_rules = {entry['rule'] for entry in http3_control_stream_rule_table()} + assert 'single-control-stream' in control_rules + assert 'control-stream-begins-with-settings' in control_rules + assert 'goaway-id-must-not-increase' in control_rules + + qpack_rules = {entry['rule'] for entry in http3_qpack_accounting_rule_table()} + assert 'blocked-header-sections-are-retained' in qpack_rules + assert 'field-section-errors-map-to-decompression-failed' in qpack_rules + +def test_quic_packet_space_legality_exports_match_runtime_validator(): + table = quic_packet_space_legality_table() + assert 'PING' in table['initial'] + assert 'CRYPTO' in table['handshake'] + assert 'HANDSHAKE_DONE' not in table['initial'] + + validate_frame_for_packet_space(FRAME_PING, 'initial') + validate_frame_for_packet_space(FRAME_ACK, 'handshake') + + with pytest.raises(ProtocolError): + validate_frame_for_packet_space(QuicHandshakeDoneFrame(), 'initial', is_client=False) + with pytest.raises(ProtocolError): + validate_frame_for_packet_space(QuicHandshakeDoneFrame(), 'application', is_client=True) + with pytest.raises(ProtocolError): + validate_frame_for_packet_space(QuicPathChallengeFrame(b'12345678'), '0rtt') + with pytest.raises(ProtocolError): + validate_frame_for_packet_space( + QuicNewConnectionIdFrame(sequence=1, retire_prior_to=0, connection_id=b'cid', stateless_reset_token=b'0' * 16), + '0rtt', + ) + with pytest.raises(ProtocolError): + validate_frame_for_packet_space(QuicConnectionCloseFrame(error_code=1, frame_type=0, reason='x', application=True), 'handshake') + + prohibitions = list(quic_packet_space_prohibitions()) + assert any(entry['packet_space'] == 'client-only' for entry in prohibitions) + +def test_quic_recovery_and_state_tables_exist(): + recovery_rules = {entry['rule'] for entry in quic_recovery_rule_table()} + assert 'packet-threshold-loss' in recovery_rules + assert 'pto-base' in recovery_rules + assert 'pacing-budget' in recovery_rules + + state_table = list(quic_connection_state_table()) + assert any(entry['to'] == 'established' for entry in state_table) + assert any(entry['to'] == 'closed' for entry in state_table) + + error_matrix = {entry['name']: entry['code'] for entry in quic_transport_error_matrix()} + assert 'PROTOCOL_VIOLATION' in error_matrix + assert 'INVALID_TOKEN' in error_matrix + +def test_tls13_handshake_state_table_is_exported(): + table = list(tls13_handshake_state_table()) + assert any(entry['from'] == 'client_idle' and entry['to'] == 'client_wait_server' for entry in table) + assert any(entry['to'] == 'complete' for entry in table) + +def test_generated_phase3_snapshot_files_exist(): + root = Path(__file__).resolve().parents[1] / 'docs' / 'review' / 'conformance' / 'phase3_transport_core' + expected = [ + 'http11_error_matrix.json', + 'http2_stream_transition_table.json', + 'http3_request_transition_table.json', + 'quic_packet_space_legality.json', + 'quic_recovery_rules.json', + 'tls13_handshake_state_table.json', + 'interop_evidence_manifest.json', + ] + for name in expected: + path = root / name + assert path.is_file(), name + payload = json.loads(path.read_text(encoding='utf-8')) + assert payload + + manifest = json.loads((root / 'interop_evidence_manifest.json').read_text(encoding='utf-8')) + for entry in manifest['scenario_classes']: + artifact = Path(__file__).resolve().parents[1] / entry['artifact'] + assert artifact.is_file(), entry['artifact'] + diff --git a/tests/test_phase4_advanced_protocol_delivery_checkpoint_pytest.py b/tests/test_phase4_advanced_protocol_delivery_checkpoint_pytest.py new file mode 100644 index 00000000..75e3e4b7 --- /dev/null +++ b/tests/test_phase4_advanced_protocol_delivery_checkpoint_pytest.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import asyncio +import gzip +import json +import socket +import tempfile +from pathlib import Path + +import pytest +from tigrcorn import EmbeddedServer, StaticFilesApp +from tigrcorn.cli import build_parser +from tigrcorn.config.load import build_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.constants import H2_PREFACE +from tigrcorn.http.alt_svc import configured_alt_svc_values +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, decode_settings, serialize_settings +from tigrcorn.protocols.http2.hpack import HPACKDecoder, decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.bootstrap import runtime_compatibility_matrix +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server( + app, + *, + tcp_versions: list[str] | None = None, + include_udp_http3: bool = False, + alt_svc_auto: bool = False, + alt_svc: list[str] | None = None, +): + config = build_config( + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=tcp_versions or ['1.1'], + quic_secret=b'shared', + alt_svc_auto=alt_svc_auto, + alt_svc=alt_svc, + ) + if include_udp_http3: + config.listeners.append( + ListenerConfig( + kind='udp', + host='127.0.0.1', + port=0, + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + ) + server = TigrCornServer(app, config) + await server.start() + tcp_port = None + udp_port = None + for listener in server._listeners: + if hasattr(listener, 'server') and getattr(listener, 'server', None) is not None: + sockets = listener.server.sockets or [] + if sockets: + tcp_port = sockets[0].getsockname()[1] + if hasattr(listener, 'transport') and getattr(listener, 'transport', None) is not None: + udp_port = listener.transport.get_extra_info('sockname')[1] + return server, tcp_port, udp_port + + +async def _read_http1_response(reader: asyncio.StreamReader) -> tuple[bytes, dict[bytes, bytes], bytes]: + head = await reader.readuntil(b'\r\n\r\n') + headers: dict[bytes, bytes] = {} + for line in head.split(b'\r\n')[1:]: + if not line: + continue + name, value = line.split(b':', 1) + headers[name.strip().lower()] = value.strip() + length = int(headers.get(b'content-length', b'0')) + body = await reader.readexactly(length) if length else b'' + return head, headers, body + + +async def _read_h2_response_sequence(reader: asyncio.StreamReader) -> tuple[list[list[tuple[bytes, bytes]]], bytes]: + buf = FrameBuffer() + decoder = HPACKDecoder() + header_blocks: list[list[tuple[bytes, bytes]]] = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS and frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + header_blocks.append(decoder.decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if header_blocks and ended: + break + return header_blocks, bytes(body) + + +async def _prime_http3(sock: socket.socket, client: QuicConnection, core: HTTP3ConnectionCore, *, port: int) -> None: + loop = asyncio.get_running_loop() + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + received = 0 + for _ in range(4): + try: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + except TimeoutError: + if received: + break + raise + received += 1 + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + +async def _read_h3_response_state(sock: socket.socket, client: QuicConnection, core: HTTP3ConnectionCore, *, stream_id: int): + loop = asyncio.get_running_loop() + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if event.stream_id == stream_id: + response_state = state + assert response_state is not None + return response_state + + +def test_runtime_compatibility_matrix_is_honest(): + matrix = runtime_compatibility_matrix() + assert set(matrix) == {'auto', 'asyncio', 'uvloop'} + assert matrix['auto']['implemented'] + assert 'trio' not in matrix + +def test_public_runtime_surface_descopes_trio(): + parser = build_parser() + with pytest.raises(SystemExit): + parser.parse_args(['tests.fixtures_pkg.appmod:app', '--runtime', 'trio']) + matrix_path = Path('docs/review/conformance/phase4_advanced_delivery/runtime_compatibility_matrix.json') + assert json.loads(matrix_path.read_text()) == runtime_compatibility_matrix() + +def test_alt_svc_auto_values_resolve_and_suppress_on_http3(): + config = build_config(host='127.0.0.1', port=8080, lifespan='off', alt_svc_auto=True) + config.listeners.append( + ListenerConfig( + kind='udp', + host='127.0.0.1', + port=8443, + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + ) + assert configured_alt_svc_values(config, request_http_version='3') == [] + assert b'h3=":8443"; ma=86400' in configured_alt_svc_values(config, request_http_version='2') + +def test_phase4_artifacts_and_examples_exist(): + expected = [ + Path('docs/review/conformance/phase4_advanced_delivery/early_hints_support_statement.json'), + Path('docs/review/conformance/phase4_advanced_delivery/alt_svc_support_statement.json'), + Path('docs/review/conformance/phase4_advanced_delivery/runtime_compatibility_matrix.json'), + Path('docs/review/conformance/phase4_advanced_delivery/examples_matrix.json'), + Path('docs/review/conformance/phase4_advanced_protocol_delivery_checkpoint.current.json'), + Path('docs/review/conformance/state/checkpoints/CURRENT_REPOSITORY_STATE_PHASE4_ADVANCED_PROTOCOL_DELIVERY_CHECKPOINT.md'), + Path('examples/advanced_delivery/app.py'), + Path('examples/advanced_delivery/client_http1.py'), + Path('examples/advanced_delivery/client_http2.py'), + Path('examples/advanced_delivery/client_http3.py'), + Path('examples/PHASE4_PROTOCOL_PAIRING.md'), + ] + for path in expected: + assert path.exists(), path + +def test_phase4_status_json_is_honest(): + payload = json.loads(Path('docs/review/conformance/phase4_advanced_protocol_delivery_checkpoint.current.json').read_text()) + assert payload['phase'] == 4 + assert not (payload['boundary']['expanded_program_fully_featured']) + assert not (payload['boundary']['expanded_program_fully_rfc_compliant']) + assert payload['implemented']['runtime_embedding']['trio_surface'] == 'descoped_not_supported' + assert payload['implemented']['runtime_embedding']['supported_runtimes'] == ['auto', 'asyncio', 'uvloop'] + + +@pytest.mark.asyncio +async def test_static_files_precompressed_sidecars_and_range_identity_coexist(): + async def receive() -> dict: + return {'type': 'http.request', 'body': b'', 'more_body': False} + + sent: list[dict] = [] + + async def send(message: dict) -> None: + sent.append(message) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload = b'hello world from static sidecar' + (root / 'hello.txt').write_bytes(payload) + encoded = gzip.compress(payload) + (root / 'hello.txt.gz').write_bytes(encoded) + app = StaticFilesApp(root) + + await app({'type': 'http', 'method': 'GET', 'path': '/hello.txt', 'headers': [(b'accept-encoding', b'gzip')]}, receive, send) + assert sent[0]['status'] == 200 + headers = dict(sent[0]['headers']) + assert headers[b'content-encoding'] == b'gzip' + assert headers[b'vary'] == b'accept-encoding' + assert sent[1]['body'] == encoded + + sent.clear() + await app({'type': 'http', 'method': 'HEAD', 'path': '/hello.txt', 'headers': [(b'accept-encoding', b'gzip')]}, receive, send) + assert sent[0]['status'] == 200 + headers = dict(sent[0]['headers']) + assert headers[b'content-length'] == str(len(encoded)).encode('ascii') + assert sent[1]['body'] == b'' + + sent.clear() + await app( + { + 'type': 'http', + 'method': 'GET', + 'path': '/hello.txt', + 'headers': [(b'accept-encoding', b'gzip'), (b'range', b'bytes=6-10')], + }, + receive, + send, + ) + assert sent[0]['status'] == 206 + headers = dict(sent[0]['headers']) + assert b'content-encoding' not in headers + assert headers[b'content-range'] == b'bytes 6-10/31' + assert sent[1]['body'] == b'world' + + +@pytest.mark.asyncio +async def test_embedded_server_context_manager_runs_hooks(): + events: list[str] = [] + + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'embedded', 'more_body': False}) + + async def on_start(server) -> None: + events.append('startup') + + async def on_stop(server) -> None: + events.append('shutdown') + + config = build_config(host='127.0.0.1', port=0, lifespan='off') + config.hooks.on_startup = [on_start] + config.hooks.on_shutdown = [on_stop] + + async with EmbeddedServer(app, config) as embedded: + endpoints = embedded.bound_endpoints() + assert endpoints + port = endpoints[0][1] + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + _head, _headers, body = await _read_http1_response(reader) + assert body == b'embedded' + writer.close() + await writer.wait_closed() + assert events == ['startup', 'shutdown'] + +@pytest.mark.asyncio +async def test_http11_early_hints_and_alt_svc_auto(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 103, 'headers': [(b'link', b'; rel=preload; as=script'), (b'x-unsafe', b'no')]}) + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port, udp_port = await _start_server(app, tcp_versions=['1.1'], include_udp_http3=True, alt_svc_auto=True) + assert port is not None and udp_port is not None + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + interim_head, interim_headers, interim_body = await _read_http1_response(reader) + assert b'103 Early Hints' in interim_head + assert interim_body == b'' + assert b'link' in interim_headers + assert b'x-unsafe' not in interim_headers + final_head, final_headers, final_body = await _read_http1_response(reader) + assert b'200 OK' in final_head + assert final_body == b'ok' + assert final_headers[b'alt-svc'] == f'h3=":{udp_port}"; ma=86400'.encode('ascii') + writer.close() + await writer.wait_closed() + finally: + await server.close() + +@pytest.mark.asyncio +async def test_http2_early_hints_and_alt_svc_auto(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 103, 'headers': [(b'link', b'; rel=preload; as=style'), (b'x-unsafe', b'no')]}) + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port, udp_port = await _start_server(app, tcp_versions=['2'], include_udp_http3=True, alt_svc_auto=True) + assert port is not None and udp_port is not None + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + request_headers = encode_header_block([ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/'), + (b':authority', b'localhost'), + ]) + writer.write(frame_writer.headers(1, request_headers, end_stream=True)) + await writer.drain() + header_blocks, body = await _read_h2_response_sequence(reader) + assert len(header_blocks) >= 2 + interim = header_blocks[0] + final = header_blocks[-1] + assert (b':status', b'103') in interim + assert (b'link', b'; rel=preload; as=style') in interim + assert (b'x-unsafe', b'no') not in interim + assert (b':status', b'200') in final + assert (b'alt-svc', f'h3=":{udp_port}"; ma=86400'.encode('ascii')) in final + assert body == b'ok' + writer.close() + await writer.wait_closed() + finally: + await server.close() + +@pytest.mark.asyncio +async def test_http3_early_hints_are_preserved_before_final_headers(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 103, 'headers': [(b'link', b'; rel=preload; as=script'), (b'x-unsafe', b'no')]}) + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, _port, udp_port = await _start_server(app, tcp_versions=['1.1'], include_udp_http3=True, alt_svc_auto=True) + assert udp_port is not None + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'phase4-http3') + core = HTTP3ConnectionCore(role='client') + try: + await _prime_http3(sock, client, core, port=udp_port) + request_stream_id = 0 + payload = core.get_request(request_stream_id).encode_request([ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/'), + (b':authority', b'localhost'), + ]) + sock.sendto(client.send_stream_data(request_stream_id, payload, fin=True), ('127.0.0.1', udp_port)) + response_state = await _read_h3_response_state(sock, client, core, stream_id=request_stream_id) + assert len(response_state.informational_headers) == 1 + assert (b':status', b'103') in response_state.informational_headers[0] + assert (b'link', b'; rel=preload; as=script') in response_state.informational_headers[0] + assert (b'x-unsafe', b'no') not in response_state.informational_headers[0] + assert (b':status', b'200') in response_state.headers + assert response_state.body == b'ok' + finally: + sock.close() + await server.close() + diff --git a/tests/test_phase4_http2_operator_surface_pytest.py b/tests/test_phase4_http2_operator_surface_pytest.py new file mode 100644 index 00000000..19cef2c3 --- /dev/null +++ b/tests/test_phase4_http2_operator_surface_pytest.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import asyncio +import logging +import os +from unittest.mock import patch + +from tigrcorn.cli import build_parser +from tigrcorn.config.load import build_config, build_config_from_namespace +from tigrcorn.constants import H2_PREFACE +from tigrcorn.observability.logging import AccessLogger +import pytest +from tigrcorn.protocols.http2.codec import ( + FRAME_PING, + FRAME_SETTINGS, + FRAME_WINDOW_UPDATE, + FrameBuffer, + HTTP2Frame, + decode_settings, + parse_window_update, + serialize_settings, +) +from tigrcorn.protocols.http2.flow import next_adaptive_window_target +from tigrcorn.protocols.http2.handler import HTTP2ConnectionHandler +from tigrcorn.server.runner import TigrCornServer + + +async def _start_http2_server(app, *, config_mutator=None, config_payload=None): + config = build_config( + app=None, + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['2'], + config=config_payload, + ) + if config_mutator is not None: + config_mutator(config) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +class _DummyWriter: + def __init__(self) -> None: + self.closed = False + self.buffer = bytearray() + + def write(self, data: bytes) -> None: + self.buffer.extend(data) + + async def drain(self) -> None: + return None + + def is_closing(self) -> bool: + return self.closed + + def close(self) -> None: + self.closed = True + + async def wait_closed(self) -> None: + return None + + def get_extra_info(self, _name: str, default=None): + return default + + +async def _read_h2_frames(reader: asyncio.StreamReader, *, timeout: float = 1.0) -> list[HTTP2Frame]: + buf = FrameBuffer() + data = await asyncio.wait_for(reader.read(65535), timeout) + if not data: + return [] + buf.feed(data) + return buf.pop_all() + + + +def test_parser_accepts_phase4_flags(): + parser = build_parser() + ns = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--http2-max-concurrent-streams', '11', + '--http2-max-headers-size', '12288', + '--http2-max-frame-size', '32768', + '--http2-adaptive-window', + '--http2-initial-connection-window-size', '131072', + '--http2-initial-stream-window-size', '98304', + '--http2-keep-alive-interval', '2.5', + '--http2-keep-alive-timeout', '1.25', + ] + ) + assert ns.http2_max_concurrent_streams == 11 + assert ns.http2_max_headers_size == 12288 + assert ns.http2_max_frame_size == 32768 + assert ns.http2_adaptive_window + assert ns.http2_initial_connection_window_size == 131072 + assert ns.http2_initial_stream_window_size == 98304 + assert ns.http2_keep_alive_interval == 2.5 + assert ns.http2_keep_alive_timeout == 1.25 +def test_http2_adaptive_window_disable_form_is_respected(): + parser = build_parser() + ns = parser.parse_args([ + 'tests.fixtures_pkg.appmod:app', + '--http2-adaptive-window', + '--no-http2-adaptive-window', + ]) + assert not (ns.http2_adaptive_window) + config = build_config_from_namespace(ns) + assert not (config.http.http2_adaptive_window) +def test_build_config_from_namespace_maps_phase4_submodels(): + parser = build_parser() + ns = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--http2-max-concurrent-streams', '7', + '--http2-max-headers-size', '8192', + '--http2-max-frame-size', '32768', + '--http2-adaptive-window', + '--http2-initial-connection-window-size', '131072', + '--http2-initial-stream-window-size', '98304', + '--http2-keep-alive-interval', '0.5', + '--http2-keep-alive-timeout', '0.25', + ] + ) + config = build_config_from_namespace(ns) + assert config.http.http2_max_concurrent_streams == 7 + assert config.http.http2_max_headers_size == 8192 + assert config.http.http2_max_frame_size == 32768 + assert config.http.http2_adaptive_window + assert config.http.http2_initial_connection_window_size == 131072 + assert config.http.http2_initial_stream_window_size == 98304 + assert config.http.http2_keep_alive_interval == 0.5 + assert config.http.http2_keep_alive_timeout == 0.25 +def test_phase4_env_surface_is_respected(): + parser = build_parser() + ns = parser.parse_args(['--env-prefix', 'PHASE4TEST']) + with patch.dict( + os.environ, + { + 'PHASE4TEST_HTTP2_MAX_CONCURRENT_STREAMS': '13', + 'PHASE4TEST_HTTP2_MAX_HEADERS_SIZE': '9216', + 'PHASE4TEST_HTTP2_MAX_FRAME_SIZE': '32768', + 'PHASE4TEST_HTTP2_ADAPTIVE_WINDOW': 'true', + 'PHASE4TEST_HTTP2_INITIAL_CONNECTION_WINDOW_SIZE': '196608', + 'PHASE4TEST_HTTP2_INITIAL_STREAM_WINDOW_SIZE': '131072', + 'PHASE4TEST_HTTP2_KEEP_ALIVE_INTERVAL': '1.5', + 'PHASE4TEST_HTTP2_KEEP_ALIVE_TIMEOUT': '0.75', + }, + clear=False, + ): + config = build_config_from_namespace(ns) + assert config.http.http2_max_concurrent_streams == 13 + assert config.http.http2_max_headers_size == 9216 + assert config.http.http2_max_frame_size == 32768 + assert config.http.http2_adaptive_window + assert config.http.http2_initial_connection_window_size == 196608 + assert config.http.http2_initial_stream_window_size == 131072 + assert config.http.http2_keep_alive_interval == 1.5 + assert config.http.http2_keep_alive_timeout == 0.75 +def test_generic_and_http2_specific_limits_are_coherent(): + fallback = build_config( + app=None, + http_versions=['2'], + max_header_size=8192, + config={'scheduler': {'max_streams': 19}}, + ) + assert fallback.http.http2_max_concurrent_streams == 19 + assert fallback.http.http2_max_headers_size == 8192 + assert fallback.http.http2_max_frame_size == 16384 + explicit = build_config( + app=None, + http_versions=['2'], + max_header_size=8192, + http2_max_concurrent_streams=7, + http2_max_headers_size=4096, + http2_max_frame_size=32768, + config={'scheduler': {'max_streams': 19}}, + ) + assert explicit.http.http2_max_concurrent_streams == 7 + assert explicit.http.http2_max_headers_size == 4096 + assert explicit.http.http2_max_frame_size == 32768 +async def test_http2_server_advertises_configured_local_settings(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + server, port = await _start_http2_server( + app, + config_payload={ + 'http': { + 'http2_max_concurrent_streams': 7, + 'http2_max_headers_size': 12288, + 'http2_max_frame_size': 32768, + 'http2_initial_stream_window_size': 98304, + } + }, + ) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + await writer.drain() + settings_payload = None + deadline = asyncio.get_running_loop().time() + 1.0 + while settings_payload is None and asyncio.get_running_loop().time() < deadline: + for frame in await _read_h2_frames(reader): + if frame.frame_type == FRAME_SETTINGS and frame.payload: + settings_payload = decode_settings(frame.payload) + break + assert settings_payload is not None + assert settings_payload is not None + assert settings_payload[0x3] == 7 + assert settings_payload[0x6] == 12288 + assert settings_payload[0x5] == 32768 + assert settings_payload[0x4] == 98304 + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http2_initial_connection_window_emits_stream_zero_window_update(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + target = 131072 + server, port = await _start_http2_server( + app, + config_payload={'http': {'http2_initial_connection_window_size': target}}, + ) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + await writer.drain() + increment = None + deadline = asyncio.get_running_loop().time() + 1.0 + while increment is None and asyncio.get_running_loop().time() < deadline: + for frame in await _read_h2_frames(reader): + if frame.frame_type == FRAME_WINDOW_UPDATE and frame.stream_id == 0: + increment = parse_window_update(frame.payload) + break + assert increment == target - 65535 + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http2_adaptive_window_growth_emits_window_updates(): + async def app(scope, receive, send): + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + config = build_config(app=None, http_versions=['2'], config={'http': {'http2_adaptive_window': True}}) + handler = HTTP2ConnectionHandler( + app=app, + config=config, + access_logger=AccessLogger(logging.getLogger('phase4-h2-test')), + reader=asyncio.StreamReader(), + writer=_DummyWriter(), + client=None, + server=None, + scheme='http', + ) + updates: list[bytes] = [] + + async def capture_write(data: bytes, *, record_activity: bool = True) -> None: + updates.append(data) + + handler._write_raw = capture_write # type: ignore[method-assign] + state = handler.streams.activate_remote( + 1, + send_window=handler.state.initial_window_size, + receive_window=handler.state.local_initial_window_size, + ) + assert next_adaptive_window_target(65535 == 40000), 131070 + handler.state.connection_receive_window.consume(40000) + state.receive_window.consume(40000) + await handler._maybe_replenish_receive_credit(1, 40000) + assert handler.state.connection_receive_window_target > 65535 + assert state.receive_window_target > 65535 + buf = FrameBuffer() + for payload in updates: + buf.feed(payload) + frames = buf.pop_all() + window_updates = [frame for frame in frames if frame.frame_type == FRAME_WINDOW_UPDATE] + assert {frame.stream_id for frame in window_updates} == {0, 1} + assert all(parse_window_update(frame.payload) > 0 for frame in window_updates) +async def test_http2_keepalive_sends_ping_then_closes_on_timeout(): + async def app(scope, receive, send): + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + server, port = await _start_http2_server( + app, + config_payload={'http': {'http2_keep_alive_interval': 0.05, 'http2_keep_alive_timeout': 0.05}}, + ) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + await writer.drain() + saw_ping = False + deadline = asyncio.get_running_loop().time() + 1.0 + while not saw_ping and asyncio.get_running_loop().time() < deadline: + frames = await _read_h2_frames(reader) + if not frames: + break + saw_ping = any(frame.frame_type == FRAME_PING for frame in frames) + assert saw_ping + tail = await asyncio.wait_for(reader.read(), 1.0) + assert tail == b'' + writer.close() + await writer.wait_closed() + finally: + await server.close() + + diff --git a/tests/test_phase4_operator_surface_pytest.py b/tests/test_phase4_operator_surface_pytest.py new file mode 100644 index 00000000..e1731e9d --- /dev/null +++ b/tests/test_phase4_operator_surface_pytest.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import json +import logging +import os +import tempfile +import time +from pathlib import Path +from unittest.mock import patch + +from tigrcorn.asgi.scopes.http import build_http_scope +from tigrcorn.asgi.scopes.websocket import build_websocket_scope +from tigrcorn.cli import build_parser +from tigrcorn.config.load import build_config_from_namespace +from tigrcorn.config.model import ProxyConfig, ServerConfig +from tigrcorn.config.validate import validate_config +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.observability.metrics import Metrics +from tigrcorn.protocols.http1.parser import ParsedRequest +from tigrcorn.server.reloader import PollingReloader +from tigrcorn.server.supervisor import ServerSupervisor +from tigrcorn.workers.local import LocalWorker +from tigrcorn.workers.process import ProcessWorker +from tigrcorn.workers.supervisor import WorkerSupervisor +from tigrcorn.errors import ConfigError + + +import pytest +def _sleep_worker(seconds: float = 10.0) -> None: + time.sleep(seconds) + + +def _noop_worker(_payload) -> None: + return None + + + +def test_validate_rejects_reload_with_multiple_workers(): + parser = build_parser() + ns = parser.parse_args(['tests.fixtures_pkg.appmod:app', '--reload', '--workers', '2']) + with pytest.raises(ConfigError): + build_config_from_namespace(ns) + +def test_proxy_scope_building_applies_forwarded_headers(): + request = ParsedRequest( + method='GET', + target='/svc/hello', + path='/svc/hello', + raw_path=b'/svc/hello', + query_string=b'', + http_version='1.1', + headers=[ + (b'x-forwarded-for', b'203.0.113.8'), + (b'x-forwarded-proto', b'https'), + (b'x-forwarded-host', b'example.com'), + (b'x-forwarded-prefix', b'/svc'), + ], + body=b'', + keep_alive=True, + expect_continue=False, + websocket_upgrade=False, + ) + proxy = ProxyConfig(proxy_headers=True, forwarded_allow_ips=['127.0.0.1']) + scope = build_http_scope( + request, + client=('127.0.0.1', 50000), + server=('127.0.0.1', 8000), + scheme='http', + root_path='', + proxy=proxy, + ) + assert scope['client'] == ('203.0.113.8', 50000) + assert scope['scheme'] == 'https' + assert scope['server'] == ('example.com', 8000) + assert scope['root_path'] == '/svc' + assert scope['path'] == '/hello' +def test_websocket_scope_inherits_proxy_normalization(): + request = ParsedRequest( + method='GET', + target='/mount/ws', + path='/mount/ws', + raw_path=b'/mount/ws', + query_string=b'', + http_version='1.1', + headers=[ + (b'forwarded', b'for=198.51.100.10;proto=wss;host=example.net'), + (b'x-forwarded-prefix', b'/mount'), + (b'sec-websocket-protocol', b'chat, superchat'), + ], + body=b'', + keep_alive=True, + expect_continue=False, + websocket_upgrade=True, + ) + proxy = ProxyConfig(proxy_headers=True, forwarded_allow_ips=['127.0.0.1']) + scope = build_websocket_scope( + request, + client=('127.0.0.1', 50001), + server=('127.0.0.1', 8000), + scheme='ws', + root_path='', + proxy=proxy, + ) + assert scope['client'] == ('198.51.100.10', 50001) + assert scope['scheme'] == 'wss' + assert scope['server'] == ('example.net', 8000) + assert scope['root_path'] == '/mount' + assert scope['path'] == '/ws' + assert scope['subprotocols'] == ['chat', 'superchat'] +def test_metrics_snapshot_and_exporters(): + metrics = Metrics() + metrics.connection_opened() + metrics.requests_served = 2 + metrics.websocket_opened() + snapshot = metrics.snapshot() + assert snapshot['connections_opened'] == 1 + assert snapshot['active_websocket_connections'] == 1 + assert 'tigrcorn_requests_served' in metrics.render_prometheus() + assert 'tigrcorn.requests_served' in metrics.render_statsd() +def test_configure_logging_supports_structured_and_file_handlers(): + with tempfile.TemporaryDirectory() as tmp: + class LoggingCfg: + level = 'info' + structured = True + access_log_file = str(Path(tmp) / 'access.log') + error_log_file = str(Path(tmp) / 'error.log') + logger = configure_logging('info', config=LoggingCfg()) + access = AccessLogger(logger, enabled=True) + access.log_http(('127.0.0.1', 1), 'GET', '/', 200, 'HTTP/1.1') + for handler in logger.handlers: + handler.flush() + assert Path(LoggingCfg.access_log_file).exists() + payload = Path(LoggingCfg.access_log_file).read_text(encoding='utf-8') + assert '"event": "access.http"' in payload +def test_local_and_process_workers_expose_health(): + local = LocalWorker() + local.start() + assert local.health()['alive'] + local.stop() + worker = ProcessWorker(name='phase4-test-worker') + worker.start(_sleep_worker, 10.0) + try: + assert worker.is_alive() + assert worker.health()['pid'] is not None + finally: + worker.stop(timeout=0.5) + +def test_worker_supervisor_can_replace_local_worker(): + sup = WorkerSupervisor() + first = LocalWorker(name='a') + second = LocalWorker(name='b') + sup.add(first) + sup.start_all() + assert first.running + sup.replace(0, second) + assert second.running + assert not (first.running) + sup.stop_all() + assert not (second.running) +def test_polling_reloader_detects_snapshot_changes(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / 'app.py' + source.write_text('x = 1\n', encoding='utf-8') + parser = build_parser() + ns = parser.parse_args(['tests.fixtures_pkg.appmod:app', '--reload', '--reload-dir', tmp]) + config = build_config_from_namespace(ns) + reloader = PollingReloader(['tests.fixtures_pkg.appmod:app'], config=config, interval=0.01) + snap1 = reloader.snapshot() + time.sleep(0.02) + source.write_text('x = 2\n', encoding='utf-8') + snap2 = reloader.snapshot() + assert snap1 != snap2 +def test_server_supervisor_restarts_dead_workers(): + parser = build_parser() + with tempfile.TemporaryDirectory() as tmp: + pidfile = str(Path(tmp) / 'tigrcorn.pid') + ns = parser.parse_args(['tests.fixtures_pkg.appmod:app', '--workers', '1', '--pid', pidfile, '--port', '0']) + config = build_config_from_namespace(ns) + with patch('tigrcorn.server.supervisor.run_worker_from_config_payload', _noop_worker): + supervisor = ServerSupervisor(app_target='tests.fixtures_pkg.appmod:app', config=config) + supervisor.start() + try: + assert Path(pidfile).exists() + time.sleep(0.1) + restarted = supervisor.poll_workers_once() + assert isinstance(restarted, list) + assert len(supervisor.workers.workers) == 1 + finally: + supervisor.stop() + assert not (Path(pidfile).exists()) +def test_cli_parser_still_accepts_operator_surface_flags(): + parser = build_parser() + ns = parser.parse_args([ + 'tests.fixtures_pkg.appmod:app', + '--reload', + '--reload-dir', 'src', + '--workers', '1', + '--pid', '/tmp/tigrcorn.pid', + '--bind', '127.0.0.1:0', + '--fd', '3', + '--proxy-headers', + '--forwarded-allow-ips', '127.0.0.1', + '--root-path', '/svc', + '--access-log-file', '/tmp/access.log', + '--error-log-file', '/tmp/error.log', + '--structured-log', + '--metrics', + '--metrics-bind', '127.0.0.1:9001', + '--timeout-keep-alive', '7', + '--read-timeout', '9', + '--write-timeout', '10', + '--timeout-graceful-shutdown', '11', + '--limit-concurrency', '5', + '--max-connections', '10', + '--max-tasks', '20', + '--max-streams', '3', + '--max-header-size', '4096', + '--websocket-max-message-size', '8192', + '--idle-timeout', '15', + '--quic-max-datagram-size', '1350', + '--quic-idle-timeout', '30', + ]) + assert ns.metrics_bind == '127.0.0.1:9001' + assert ns.max_connections == 10 + assert ns.max_streams == 3 \ No newline at end of file diff --git a/tests/test_phase4_rfc_boundary_formalization_checkpoint_pytest.py b/tests/test_phase4_rfc_boundary_formalization_checkpoint_pytest.py new file mode 100644 index 00000000..85028c69 --- /dev/null +++ b/tests/test_phase4_rfc_boundary_formalization_checkpoint_pytest.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, evaluate_promotion_target + + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' + + +def _load(path: str) -> dict: + return json.loads((ROOT / path).read_text(encoding='utf-8')) + + +def test_boundaries_formalize_rfc8297_and_rfc7838_section3() -> None: + authoritative = _load('docs/review/conformance/certification_boundary.json') + strict = _load('docs/review/conformance/certification_boundary.strict_target.json') + corpus = _load('docs/review/conformance/corpus.json') + + for payload in (authoritative, strict): + assert 'RFC 8297' in payload['required_rfcs'] + assert 'RFC 7838 §3' in payload['required_rfcs'] + assert payload['required_rfc_evidence']['RFC 8297']['highest_required_evidence_tier'] == 'local_conformance' + assert payload['required_rfc_evidence']['RFC 7838 §3']['highest_required_evidence_tier'] == 'local_conformance' + assert payload['required_rfc_evidence']['RFC 8297']['declared_evidence']['local_conformance'] == ['http-early-hints'] + assert payload['required_rfc_evidence']['RFC 7838 §3']['declared_evidence']['local_conformance'] == ['http-alt-svc-header-advertisement'] + + vectors = {entry['name']: entry for entry in corpus['vectors']} + assert vectors['http-early-hints']['rfc'] == '8297' + assert vectors['http-alt-svc-header-advertisement']['rfc'] == 'RFC 7838 §3' + + +def test_phase4_support_statements_are_explicit_and_rfc9218_remains_out() -> None: + early = _load('docs/review/conformance/phase4_advanced_delivery/early_hints_support_statement.json') + alt = _load('docs/review/conformance/phase4_advanced_delivery/alt_svc_support_statement.json') + checkpoint = _load('docs/review/conformance/phase4_advanced_protocol_delivery_checkpoint.current.json') + + assert early['certification_boundary']['target_rfc'] == 'RFC 8297' + assert early['certification_boundary']['support_envelope'] == 'direct_server_103_early_hints' + assert alt['certification_boundary']['target_rfc'] == 'RFC 7838 §3' + assert alt['certification_boundary']['support_envelope'] == 'header_field_advertisement_only' + assert 'RFC 9218 prioritization' in alt['non_targeted_surfaces'] + assert checkpoint['boundary']['authoritative_phase4_rfc_targets'] == ['RFC 8297', 'RFC 7838 §3'] + assert checkpoint['boundary']['rfc9218_targeted'] is False + + +def test_phase4_current_state_docs_are_explicit_not_ambiguous() -> None: + applicability = _load('docs/review/conformance/rfc_applicability_and_competitor_status.current.json') + review = _load('docs/review/conformance/package_compliance_review_phase9i.current.json') + + assert applicability['rfc_applicability']['rfc8297']['status'] == 'core_current_boundary' + assert applicability['rfc_applicability']['rfc7838']['status'] == 'core_current_boundary_bounded' + assert applicability['rfc_applicability']['rfc9218']['status'] == 'transport_adjacent_optional' + assert review['summary']['phase4_rfc_boundary_formalized'] is True + assert review['summary']['rfc8297_targeted'] is True + assert review['summary']['rfc7838_section3_targeted'] is True + assert review['summary']['rfc9218_targeted'] is False + + +def test_release_gates_and_promotion_target_remain_green_after_phase4_boundary_formalization() -> None: + authoritative = evaluate_release_gates(ROOT) + strict = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + promotion = evaluate_promotion_target(ROOT) + assert authoritative.passed, authoritative.failures + assert strict.passed, strict.failures + assert promotion.passed, promotion.failures diff --git a/tests/test_phase5_flow_control_bundle_pytest.py b/tests/test_phase5_flow_control_bundle_pytest.py new file mode 100644 index 00000000..9cf3e9ef --- /dev/null +++ b/tests/test_phase5_flow_control_bundle_pytest.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = ROOT / 'docs/review/conformance/releases/0.3.6/release-0.3.6' +FLOW_ROOT = RELEASE_ROOT / 'tigrcorn-minimum-certified-flow-control-matrix' + +EXPECTED = { + 'http3-flow-control-aioquic-client-credit-exhaustion': 'http3-server-aioquic-client-post', + 'http3-flow-control-aioquic-client-replenishment': 'http3-server-aioquic-client-post', + 'http3-flow-control-aioquic-client-stream-backpressure': 'http3-server-aioquic-client-post', + 'http3-flow-control-aioquic-client-connection-backpressure': 'http3-server-aioquic-client-post', + 'http3-flow-control-aioquic-client-qpack-blocked-stream': 'http3-server-aioquic-client-post-goaway-qpack', + 'http3-flow-control-aioquic-client-goaway-pressure': 'http3-server-aioquic-client-post-goaway-qpack', +} + + +def test_bundle_index_registers_minimum_flow_bundle() -> None: + payload = json.loads((RELEASE_ROOT / 'bundle_index.json').read_text(encoding='utf-8')) + assert payload['bundles']['minimum_certified_flow_control'] == ( + 'docs/review/conformance/releases/0.3.6/release-0.3.6/tigrcorn-minimum-certified-flow-control-matrix' + ) + + +def test_mapping_file_covers_all_phase5_flow_scenarios() -> None: + payload = json.loads((FLOW_ROOT / 'scenario_mapping.json').read_text(encoding='utf-8')) + got = {entry['id']: entry['source'] for entry in payload['mappings']} + assert got == EXPECTED + + +def test_results_are_release_gate_eligible_and_traceable() -> None: + for scenario_id, source in EXPECTED.items(): + result = json.loads((FLOW_ROOT / scenario_id / 'result.json').read_text(encoding='utf-8')) + metadata = json.loads( + (FLOW_ROOT / scenario_id / 'flow_control_metadata.json').read_text( + encoding='utf-8' + ) + ) + assert result['passed'] + assert result['release_gate_eligible'] + assert result['source_independent_scenario'] == source + assert metadata['source_independent_scenario'] == source + assert (FLOW_ROOT / scenario_id / 'packet_trace.jsonl').exists() diff --git a/tests/test_phase5_intermediary_proxy_corpus_pytest.py b/tests/test_phase5_intermediary_proxy_corpus_pytest.py new file mode 100644 index 00000000..5ec3c121 --- /dev/null +++ b/tests/test_phase5_intermediary_proxy_corpus_pytest.py @@ -0,0 +1,41 @@ + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +ROOT = Path(__file__).resolve().parents[1] +CORPUS_ROOT = ROOT / 'docs/review/conformance/intermediary_proxy_corpus_minimum_certified' + + +def test_index_declares_minimum_certified_corpus() -> None: + payload = json.loads((CORPUS_ROOT / 'index.json').read_text(encoding='utf-8')) + assert payload['corpus_kind'] == 'minimum_certified_intermediary_proxy_corpus' + assert payload['minimum_certified_case_count'] == 3 + assert payload['supplemental_case_count'] == 9 +def test_independent_cases_exist_for_http1_http2_http3() -> None: + ids = { + 'http11-curl-origin-form-post-certified', + 'http2-h2-origin-form-post-certified', + 'http3-aioquic-origin-form-post-certified', + } + for case_id in ids: + case_dir = CORPUS_ROOT / 'cases' / case_id + metadata = json.loads((case_dir / 'corpus_metadata.json').read_text(encoding='utf-8')) + result = json.loads((case_dir / 'result.json').read_text(encoding='utf-8')) + assert metadata['minimum_certified'] + assert metadata['source_kind'] == 'independent_artifact' + assert result['passed'] +def test_supplemental_local_vector_cases_exist() -> None: + for case_id in ( + 'http11-connect-relay-local-vector', + 'http2-trailer-fields-local-vector', + 'http3-content-coding-local-vector', + ): + case_dir = CORPUS_ROOT / 'cases' / case_id + metadata = json.loads((case_dir / 'corpus_metadata.json').read_text(encoding='utf-8')) + vector = json.loads((case_dir / 'source_local_vector.json').read_text(encoding='utf-8')) + assert not (metadata['minimum_certified']) + assert metadata['source_kind'] == 'local_vector' + assert 'name' in vector diff --git a/tests/test_phase5_tls_operator_material_surface_pytest.py b/tests/test_phase5_tls_operator_material_surface_pytest.py new file mode 100644 index 00000000..ac388a4a --- /dev/null +++ b/tests/test_phase5_tls_operator_material_surface_pytest.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import tempfile +from argparse import Namespace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from tigrcorn.cli import build_parser +from tigrcorn.config.load import build_config_from_namespace, build_config_from_sources +from tigrcorn.config.model import ListenerConfig +from tigrcorn.errors import ProtocolError +from tigrcorn.security.policies import build_validation_policy_for_listener +from tigrcorn.security.tls import build_server_ssl_context, verify_certificate_chain +from tigrcorn.security.tls13.handshake import QuicTlsHandshakeDriver +from tigrcorn.security.x509.path import RevocationMode + +_NOW = datetime.now(timezone.utc) + + +def _make_ca(common_name: str) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(_NOW - timedelta(days=1)) + .not_valid_after(_NOW + timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=True, path_length=1), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) + .sign(key, hashes.SHA256()) + ) + return cert, key + + +def _make_leaf( + common_name: str, + *, + issuer_cert: x509.Certificate, + issuer_key: rsa.RSAPrivateKey, + eku_oid, +) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])) + .issuer_name(issuer_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(_NOW - timedelta(days=1)) + .not_valid_after(_NOW + timedelta(days=90)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.ExtendedKeyUsage([eku_oid]), critical=False) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(common_name)]), critical=False) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_key.public_key()), critical=False) + .sign(issuer_key, hashes.SHA256()) + ) + return cert, key + + +def _make_crl( + issuer_cert: x509.Certificate, + issuer_key: rsa.RSAPrivateKey, + *, + revoked_serials: tuple[int, ...], +) -> x509.CertificateRevocationList: + builder = ( + x509.CertificateRevocationListBuilder() + .issuer_name(issuer_cert.subject) + .last_update(_NOW - timedelta(minutes=5)) + .next_update(_NOW + timedelta(days=1)) + ) + for serial in revoked_serials: + revoked = ( + x509.RevokedCertificateBuilder() + .serial_number(serial) + .revocation_date(_NOW - timedelta(minutes=1)) + .build() + ) + builder = builder.add_revoked_certificate(revoked) + return builder.sign(private_key=issuer_key, algorithm=hashes.SHA256()) + + +def _pem_certificate(certificate: x509.Certificate) -> bytes: + return certificate.public_bytes(serialization.Encoding.PEM) + + +def _write_tls_materials(root: Path) -> tuple[Path, Path, Path, Path, Path, x509.Certificate]: + ca_cert, ca_key = _make_ca('phase5-test-ca') + server_cert, server_key = _make_leaf( + 'server.phase5.local', + issuer_cert=ca_cert, + issuer_key=ca_key, + eku_oid=ExtendedKeyUsageOID.SERVER_AUTH, + ) + client_cert, _client_key = _make_leaf( + 'client.phase5.local', + issuer_cert=ca_cert, + issuer_key=ca_key, + eku_oid=ExtendedKeyUsageOID.CLIENT_AUTH, + ) + crl = _make_crl(ca_cert, ca_key, revoked_serials=(client_cert.serial_number,)) + + cert_path = root / 'server-cert.pem' + key_path = root / 'server-key.pem' + encrypted_key_path = root / 'server-key-encrypted.pem' + ca_path = root / 'client-ca.pem' + crl_path = root / 'revocations.pem' + + cert_path.write_bytes(_pem_certificate(server_cert)) + key_path.write_bytes( + server_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + encrypted_key_path.write_bytes( + server_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.BestAvailableEncryption(b'hunter2'), + ) + ) + ca_path.write_bytes(_pem_certificate(ca_cert)) + crl_path.write_bytes(crl.public_bytes(serialization.Encoding.PEM)) + return cert_path, key_path, encrypted_key_path, ca_path, crl_path, client_cert + +def test_cli_and_env_wiring_accept_ssl_keyfile_password_and_ssl_crl() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cert_path, key_path, _encrypted_key_path, _ca_path, crl_path, _client_cert = _write_tls_materials(root) + + parser = build_parser() + namespace = parser.parse_args( + [ + 'tests.fixtures_pkg.appmod:app', + '--ssl-certfile', str(cert_path), + '--ssl-keyfile', str(key_path), + '--ssl-keyfile-password', 'cli-secret', + '--ssl-crl', str(crl_path), + '--ssl-crl-mode', 'soft-fail', + ] + ) + config = build_config_from_namespace(namespace) + assert config.tls.keyfile_password == 'cli-secret' + assert config.tls.crl == str(crl_path) + assert config.listeners[0].ssl_keyfile_password == 'cli-secret' + assert config.listeners[0].ssl_crl == str(crl_path) + assert config.listeners[0].crl_mode == 'soft-fail' + + env_file = root / '.env' + env_file.write_text( + f'TIGRCORN_SSL_KEYFILE_PASSWORD=env-secret\nTIGRCORN_SSL_CRL={crl_path}\n', + encoding='utf-8', + ) + env_config = build_config_from_sources( + config_source={ + 'app': {'target': 'tests.fixtures_pkg.appmod:app'}, + 'tls': {'certfile': str(cert_path), 'keyfile': str(key_path)}, + }, + env_prefix='TIGRCORN', + env_file=str(env_file), + ) + assert env_config.tls.keyfile_password == 'env-secret' + assert env_config.tls.crl == str(crl_path) + assert env_config.listeners[0].ssl_keyfile_password == 'env-secret' + assert env_config.listeners[0].ssl_crl == str(crl_path) + +def test_encrypted_private_key_material_loads_through_server_tls_context() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cert_path, _key_path, encrypted_key_path, _ca_path, _crl_path, _client_cert = _write_tls_materials(root) + listener = ListenerConfig( + kind='tcp', + host='127.0.0.1', + port=4433, + ssl_certfile=str(cert_path), + ssl_keyfile=str(encrypted_key_path), + ssl_keyfile_password='hunter2', + ) + context = build_server_ssl_context(listener) + assert context is not None + assert context is not None + assert context.private_key_password == b'hunter2' + driver = QuicTlsHandshakeDriver( + is_client=False, + transport_mode='stream', + certificate_pem=context.certificate_pem, + private_key_pem=context.private_key_pem, + private_key_password=context.private_key_password, + trusted_certificates=context.trusted_certificates, + validation_policy=context.validation_policy, + ) + assert driver._private_key is not None + +def test_local_crl_material_is_loaded_and_revoked_client_cert_is_rejected() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cert_path, key_path, _encrypted_key_path, ca_path, crl_path, client_cert = _write_tls_materials(root) + listener = ListenerConfig( + kind='tcp', + host='127.0.0.1', + port=4433, + ssl_certfile=str(cert_path), + ssl_keyfile=str(key_path), + ssl_ca_certs=str(ca_path), + ssl_require_client_cert=True, + crl_mode='require', + ssl_crl=str(crl_path), + ) + policy = build_validation_policy_for_listener(listener) + assert policy.revocation_mode == RevocationMode.REQUIRE + assert len(policy.revocation_material.crls) == 1 + with pytest.raises(ProtocolError, match='revoked'): + verify_certificate_chain( + [_pem_certificate(client_cert)], + [ca_path.read_bytes()], + policy=policy, + ) + diff --git a/tests/test_phase6_performance_harness_pytest.py b/tests/test_phase6_performance_harness_pytest.py new file mode 100644 index 00000000..c3d07e06 --- /dev/null +++ b/tests/test_phase6_performance_harness_pytest.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from benchmarks.profiles import REQUIRED_PROFILE_IDS +import pytest +from tigrcorn.compat.perf_runner import ( + DEFAULT_BASELINE_ARTIFACT_ROOT, + DEFAULT_CURRENT_ARTIFACT_ROOT, + load_performance_matrix, + run_performance_matrix, + validate_performance_artifacts, +) + +ROOT = Path(__file__).resolve().parents[1] +MATRIX_PATH = ROOT / 'docs/review/performance/performance_matrix.json' +BASELINE_ROOT = ROOT / DEFAULT_BASELINE_ARTIFACT_ROOT +CURRENT_ROOT = ROOT / DEFAULT_CURRENT_ARTIFACT_ROOT + + + +def test_matrix_declares_all_required_profile_ids() -> None: + matrix = load_performance_matrix(MATRIX_PATH) + ids = {profile.profile_id for profile in matrix.profiles} + assert ids == set(REQUIRED_PROFILE_IDS) +def test_preserved_artifacts_validate_for_current_release() -> None: + failures = validate_performance_artifacts( + ROOT, + artifact_root=DEFAULT_CURRENT_ARTIFACT_ROOT, + baseline_root=DEFAULT_BASELINE_ARTIFACT_ROOT, + require_relative_regression=True, + ) + assert failures == [] +def test_each_profile_has_required_artifact_files() -> None: + for profile_id in REQUIRED_PROFILE_IDS: + profile_dir = CURRENT_ROOT / profile_id + assert (profile_dir / 'result.json').exists(), profile_id + assert (profile_dir / 'summary.json').exists(), profile_id + assert (profile_dir / 'env.json').exists(), profile_id + assert (profile_dir / 'percentile_histogram.json').exists(), profile_id + assert (profile_dir / 'raw_samples.csv').exists(), profile_id + assert (profile_dir / 'command.json').exists(), profile_id + assert (profile_dir / 'correctness.json').exists(), profile_id + result = json.loads((profile_dir / 'result.json').read_text(encoding='utf-8')) + assert result['passed'], profile_id + assert result['profile_id'] == profile_id + assert 'p99_9_ms' in result['metrics'] + assert 'time_to_first_byte_ms' in result['metrics'] + assert 'handshake_latency_ms' in result['metrics'] + assert 'protocol_stalls' in result['metrics'] +def test_each_profile_links_to_a_known_deployment_profile() -> None: + matrix = load_performance_matrix(MATRIX_PATH) + deployment_payload = json.loads((ROOT / "docs/review/conformance/deployment_profiles.json").read_text(encoding="utf-8")) + known = {item["profile_id"] for item in deployment_payload["profiles"]} + for profile in matrix.profiles: + assert profile.deployment_profile in known, profile.profile_id +def test_rfc_scoped_profiles_require_correctness_checks() -> None: + matrix = load_performance_matrix(MATRIX_PATH) + for profile in matrix.profiles: + if profile.rfc_targets: + assert profile.correctness_required, profile.profile_id +def test_matrix_declares_required_lanes_and_platforms() -> None: + matrix = load_performance_matrix(MATRIX_PATH) + lanes = {profile.lane for profile in matrix.profiles} + assert lanes == {'component_regression', 'end_to_end_release'} + for profile in matrix.profiles: + assert profile.certification_platforms, profile.profile_id + assert profile.lane in {'component_regression', 'end_to_end_release'} +def test_root_artifact_summary_declares_platform_and_lanes() -> None: + summary = json.loads((CURRENT_ROOT / 'summary.json').read_text(encoding='utf-8')) + assert 'certification_platform' in summary + assert summary['certification_platform'] + assert set(summary['lane_counts']) == {'component_regression', 'end_to_end_release'} + assert summary['lane_counts']['component_regression'] > 0 + assert summary['lane_counts']['end_to_end_release'] > 0 +def test_rfc_scoped_profile_artifacts_record_correctness_requirements() -> None: + matrix = load_performance_matrix(MATRIX_PATH) + for profile in matrix.profiles: + if not profile.rfc_targets: + continue + correctness = json.loads((CURRENT_ROOT / profile.profile_id / 'correctness.json').read_text(encoding='utf-8')) + assert correctness['required'], profile.profile_id + assert correctness['passed'], profile.profile_id + assert correctness['checks'], profile.profile_id +def test_end_to_end_release_profiles_preserve_live_listener_metadata() -> None: + matrix = load_performance_matrix(MATRIX_PATH) + for profile in matrix.profiles: + if profile.lane != 'end_to_end_release': + continue + assert profile.live_listener_required, profile.profile_id + for filename in ['result.json', 'summary.json', 'command.json', 'correctness.json']: + payload = json.loads((CURRENT_ROOT / profile.profile_id / filename).read_text(encoding='utf-8')) + assert payload['lane'] == 'end_to_end_release', f'{profile.profile_id}::{filename}' + assert payload['live_listener_required'], f'{profile.profile_id}::{filename}' +def test_runner_can_execute_a_selected_profile_into_a_temp_root() -> None: + with tempfile.TemporaryDirectory() as tmp: + summary = run_performance_matrix( + ROOT, + artifact_root=Path(tmp) / 'perf', + profile_ids=['http11_baseline'], + establish_baseline=True, + ) + assert summary.total == 1 + profile_dir = Path(summary.artifact_root) / 'http11_baseline' + assert (profile_dir / 'result.json').exists() + assert (profile_dir / 'summary.json').exists() + assert 'p99_9_ms' in summary.profiles[0].metrics + assert 'time_to_first_byte_ms' in summary.profiles[0].metrics diff --git a/tests/test_phase6_public_lifecycle_and_embedder_contract_pytest.py b/tests/test_phase6_public_lifecycle_and_embedder_contract_pytest.py new file mode 100644 index 00000000..d6d06d41 --- /dev/null +++ b/tests/test_phase6_public_lifecycle_and_embedder_contract_pytest.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest import mock + +import pytest +from tigrcorn import EmbeddedServer +from tigrcorn.config.load import build_config +from tigrcorn.server.reloader import PollingReloader +from tigrcorn.server.runner import TigrCornServer + + +async def _http_ok_app(scope, receive, send): + if scope['type'] == 'lifespan': + message = await receive() + assert message['type'] == 'lifespan.startup' + await send({'type': 'lifespan.startup.complete'}) + message = await receive() + assert message['type'] == 'lifespan.shutdown' + await send({'type': 'lifespan.shutdown.complete'}) + return + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + +@pytest.mark.asyncio +async def test_startup_and_shutdown_hooks_are_ordered_relative_to_lifespan() -> None: + events: list[str] = [] + + async def app(scope, receive, send): + if scope['type'] == 'lifespan': + message = await receive() + events.append(message['type']) + await send({'type': 'lifespan.startup.complete'}) + message = await receive() + events.append(message['type']) + await send({'type': 'lifespan.shutdown.complete'}) + return + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + async def on_start(server) -> None: + assert server.lifespan.started + events.append('hook.startup') + + async def on_stop(server) -> None: + events.append('hook.shutdown') + + config = build_config(host='127.0.0.1', port=0, lifespan='on') + config.hooks.on_startup = [on_start] + config.hooks.on_shutdown = [on_stop] + + server = TigrCornServer(app, config) + await server.start() + await server.close() + + assert events == ['lifespan.startup', 'hook.startup', 'lifespan.shutdown', 'hook.shutdown'] + +@pytest.mark.asyncio +async def test_startup_hook_failures_abort_startup() -> None: + async def failing_start(_server) -> None: + raise RuntimeError('startup failed') + + config = build_config(host='127.0.0.1', port=0, lifespan='off') + config.hooks.on_startup = [failing_start] + server = TigrCornServer(_http_ok_app, config) + with pytest.raises(RuntimeError, match='startup failed'): + await server.start() + assert not (server._started) + await server.close() + +@pytest.mark.asyncio +async def test_shutdown_hook_failures_are_suppressed_during_close() -> None: + events: list[str] = [] + + async def noisy_shutdown(_server) -> None: + events.append('hook.shutdown') + raise RuntimeError('ignore me') + + config = build_config(host='127.0.0.1', port=0, lifespan='off') + config.hooks.on_shutdown = [noisy_shutdown] + server = TigrCornServer(_http_ok_app, config) + await server.start() + await server.close() + assert events == ['hook.shutdown'] + + +def test_reload_hooks_run_before_child_restart_and_receive_config() -> None: + events: list[str] = [] + config = build_config(app='tests.fixtures_pkg.appmod:app', host='127.0.0.1', port=8000) + + async def on_reload(reload_config) -> None: + assert reload_config is config + events.append('hook.reload') + + config.hooks.on_reload = [on_reload] + reloader = PollingReloader([], config=config) + + with ( + mock.patch.object(PollingReloader, 'stop_child', autospec=True, side_effect=lambda _self: events.append('stop_child')), + mock.patch.object(PollingReloader, 'spawn_child', autospec=True, side_effect=lambda _self: events.append('spawn_child')), + ): + reloader.restart_child() + + assert events == ['hook.reload', 'stop_child', 'spawn_child'] + + +@pytest.mark.asyncio +async def test_embedded_server_is_a_first_class_documented_surface() -> None: + config = build_config(host='127.0.0.1', port=0, lifespan='off') + embedded = EmbeddedServer(_http_ok_app, config) + + await embedded.close() # no-op before start + first = await embedded.start() + second = await embedded.start() + assert first is second + assert embedded.listeners + assert embedded.bound_endpoints() + await embedded.close() + +def test_lifecycle_and_embedded_server_docs_exist() -> None: + readme = Path('README.md').read_text(encoding='utf-8') + contract = Path('docs/LIFECYCLE_AND_EMBEDDED_SERVER.md').read_text(encoding='utf-8') + assert 'docs/LIFECYCLE_AND_EMBEDDED_SERVER.md' in readme + assert 'on_startup' in contract + assert 'on_shutdown' in contract + assert 'on_reload' in contract + assert 'EmbeddedServer' in contract + assert 'lifespan.startup()' in contract + assert 'lifespan.shutdown()' in contract + assert 'Failure semantics' in contract + diff --git a/tests/test_phase7_flag_surface_truth_reconciliation_pytest.py b/tests/test_phase7_flag_surface_truth_reconciliation_pytest.py new file mode 100644 index 00000000..582c22d4 --- /dev/null +++ b/tests/test_phase7_flag_surface_truth_reconciliation_pytest.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from tigrcorn.cli import build_parser + + +def _public_parser_flags() -> set[str]: + parser = build_parser() + flags: set[str] = set() + for action in parser._actions: + if isinstance(action, argparse._HelpAction): + continue + if action.help == argparse.SUPPRESS: + continue + for option in action.option_strings: + if option.startswith('--'): + flags.add(option) + return flags + + +def test_markdown_flag_surface_mentions_tls_material_flags_and_alt_svc_rows() -> None: + text = Path('docs/review/conformance/CLI_FLAG_SURFACE.md').read_text(encoding='utf-8') + for needle in [ + '--ssl-keyfile-password', + '--ssl-crl', + '--alt-svc', + '--alt-svc-auto', + '--alt-svc-ma', + '--alt-svc-persist', + ]: + assert needle in text + + +def test_machine_readable_flag_surface_matches_current_parser() -> None: + payload = json.loads( + Path('docs/review/conformance/cli_flag_surface.json').read_text(encoding='utf-8') + ) + parser_flags = _public_parser_flags() + documented_flags = {flag for row in payload['flags'] for flag in row['flags']} + assert documented_flags == parser_flags + assert payload['public_flag_string_count'] == len(parser_flags) + assert payload['promotion_ready_count'] == len(parser_flags) + tls_row = next(row for row in payload['flags'] if row['flag_id'] == 'tls') + assert '--ssl-keyfile-password' in tls_row['flags'] + assert '--ssl-crl' in tls_row['flags'] + + +def test_help_snapshot_matches_current_cli_help() -> None: + snapshot = Path('docs/review/conformance/cli_help.current.txt').read_text( + encoding='utf-8' + ) + assert snapshot == build_parser().format_help() diff --git a/tests/test_phase7_release_candidate_pytest.py b/tests/test_phase7_release_candidate_pytest.py new file mode 100644 index 00000000..d89f5145 --- /dev/null +++ b/tests/test_phase7_release_candidate_pytest.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def load_json(path: str) -> dict: + return json.loads(Path(path).read_text(encoding='utf-8')) + + +def test_phase7_status_snapshot_records_blocked_promotion() -> None: + status = load_json('docs/review/conformance/phase7_canonical_promotion_status.current.json') + assert status['phase'] == 7 + assert status['canonical_promotion_performed'] is False + assert status['authoritative_boundary_passed'] is True + assert status['strict_overlay_passed'] is False + assert status['strict_profile_release_gate_eligible'] is False + assert len(status['blocking_missing_independent_scenarios']) == 13 + + +def test_phase7_candidate_release_root_contains_required_bundles() -> None: + root = Path('docs/review/conformance/releases/0.3.7/release-0.3.7') + assert root.exists() + required = { + 'tigrcorn-independent-certification-release-matrix', + 'tigrcorn-same-stack-replay-matrix', + 'tigrcorn-mixed-compatibility-release-matrix', + 'tigrcorn-flag-surface-certification-bundle', + 'tigrcorn-operator-surface-certification-bundle', + 'tigrcorn-performance-certification-bundle', + } + assert required.issubset({p.name for p in root.iterdir() if p.is_dir()}) + manifest = load_json(str(root / 'manifest.json')) + assert manifest['canonical_promotion_performed'] is False + assert manifest['strict_profile_release_gate_eligible'] is False + + +def test_phase7_candidate_flag_operator_performance_bundles_are_frozen() -> None: + root = Path('docs/review/conformance/releases/0.3.7/release-0.3.7') + flag_index = load_json(str(root / 'tigrcorn-flag-surface-certification-bundle' / 'index.json')) + operator_index = load_json(str(root / 'tigrcorn-operator-surface-certification-bundle' / 'index.json')) + perf_index = load_json(str(root / 'tigrcorn-performance-certification-bundle' / 'index.json')) + + assert flag_index['release_gate_eligible'] is True + assert flag_index['flag_count'] > 0 + assert operator_index['release_gate_eligible'] is True + assert operator_index['implemented_count'] >= 1 + assert perf_index['release_gate_eligible'] is True + assert perf_index['profile_count'] >= 1 + + +def test_phase7_docs_keep_current_boundary_canonical() -> None: + current_state = Path('docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md').read_text(encoding='utf-8') + boundary_doc = Path('docs/review/conformance/CERTIFICATION_BOUNDARY.md').read_text(encoding='utf-8') + phase7_doc = Path('docs/review/conformance/PHASE7_CANONICAL_PROMOTION_STATUS.md').read_text(encoding='utf-8') + assert 'Canonical promotion was **not** performed' in current_state + assert 'candidate next release root' in boundary_doc + assert 'cannot honestly replace `certification_boundary.json`' in phase7_doc diff --git a/tests/test_phase8_promotion_targets_pytest.py b/tests/test_phase8_promotion_targets_pytest.py new file mode 100644 index 00000000..b2bfdaac --- /dev/null +++ b/tests/test_phase8_promotion_targets_pytest.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from tigrcorn.cli import build_parser +from tigrcorn.compat.release_gates import evaluate_promotion_target + +ROOT = Path(__file__).resolve().parents[1] +PROMOTION_REPORT = evaluate_promotion_target(ROOT) + + +def _load_json(relative_path: str) -> dict: + return json.loads((ROOT / relative_path).read_text(encoding='utf-8')) + + +def test_strict_target_boundary_uses_current_boundary_schema_and_promotes_required_rfcs() -> None: + authoritative = _load_json('docs/review/conformance/certification_boundary.json') + strict_target = _load_json( + 'docs/review/conformance/certification_boundary.strict_target.json' + ) + assert set(strict_target) == set(authoritative) + assert strict_target['canonical_doc'] == 'docs/review/conformance/STRICT_PROFILE_TARGET.md' + for rfc in [ + 'RFC 7692', + 'RFC 9110 §9.3.6', + 'RFC 9110 §6.5', + 'RFC 9110 §8', + 'RFC 6960', + ]: + assert ( + authoritative['required_rfc_evidence'][rfc]['highest_required_evidence_tier'] + == 'local_conformance' + ) + assert ( + strict_target['required_rfc_evidence'][rfc]['highest_required_evidence_tier'] + == 'independent_certification' + ) + assert ( + 'independent_certification' + in strict_target['required_rfc_evidence'][rfc]['declared_evidence'] + ) + + +def test_flag_contracts_cover_every_public_flag_and_record_current_gaps() -> None: + parser = build_parser() + public_flags: set[str] = set() + for action in parser._actions: + if isinstance(action, argparse._HelpAction): + continue + if action.help == argparse.SUPPRESS: + continue + for flag in action.option_strings: + if flag.startswith('--'): + public_flags.add(flag) + + payload = _load_json('docs/review/conformance/flag_contracts.json') + contracts = payload['contracts'] + contract_flags = {row['flag_strings'][0] for row in contracts} + assert payload['contract_mode'] == 'one_row_per_concrete_public_flag' + assert payload['public_flag_string_count'] == len(public_flags) + assert contract_flags == public_flags + assert len(contracts) == len(public_flags) + + gaps = { + row['flag_strings'][0]: row['status']['current_runtime_state'] + for row in contracts + if row['flag_strings'][0] + in { + '--limit-concurrency', + '--websocket-ping-interval', + '--websocket-ping-timeout', + } + } + assert gaps == { + '--limit-concurrency': 'implemented', + '--websocket-ping-interval': 'implemented', + '--websocket-ping-timeout': 'implemented', + } + + +def test_flag_covering_array_declares_required_hazard_clusters_and_full_one_way_coverage() -> None: + payload = _load_json('docs/review/conformance/flag_covering_array.json') + hazard_clusters = {entry['cluster_id'] for entry in payload['hazard_clusters']} + assert hazard_clusters == { + 'transport_protocol_tls', + 'protocol_timeout_concurrency', + 'websocket_compression_carrier_transport', + 'semantic_extensions_by_http_version', + 'workers_reload_inherited_fd', + } + covered_flags: set[str] = set() + for case in payload['cases']: + for dimension in case['dimensions']: + if 'flag' in dimension: + covered_flags.add(dimension['flag']) + contract_payload = _load_json('docs/review/conformance/flag_contracts.json') + contract_flags = {row['flag_strings'][0] for row in contract_payload['contracts']} + assert covered_flags == contract_flags + assert payload['public_flag_string_count'] == len(contract_flags) + + +def test_performance_slo_target_declares_stricter_metric_threshold_and_budget_keys() -> None: + slos = _load_json('docs/review/performance/performance_slos.json') + assert 'component_regression' in slos['required_matrix_lanes'] + assert 'end_to_end_release' in slos['required_matrix_lanes'] + assert 'p99_9_ms' in slos['required_metric_keys'] + assert 'time_to_first_byte_ms' in slos['required_metric_keys'] + assert 'handshake_latency_ms' in slos['required_metric_keys'] + assert 'max_p99_9_ms' in slos['required_threshold_keys'] + assert 'max_time_to_first_byte_ms' in slos['required_threshold_keys'] + assert 'max_handshake_latency_ms' in slos['required_threshold_keys'] + assert 'max_p99_9_increase_fraction' in slos['required_relative_regression_budget_keys'] + assert 'max_cpu_increase_fraction' in slos['required_relative_regression_budget_keys'] + assert 'max_rss_increase_fraction' in slos['required_relative_regression_budget_keys'] + assert 'correctness.json' in slos['required_artifact_files'] + assert set(slos['required_matrix_lanes']) == {'component_regression', 'end_to_end_release'} + assert slos['promotion_requirements']['require_correctness_under_load_for_rfc_targets'] + assert slos['promotion_requirements']['require_end_to_end_live_listener_profiles'] + assert slos['promotion_requirements']['require_certification_platforms'] + assert slos['promotion_requirements']['require_documented_slos_per_profile'] + + +def test_composite_promotion_evaluator_reports_current_dual_boundary_state() -> None: + report = PROMOTION_REPORT + assert report.authoritative_boundary.passed, report.authoritative_boundary.failures + assert report.strict_target_boundary.passed + assert report.flag_surface.passed, report.flag_surface.failures + assert report.operator_surface.passed + assert report.performance.passed, report.performance.failures + assert report.documentation.passed, report.documentation.failures + assert report.passed + + strict_failures = '\n'.join(report.strict_target_boundary.failures) + assert ( + 'RFC 7692 independent_certification scenario websocket-http3-server-aioquic-client-permessage-deflate has preserved artifacts but they are not marked passing' + not in strict_failures + ) + assert ( + 'RFC 7692 requires independent_certification evidence, but the resolved evidence only reaches local_conformance' + not in strict_failures + ) + assert ( + 'RFC 9110 §9.3.6 independent_certification scenario http3-connect-relay-aioquic-client has preserved artifacts but they are not marked passing' + not in strict_failures + ) + assert ( + 'RFC 9110 §9.3.6 references unknown independent_certification scenario http11-connect-relay-curl-client' + not in strict_failures + ) + assert ( + 'RFC 9110 §6.5 independent_certification scenario http3-trailer-fields-aioquic-client has preserved artifacts but they are not marked passing' + not in strict_failures + ) + assert ( + 'RFC 9110 §6.5 references unknown independent_certification scenario http11-trailer-fields-curl-client' + not in strict_failures + ) + assert strict_failures == '' + assert ( + 'RFC 9110 §8 references unknown independent_certification scenario http11-content-coding-curl-client' + not in strict_failures + ) + assert ( + 'RFC 6960 references unknown independent_certification scenario tls-server-ocsp-validation-openssl-client' + not in strict_failures + ) + assert ( + 'RFC 6960 requires independent_certification evidence, but the resolved evidence only reaches local_conformance' + not in strict_failures + ) + + flag_failures = '\n'.join(report.flag_surface.failures) + assert flag_failures == '' + for flag in [ + '--ssl-ciphers', + '--limit-concurrency', + '--websocket-ping-interval', + '--websocket-ping-timeout', + '--log-config', + '--otel-endpoint', + '--statsd-host', + ]: + assert flag not in flag_failures + + perf_failures = '\n'.join(report.performance.failures) + assert perf_failures == '' + + +def test_phase8_status_snapshot_matches_composite_report() -> None: + snapshot = _load_json( + 'docs/review/conformance/phase8_strict_promotion_target_status.current.json' + ) + assert snapshot['phase'] == 8 + assert snapshot['checkpoint'] == 'strict_promotion_targets_documented' + assert snapshot['authoritative_boundary_passed'] == PROMOTION_REPORT.authoritative_boundary.passed + assert snapshot['strict_target_boundary_passed'] == PROMOTION_REPORT.strict_target_boundary.passed + assert snapshot['flag_surface_passed'] == PROMOTION_REPORT.flag_surface.passed + assert snapshot['operator_surface_passed'] == PROMOTION_REPORT.operator_surface.passed + assert snapshot['performance_passed'] == PROMOTION_REPORT.performance.passed + assert snapshot['documentation_passed'] == PROMOTION_REPORT.documentation.passed + assert snapshot['final_promotion_gate_passed'] == PROMOTION_REPORT.passed + assert snapshot['blockers']['documentation'] == [] + + +def test_phase8_status_tool_regenerates_snapshot_consistently() -> None: + subprocess.run( + [sys.executable, str(ROOT / 'tools' / 'create_phase8_promotion_target_status.py')], + check=True, + cwd=ROOT, + ) + snapshot = json.loads( + ( + ROOT + / 'docs/review/conformance/phase8_strict_promotion_target_status.current.json' + ).read_text(encoding='utf-8') + ) + assert snapshot['phase'] == 8 + assert snapshot['checkpoint'] == 'strict_promotion_targets_documented' + assert snapshot['authoritative_boundary_passed'] + assert snapshot['strict_target_boundary_passed'] + assert snapshot['flag_surface_passed'] + assert snapshot['operator_surface_passed'] + assert snapshot['performance_passed'] + assert snapshot['documentation_passed'] + assert snapshot['final_promotion_gate_passed'] diff --git a/tests/test_phase9_implementation_plan_pytest.py b/tests/test_phase9_implementation_plan_pytest.py new file mode 100644 index 00000000..b4a18917 --- /dev/null +++ b/tests/test_phase9_implementation_plan_pytest.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' + + +def test_phase9_plan_documents_exist_and_remain_honest() -> None: + plan_md = CONFORMANCE / 'PHASE9_IMPLEMENTATION_PLAN.md' + plan_json = CONFORMANCE / 'phase9_implementation_plan.current.json' + delivery = ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9_IMPLEMENTATION_PLAN.md' + + assert plan_md.exists(), 'expected phase9 implementation plan markdown to exist' + assert plan_json.exists(), 'expected phase9 implementation plan json to exist' + assert delivery.exists(), 'expected delivery notes for the plan checkpoint to exist' + + text = plan_md.read_text(encoding='utf-8') + assert 'It is **not** a claim that the current tree is already strict-target complete.' in text + assert 'strict target boundary: blocked by 10 still-missing independent third-party scenarios plus 1 preserved failing RFC 7692 HTTP/3 artifact' in text + assert 'flag surface: blocked by 7 non-promotion-ready public flags' in text + assert 'operator surface: green' in text + assert 'performance target: blocked by stricter SLO and lane gaps' in text + assert 'Do **not** mutate `docs/review/conformance/releases/0.3.7/release-0.3.7/`.' in text + assert 'docs/review/conformance/releases/0.3.9/release-0.3.9/' in text + + +def test_phase9_plan_json_tracks_current_blockers_and_exit_conditions() -> None: + payload = json.loads((CONFORMANCE / 'phase9_implementation_plan.current.json').read_text(encoding='utf-8')) + assert payload['phase'] == 9 + assert payload['checkpoint'] == 'phase9_implementation_plan_checkpoint' + + current = payload['current_state'] + assert current['authoritative_boundary_passed'] is True + assert current['strict_target_boundary_passed'] is False + assert current['flag_surface_passed'] is False + assert current['operator_surface_passed'] is True + assert current['performance_passed'] is False + assert current['documentation_passed'] is True + assert current['final_promotion_gate_passed'] is False + + assert len(current['strict_target_missing_independent_scenarios']) == 10 + assert len(current['flag_runtime_blockers']) == 7 + assert 'tls-server-ocsp-validation-openssl-client' in current['strict_target_missing_independent_scenarios'] + assert current['strict_target_non_passing_independent_scenarios'] == ['websocket-http3-server-aioquic-client-permessage-deflate'] + assert '--limit-concurrency' in current['flag_runtime_blockers'] + + phases = {entry['phase_id']: entry for entry in payload['phases']} + for phase_id in ['9A', '9B', '9C', '9D', '9E', '9F', '9G', '9H', '9I', '10']: + assert phase_id in phases + + assert phases['9C']['blockers_closed'] == [ + 'websocket-http11-server-websockets-client-permessage-deflate', + 'websocket-http2-server-h2-client-permessage-deflate', + 'websocket-http3-server-aioquic-client-permessage-deflate', + ] + assert 'src/tigrcorn/security/tls13/handshake.py' in phases['9F']['key_files'] + assert 'docs/review/performance/performance_matrix.json' in phases['9G']['key_files'] + assert 'src/tigrcorn/compat/release_gates.py' in phases['9H']['key_files'] + assert phases['9I']['dependencies'] == ['9C', '9D', '9E', '9F', '9G', '9H'] + + definition = payload['promotion_definition_of_done'] + assert any('all 13 missing strict-target scenarios' in item for item in definition) + assert payload['phase_execution_status']['9B']['status'] == 'completed' + assert payload['phase_execution_status']['9C']['status'] == 'partially_completed' + assert any('all 7 remaining public flag gaps' in item for item in definition) + assert any('promotion evaluator enforces the full target contract' in item for item in definition) + + +def test_current_state_and_readmes_point_to_phase9_plan() -> None: + current_state = (ROOT / 'docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md').read_text(encoding='utf-8') + conformance_readme = (CONFORMANCE / 'README.md').read_text(encoding='utf-8') + top_readme = (ROOT / 'README.md').read_text(encoding='utf-8') + + assert '## Phase 9 implementation-plan checkpoint' in current_state + assert 'PHASE9_IMPLEMENTATION_PLAN.md' in current_state + assert 'phase9_implementation_plan.current.json' in current_state + + assert 'PHASE9_IMPLEMENTATION_PLAN.md' in conformance_readme + assert 'phase9_implementation_plan.current.json' in conformance_readme + assert 'PHASE9_IMPLEMENTATION_PLAN.md' in top_readme + assert 'phase9_implementation_plan.current.json' in top_readme diff --git a/tests/test_phase9a_promotion_contract_freeze_pytest.py b/tests/test_phase9a_promotion_contract_freeze_pytest.py new file mode 100644 index 00000000..95e187a3 --- /dev/null +++ b/tests/test_phase9a_promotion_contract_freeze_pytest.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +PERFORMANCE = ROOT / 'docs' / 'review' / 'performance' + + +def _load_json(relative_path: str) -> dict: + return json.loads((ROOT / relative_path).read_text(encoding='utf-8')) + + +def test_phase9a_contract_freeze_docs_and_release_root_exist() -> None: + doc = CONFORMANCE / 'PHASE9A_PROMOTION_CONTRACT_FREEZE.md' + backlog_doc = CONFORMANCE / 'PHASE9A_EXECUTION_BACKLOG.md' + status_json = CONFORMANCE / 'phase9a_promotion_contract.current.json' + backlog_json = CONFORMANCE / 'phase9a_execution_backlog.current.json' + delivery = ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9A_PROMOTION_CONTRACT_FREEZE.md' + release_root = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' + manifest = release_root / 'manifest.json' + readme = release_root / 'README.md' + + assert doc.exists() + assert backlog_doc.exists() + assert status_json.exists() + assert backlog_json.exists() + assert delivery.exists() + assert release_root.exists() + assert manifest.exists() + assert readme.exists() + + text = doc.read_text(encoding='utf-8') + assert 'Phase 9A' in text + assert '0.3.9' in text + assert '0.3.7' in text + assert '13 strict-target independent-scenario gaps' in text + assert '7 public flag/runtime gaps' in text + assert 'not yet' in text and 'certifiably fully featured' in text + + +def test_phase9a_status_snapshot_freezes_release_root_policy_and_scope() -> None: + payload = _load_json('docs/review/conformance/phase9a_promotion_contract.current.json') + assert payload['phase'] == '9A' + assert payload['checkpoint'] == 'phase9a_promotion_contract_freeze' + assert payload['contract_frozen'] is True + assert payload['status'] == 'contract_frozen_not_yet_strict_complete' + + current = payload['current_state'] + assert current['authoritative_boundary_passed'] is True + assert current['strict_target_boundary_passed'] is False + assert current['flag_surface_passed'] is False + assert current['operator_surface_passed'] is True + assert current['performance_passed'] is False + assert current['documentation_passed'] is True + assert current['final_promotion_gate_passed'] is False + assert len(current['strict_target_missing_independent_scenarios']) == 13 + assert len(current['flag_runtime_blockers']) == 7 + + policy = payload['release_root_policy'] + assert policy['immutable_candidate_release_root'].endswith('releases/0.3.7/release-0.3.7') + assert policy['allow_mutation_of_candidate_release_root'] is False + assert policy['next_promotable_release_root'].endswith('releases/0.3.9/release-0.3.9') + assert policy['next_promotable_release_root_frozen'] is True + + operator = payload['operator_surface_no_regression'] + assert operator['must_remain_green'] is True + assert set(operator['required_implemented_keys']) == { + 'workers_process_supervision', + 'reload', + 'proxy_header_normalization', + 'root_path_scope_injection', + 'structured_logging', + 'metrics_endpoint', + 'resource_timeout_controls_wired', + } + + assert payload['out_of_scope_until_strict_target_green'] == [ + 'RFC 7232', + 'RFC 9530', + 'RFC 9111', + 'RFC 9421', + 'JOSE', + 'COSE', + ] + + +def test_phase9a_backlog_tracks_every_remaining_strict_scenario_and_flag_gap() -> None: + payload = _load_json('docs/review/conformance/phase9a_execution_backlog.current.json') + strict_rows = payload['strict_target_scenario_rows'] + flag_rows = payload['public_flag_closure_rows'] + + assert len(strict_rows) == 13 + assert len(flag_rows) == 7 + + strict_ids = {row['scenario_id'] for row in strict_rows} + assert 'websocket-http11-server-websockets-client-permessage-deflate' in strict_ids + assert 'http3-content-coding-aioquic-client' in strict_ids + assert 'tls-server-ocsp-validation-openssl-client' in strict_ids + + for row in strict_rows: + assert row['owner_role'] + assert row['target_phase'] in {'9C', '9D', '9E'} + assert row['touch_files'] + assert row['artifact_contract']['required_scenario_files'] + assert row['exit_tests'] + + flag_ids = {row['flag'] for row in flag_rows} + assert flag_ids == { + '--ssl-ciphers', + '--log-config', + '--statsd-host', + '--otel-endpoint', + '--limit-concurrency', + '--websocket-ping-interval', + '--websocket-ping-timeout', + } + + for row in flag_rows: + assert row['owner_role'] + assert row['target_phase'] == '9F' + assert row['touch_files'] + assert row['artifact_contract']['required_state_transition'] + assert row['exit_tests'] + + performance = payload['performance_closure_contract'] + assert performance['owner_role'] == 'performance_owner' + assert 'docs/review/performance/performance_matrix.json' in performance['touch_files'] + assert 'p99_9_ms' in performance['artifact_contract']['required_metric_keys'] + assert 'max_p99_9_ms' in performance['artifact_contract']['required_threshold_keys'] + assert 'max_cpu_increase_fraction' in performance['artifact_contract']['required_relative_regression_budget_keys'] + assert 'end_to_end_release' in performance['artifact_contract']['required_matrix_lanes'] + + gate = payload['gate_hardening_contract'] + assert gate['owner_role'] == 'promotion_gate_owner' + assert 'required_artifact_files' in gate['artifact_contract']['must_enforce'] + assert 'missing_lane' in gate['artifact_contract']['required_negative_fixture_classes'] + + +def test_phase9a_updates_contract_files_and_readmes() -> None: + strict_text = (CONFORMANCE / 'STRICT_PROFILE_TARGET.md').read_text(encoding='utf-8') + flag_text = (CONFORMANCE / 'FLAG_CERTIFICATION_TARGET.md').read_text(encoding='utf-8') + perf_text = (PERFORMANCE / 'PERFORMANCE_SLOS.md').read_text(encoding='utf-8') + current_state = (ROOT / 'docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md').read_text(encoding='utf-8') + root_readme = (ROOT / 'README.md').read_text(encoding='utf-8') + conf_readme = (CONFORMANCE / 'README.md').read_text(encoding='utf-8') + plan_json = _load_json('docs/review/conformance/phase9_implementation_plan.current.json') + contracts = _load_json('docs/review/conformance/flag_contracts.json') + covering = _load_json('docs/review/conformance/flag_covering_array.json') + promo = _load_json('docs/review/conformance/promotion_gate.target.json') + + assert 'PHASE9A_PROMOTION_CONTRACT_FREEZE.md' in strict_text + assert 'phase9a_execution_backlog.current.json' in strict_text + assert 'RFC 7232, RFC 9530, RFC 9111, RFC 9421, JOSE, and COSE remain out-of-scope' in strict_text + + assert 'PHASE9A_EXECUTION_BACKLOG.md' in flag_text + assert 'phase9a_execution_backlog.current.json' in flag_text + assert 'Row-level delivery metadata' in flag_text + + assert 'phase9a_promotion_contract.current.json' in perf_text + assert 'fixed contract data' in perf_text + + assert '## Phase 9A promotion-contract-freeze checkpoint' in current_state + assert 'phase9a_promotion_contract.current.json' in current_state + assert 'phase9a_execution_backlog.current.json' in current_state + + assert 'PHASE9A_PROMOTION_CONTRACT_FREEZE.md' in root_readme + assert 'phase9a_promotion_contract.current.json' in root_readme + assert 'PHASE9A_PROMOTION_CONTRACT_FREEZE.md' in conf_readme + assert 'phase9a_execution_backlog.current.json' in conf_readme + + assert plan_json['phase_execution_status']['9A']['status'] == 'completed' + assert 'phase9a_contract_freeze' in contracts + assert 'phase9a_contract_freeze' in covering + assert 'phase9a_contract_freeze' in promo + + blocking = contracts['phase9a_contract_freeze']['blocking_flags'] + assert set(blocking) == { + '--ssl-ciphers', + '--log-config', + '--statsd-host', + '--otel-endpoint', + '--limit-concurrency', + '--websocket-ping-interval', + '--websocket-ping-timeout', + } + + rows = {row['flag_strings'][0]: row for row in contracts['contracts']} + assert rows['--ssl-ciphers']['phase9a_delivery']['owner_role'] == 'tls_runtime_owner' + assert rows['--log-config']['phase9a_delivery']['owner_role'] == 'observability_owner' + assert rows['--limit-concurrency']['phase9a_delivery']['owner_role'] == 'scheduler_runtime_owner' + assert rows['--websocket-ping-timeout']['phase9a_delivery']['owner_role'] == 'websocket_runtime_owner' diff --git a/tests/test_phase9b_independent_harness_foundation_pytest.py b/tests/test_phase9b_independent_harness_foundation_pytest.py new file mode 100644 index 00000000..d8f8513d --- /dev/null +++ b/tests/test_phase9b_independent_harness_foundation_pytest.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + +from tigrcorn.compat.interop_runner import ExternalInteropRunner, load_external_matrix +from tigrcorn.compat.release_gates import validate_independent_certification_bundle +from tools.interop_wrappers import describe_wrapper_registry + + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +BUNDLE_ROOT = RELEASE_ROOT / 'tigrcorn-independent-harness-foundation-bundle' +PYTHON = sys.executable + + +def _write_matrix(tmp_path: Path, payload: dict) -> Path: + path = tmp_path / 'matrix.json' + path.write_text(json.dumps(payload), encoding='utf-8') + return path + + +def test_phase9b_docs_wrapper_registry_and_release_root_exist() -> None: + doc = CONFORMANCE / 'PHASE9B_INDEPENDENT_HARNESS_FOUNDATION.md' + schema_doc = CONFORMANCE / 'INTEROP_HARNESS_ARTIFACT_SCHEMA.md' + status_json = CONFORMANCE / 'phase9b_independent_harness.current.json' + wrapper_json = CONFORMANCE / 'interop_wrapper_registry.current.json' + delivery = ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9B_INDEPENDENT_HARNESS_FOUNDATION.md' + + assert doc.exists() + assert schema_doc.exists() + assert status_json.exists() + assert wrapper_json.exists() + assert delivery.exists() + assert BUNDLE_ROOT.exists() + + payload = json.loads(status_json.read_text(encoding='utf-8')) + assert payload['phase'] == '9B' + assert payload['status'] == 'harness_foundation_complete_not_yet_strict_complete' + assert payload['current_state']['authoritative_boundary_passed'] is True + assert payload['current_state']['strict_target_boundary_passed'] is False + assert payload['current_state']['proof_bundle_validator_passed'] is True + assert payload['proof_bundle']['path'].endswith('tigrcorn-independent-harness-foundation-bundle') + assert payload['proof_bundle']['proof_scenarios'] == ['http1-server-curl-client'] + + +def test_wrapper_registry_covers_the_phase9b_peer_families() -> None: + registry = describe_wrapper_registry() + assert registry['module'] == 'tools.interop_wrappers' + assert set(registry['families']) == {'curl', 'websockets', 'h2', 'aioquic', 'openssl'} + assert registry['families']['curl'] == ['curl.http1_client', 'curl.http2_client'] + assert 'aioquic.http3_client' in registry['families']['aioquic'] + assert 'openssl.quic_client' in registry['families']['openssl'] + + +def test_phase9b_proof_bundle_validates_and_contains_required_artifacts() -> None: + report = validate_independent_certification_bundle( + BUNDLE_ROOT, + required_scenarios=['http1-server-curl-client'], + ) + assert report.passed is True + assert report.failures == [] + + manifest = json.loads((BUNDLE_ROOT / 'manifest.json').read_text(encoding='utf-8')) + index_payload = json.loads((BUNDLE_ROOT / 'index.json').read_text(encoding='utf-8')) + scenario_root = BUNDLE_ROOT / 'http1-server-curl-client' + + assert manifest['bundle_kind'] == 'independent_harness_foundation' + assert manifest['phase'] == '9B' + assert manifest['proof_scenarios'] == ['http1-server-curl-client'] + assert index_payload['total'] == 1 + assert index_payload['passed'] == 1 + assert scenario_root.exists() + for filename in ('summary.json', 'index.json', 'result.json', 'scenario.json', 'command.json', 'env.json', 'versions.json', 'wire_capture.json'): + assert (scenario_root / filename).exists() + + +def test_bundle_validator_rejects_missing_required_scenario_file() -> None: + with tempfile.TemporaryDirectory() as tmpdir: + clone_root = Path(tmpdir) / 'bundle' + shutil.copytree(BUNDLE_ROOT, clone_root) + (clone_root / 'http1-server-curl-client' / 'env.json').unlink() + report = validate_independent_certification_bundle( + clone_root, + required_scenarios=['http1-server-curl-client'], + ) + assert report.passed is False + assert any('env.json' in failure for failure in report.failures) + + +def test_runner_emits_phase9b_artifact_schema_for_new_runs() -> None: + payload = { + 'metadata': { + 'bundle_kind': 'independent_certification', + 'phase9b_wrapper_families': describe_wrapper_registry()['families'], + }, + 'name': 'phase9b-runner-proof', + 'scenarios': [ + { + 'id': 'http1-server-fixture-client', + 'protocol': 'http1', + 'role': 'server', + 'feature': 'post-echo', + 'peer': 'fixture-http-client', + 'sut': { + 'name': 'tigrcorn-http1', + 'adapter': 'subprocess', + 'role': 'server', + 'command': [PYTHON, '-m', 'tigrcorn', 'examples.echo_http.app:app', '--host', '{bind_host}', '--port', '{bind_port}', '--protocol', 'http1', '--disable-websocket', '--no-access-log', '--lifespan', 'off'], + 'ready_pattern': 'listening on', + 'version_command': [PYTHON, '-m', 'tigrcorn', '--help'], + }, + 'peer_process': { + 'name': 'fixture-http-client', + 'adapter': 'subprocess', + 'role': 'client', + 'command': [PYTHON, '-m', 'tests.fixtures_pkg.interop_http_client'], + 'version_command': [PYTHON, '-m', 'tests.fixtures_pkg.interop_http_client', '--version'], + }, + 'assertions': [ + {'path': 'peer.exit_code', 'equals': 0}, + {'path': 'transcript.peer.response.status', 'equals': 200}, + {'path': 'transcript.peer.response.body', 'equals': 'echo:hello-interop'}, + ], + } + ], + } + with tempfile.TemporaryDirectory() as tmpdir: + tmp_root = Path(tmpdir) + matrix_path = _write_matrix(tmp_root, payload) + prior = os.environ.get('TIGRCORN_COMMIT_HASH') + os.environ['TIGRCORN_COMMIT_HASH'] = 'phase9b-runner-test' + try: + runner = ExternalInteropRunner(matrix=load_external_matrix(matrix_path), artifact_root=tmp_root, source_root=ROOT) + summary = runner.run() + finally: + if prior is None: + os.environ.pop('TIGRCORN_COMMIT_HASH', None) + else: + os.environ['TIGRCORN_COMMIT_HASH'] = prior + + run_root = Path(summary.artifact_root) + scenario_root = run_root / 'http1-server-fixture-client' + assert (run_root / 'summary.json').exists() + assert (run_root / 'index.json').exists() + assert (scenario_root / 'summary.json').exists() + assert (scenario_root / 'index.json').exists() + assert (scenario_root / 'command.json').exists() + assert (scenario_root / 'env.json').exists() + assert (scenario_root / 'versions.json').exists() + assert (scenario_root / 'wire_capture.json').exists() + + scenario_index = json.loads((scenario_root / 'index.json').read_text(encoding='utf-8')) + assert scenario_index['artifact_files']['env.json']['exists'] is True + assert scenario_index['artifact_files']['command.json']['exists'] is True + assert scenario_index['artifact_files']['versions.json']['exists'] is True + assert scenario_index['artifact_files']['wire_capture.json']['exists'] is True diff --git a/tests/test_phase9c_rfc7692_independent_closure_pytest.py b/tests/test_phase9c_rfc7692_independent_closure_pytest.py new file mode 100644 index 00000000..c5aff7f1 --- /dev/null +++ b/tests/test_phase9c_rfc7692_independent_closure_pytest.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates + + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +INDEPENDENT = RELEASE_ROOT / 'tigrcorn-independent-certification-release-matrix' +LOCAL_NEGATIVE = RELEASE_ROOT / 'tigrcorn-rfc7692-local-negative-artifacts' + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + + +def test_phase9c_docs_and_status_exist() -> None: + assert (CONFORMANCE / 'PHASE9C_RFC7692_INDEPENDENT_CLOSURE.md').exists() + assert (CONFORMANCE / 'phase9c_rfc7692_independent_closure.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9C_RFC7692_INDEPENDENT_CLOSURE.md').exists() + + payload = _load_json(CONFORMANCE / 'phase9c_rfc7692_independent_closure.current.json') + assert payload['phase'] == '9C' + assert payload['status'] == 'rfc7692_independent_closure_complete_all_carriers_green' + assert payload['current_state']['authoritative_boundary_passed'] is True + assert payload['current_state']['strict_target_boundary_passed'] is True + assert payload['current_state']['promotion_target_passed'] is True + assert payload['current_state']['strict_failure_count'] == 0 + assert payload['current_state']['remaining_non_passing_independent_scenarios'] == [] + assert payload['current_state']['rfc7692_complete_all_carriers'] is True + + +def test_phase9c_release_root_contains_passing_rfc7692_artifacts_and_local_negative_vectors() -> None: + index_payload = _load_json(INDEPENDENT / 'index.json') + entries = {entry['id']: entry for entry in index_payload['scenarios']} + assert entries['websocket-http11-server-websockets-client-permessage-deflate']['passed'] is True + assert entries['websocket-http2-server-h2-client-permessage-deflate']['passed'] is True + assert entries['websocket-http3-server-aioquic-client-permessage-deflate']['passed'] is True + + h2_result = _load_json(INDEPENDENT / 'websocket-http2-server-h2-client-permessage-deflate' / 'result.json') + assert h2_result['passed'] is True + assert h2_result['transcript']['peer']['response']['extension_header'] == 'permessage-deflate; server_max_window_bits=15; client_max_window_bits=15' + + h3_dir = INDEPENDENT / 'websocket-http3-server-aioquic-client-permessage-deflate' + h3_result = _load_json(h3_dir / 'result.json') + h3_index = _load_json(h3_dir / 'index.json') + assert h3_result['passed'] is True + for name in ['sut_transcript.json', 'peer_transcript.json', 'sut_negotiation.json', 'peer_negotiation.json']: + assert h3_index['artifact_files'][name]['exists'] is True + + local_index = _load_json(LOCAL_NEGATIVE / 'index.json') + assert {entry['id'] for entry in local_index['scenarios']} == { + 'invalid-offer-parameters-ignored', + 'unsolicited-client-max-window-bits-rejected', + 'explicit-window-bits-default-agreement', + } + assert local_index['failed'] == 0 + + +def test_phase9c_strict_boundary_now_points_to_0_3_8_and_reports_rfc7692_as_complete() -> None: + boundary = _load_json(CONFORMANCE / 'certification_boundary.strict_target.json') + assert boundary['canonical_release_bundle'] == 'docs/review/conformance/releases/0.3.9/release-0.3.9' + assert boundary['artifact_bundles']['independent_certification'].endswith('releases/0.3.9/release-0.3.9/tigrcorn-independent-certification-release-matrix') + + report = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + failures = '\n'.join(report.failures) + assert report.passed is True + assert 'RFC 7692 independent_certification scenario websocket-http3-server-aioquic-client-permessage-deflate has preserved artifacts but they are not marked passing' not in failures + assert 'RFC 7692 requires independent_certification evidence, but the resolved evidence only reaches local_conformance' not in failures + assert 'RFC 7692 references unknown independent_certification scenario websocket-http11-server-websockets-client-permessage-deflate' not in failures diff --git a/tests/test_phase9d1_connect_relay_independent_closure_pytest.py b/tests/test_phase9d1_connect_relay_independent_closure_pytest.py new file mode 100644 index 00000000..175d601e --- /dev/null +++ b/tests/test_phase9d1_connect_relay_independent_closure_pytest.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, validate_independent_certification_bundle + + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +INDEPENDENT = RELEASE_ROOT / 'tigrcorn-independent-certification-release-matrix' +LOCAL_NEGATIVE = RELEASE_ROOT / 'tigrcorn-connect-relay-local-negative-artifacts' + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + + +def test_phase9d1_docs_and_status_exist() -> None: + assert (CONFORMANCE / 'PHASE9D1_CONNECT_RELAY_INDEPENDENT_CLOSURE.md').exists() + assert (CONFORMANCE / 'phase9d1_connect_relay_independent.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9D1_CONNECT_RELAY_INDEPENDENT_CLOSURE.md').exists() + + payload = _load_json(CONFORMANCE / 'phase9d1_connect_relay_independent.current.json') + assert payload['phase'] == '9D1' + assert payload['status'] == 'connect_relay_independent_closure_complete_all_carriers_green' + assert payload['current_state']['authoritative_boundary_passed'] is True + assert payload['current_state']['strict_target_boundary_passed'] is True + assert payload['current_state']['promotion_target_passed'] is True + assert payload['current_state']['independent_bundle_validator_passed'] is True + assert payload['current_state']['non_passing_independent_scenarios'] == [] + assert payload['current_state']['connect_relay_complete_all_carriers'] is True + + +def test_phase9d1_release_root_contains_connect_artifacts_and_local_negative_vectors() -> None: + report = validate_independent_certification_bundle( + INDEPENDENT, + required_scenarios=[ + 'http11-connect-relay-curl-client', + 'http2-connect-relay-h2-client', + 'http3-connect-relay-aioquic-client', + ], + ) + assert report.passed is True + assert report.failures == [] + + index_payload = _load_json(INDEPENDENT / 'index.json') + entries = {entry['id']: entry for entry in index_payload['scenarios']} + assert entries['http11-connect-relay-curl-client']['passed'] is True + assert entries['http2-connect-relay-h2-client']['passed'] is True + assert entries['http3-connect-relay-aioquic-client']['passed'] is True + + h2_result = _load_json(INDEPENDENT / 'http2-connect-relay-h2-client' / 'result.json') + assert h2_result['passed'] is True + assert h2_result['transcript']['peer']['tunnel']['connect_status'] == 200 + assert h2_result['transcript']['peer']['response']['body'].startswith('echo:') + + h3_result = _load_json(INDEPENDENT / 'http3-connect-relay-aioquic-client' / 'result.json') + assert h3_result['passed'] is True + assert h3_result['peer']['exit_code'] == 0 + assert h3_result['negotiation']['peer']['protocol'] == 'h3' + for name in ['sut_transcript', 'peer_transcript', 'sut_negotiation', 'peer_negotiation']: + assert h3_result['artifacts'][name]['exists'] is True + + local_index = _load_json(LOCAL_NEGATIVE / 'index.json') + assert {entry['id'] for entry in local_index['scenarios']} == { + 'http11-connect-policy-deny', + 'http11-connect-allowlist-rejection', + 'http2-connect-policy-deny', + 'http2-connect-allowlist-rejection', + 'http3-connect-policy-deny', + 'http3-connect-allowlist-rejection', + } + assert local_index['failed'] == 0 + + +def test_phase9d1_strict_boundary_reports_connect_as_partial_artifact_failure_not_unknown_scenarios() -> None: + report = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + failures = '\n'.join(report.failures) + assert report.passed is True + assert 'RFC 9110 §9.3.6 independent_certification scenario http3-connect-relay-aioquic-client has preserved artifacts but they are not marked passing' not in failures + assert 'RFC 9110 §9.3.6 references unknown independent_certification scenario http11-connect-relay-curl-client' not in failures + assert 'RFC 9110 §9.3.6 references unknown independent_certification scenario http2-connect-relay-h2-client' not in failures diff --git a/tests/test_phase9d1_connect_relay_local_negatives_pytest.py b/tests/test_phase9d1_connect_relay_local_negatives_pytest.py new file mode 100644 index 00000000..2f5b0df3 --- /dev/null +++ b/tests/test_phase9d1_connect_relay_local_negatives_pytest.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import asyncio +import socket + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, serialize_settings +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.http3.codec import encode_frame as encode_h3_frame, FRAME_DATA as H3_FRAME_DATA +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +import pytest +async def _start_server(*, http_versions: list[str], transport: str = 'tcp', config_mutator=None): + async def app(scope, receive, send): + raise AssertionError('CONNECT handling should not dispatch to the ASGI app') + + kwargs = {'host': '127.0.0.1', 'port': 0, 'lifespan': 'off', 'http_versions': http_versions} + if transport == 'udp': + kwargs.update({'transport': 'udp', 'protocols': ['http3'], 'quic_secret': b'shared'}) + config = build_config(**kwargs) + if config_mutator is not None: + config_mutator(config) + server = TigrCornServer(app, config) + await server.start() + if transport == 'udp': + port = server._listeners[0].transport.get_extra_info('sockname')[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _issue_h2_connect(port: int, authority: str) -> tuple[list[tuple[bytes, bytes]], bytes, bool]: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + try: + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + frame_writer = FrameWriter() + request_headers = encode_header_block([ + (b':method', b'CONNECT'), + (b':authority', authority.encode('ascii')), + ]) + writer.write(frame_writer.headers(1, request_headers, end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + response_headers: list[tuple[bytes, bytes]] = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 1.0) + if not data: + break + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + continue + if frame.frame_type == FRAME_HEADERS and frame.stream_id == 1: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA and frame.stream_id == 1: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + return response_headers, bytes(body), ended + finally: + writer.close() + await writer.wait_closed() + + +async def _issue_h3_connect(port: int, authority: str) -> tuple[list[tuple[bytes, bytes]], bytes, bool]: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-connect-neg') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + connect_payload = core.get_request(0).encode_request([ + (b':method', b'CONNECT'), + (b':authority', authority.encode('ascii')), + ]) + sock.sendto(client.send_stream_data(0, connect_payload, fin=True), ('127.0.0.1', port)) + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream' and event.stream_id == 0: + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + assert response_state is not None + return list(response_state.headers), bytes(response_state.body), bool(response_state.ended) + finally: + sock.close() + + + +async def test_http2_connect_policy_deny_and_allowlist_rejection_end_stream() -> None: + upstream = await asyncio.start_server(lambda r, w: None, '127.0.0.1', 0) + upstream_port = upstream.sockets[0].getsockname()[1] + try: + def deny(config): + config.http.connect_policy = 'deny' + + server, port = await _start_server(http_versions=['2'], config_mutator=deny) + try: + headers, body, ended = await _issue_h2_connect(port, f'127.0.0.1:{upstream_port}') + assert (b':status' in b'403'), headers + assert body == b'connect denied' + assert ended + finally: + await server.close() + + def allowlist(config): + config.http.connect_policy = 'allowlist' + config.http.connect_allow = ['127.0.0.1:1'] + + server, port = await _start_server(http_versions=['2'], config_mutator=allowlist) + try: + headers, body, ended = await _issue_h2_connect(port, f'127.0.0.1:{upstream_port}') + assert (b':status' in b'403'), headers + assert body == b'connect denied' + assert ended + finally: + await server.close() + finally: + upstream.close() + await upstream.wait_closed() + +async def test_http3_connect_policy_deny_and_allowlist_rejection_end_stream() -> None: + upstream = await asyncio.start_server(lambda r, w: None, '127.0.0.1', 0) + upstream_port = upstream.sockets[0].getsockname()[1] + try: + def deny(config): + config.http.connect_policy = 'deny' + + server, port = await _start_server(http_versions=['3'], transport='udp', config_mutator=deny) + try: + headers, body, ended = await _issue_h3_connect(port, f'127.0.0.1:{upstream_port}') + assert (b':status' in b'403'), headers + assert body == b'connect denied' + assert ended + finally: + await server.close() + + def allowlist(config): + config.http.connect_policy = 'allowlist' + config.http.connect_allow = ['127.0.0.1:1'] + + server, port = await _start_server(http_versions=['3'], transport='udp', config_mutator=allowlist) + try: + headers, body, ended = await _issue_h3_connect(port, f'127.0.0.1:{upstream_port}') + assert (b':status' in b'403'), headers + assert body == b'connect denied' + assert ended + finally: + await server.close() + finally: + upstream.close() + await upstream.wait_closed() + + diff --git a/tests/test_phase9d2_trailer_fields_independent_closure_pytest.py b/tests/test_phase9d2_trailer_fields_independent_closure_pytest.py new file mode 100644 index 00000000..bbedcc0d --- /dev/null +++ b/tests/test_phase9d2_trailer_fields_independent_closure_pytest.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, validate_independent_certification_bundle + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +INDEPENDENT = RELEASE_ROOT / 'tigrcorn-independent-certification-release-matrix' +LOCAL_BEHAVIOR = RELEASE_ROOT / 'tigrcorn-trailer-fields-local-behavior-artifacts' + + +def _load_json(path: Path): + return json.loads(path.read_text(encoding='utf-8')) + + +def test_phase9d2_docs_and_status_exist() -> None: + assert (CONFORMANCE / 'PHASE9D2_TRAILER_FIELDS_INDEPENDENT_CLOSURE.md').exists() + assert (CONFORMANCE / 'phase9d2_trailer_fields_independent.current.json').exists() + assert (CONFORMANCE / 'TRAILER_FIELDS_LOCAL_BEHAVIOR_ARTIFACTS.md').exists() + assert (CONFORMANCE / 'trailer_fields_local_behavior_artifacts.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9D2_TRAILER_FIELDS_INDEPENDENT_CLOSURE.md').exists() + + payload = _load_json(CONFORMANCE / 'phase9d2_trailer_fields_independent.current.json') + assert payload['phase'] == '9D2' + assert payload['status'] == 'trailer_fields_independent_closure_complete_all_carriers_green' + assert payload['current_state']['authoritative_boundary_passed'] is True + assert payload['current_state']['strict_target_boundary_passed'] is True + assert payload['current_state']['promotion_target_passed'] is True + assert payload['current_state']['strict_failure_count'] == 0 + assert payload['current_state']['non_passing_independent_scenarios'] == [] + + +def test_phase9d2_release_root_contains_trailer_artifacts_and_local_behavior_bundle() -> None: + index_payload = _load_json(INDEPENDENT / 'index.json') + entries = {entry['id']: entry for entry in index_payload['scenarios']} + assert entries['http11-trailer-fields-curl-client']['passed'] is True + assert entries['http2-trailer-fields-h2-client']['passed'] is True + assert entries['http3-trailer-fields-aioquic-client']['passed'] is True + + h1_result = _load_json(INDEPENDENT / 'http11-trailer-fields-curl-client' / 'result.json') + assert h1_result['passed'] is True + assert h1_result['transcript']['peer']['response']['body'] == 'ok' + assert h1_result['transcript']['peer']['response']['trailers'] == [['x-trailer-one', 'yes'], ['x-trailer-two', 'done']] + + h2_result = _load_json(INDEPENDENT / 'http2-trailer-fields-h2-client' / 'result.json') + assert h2_result['passed'] is True + assert h2_result['transcript']['peer']['response']['body'] == 'ok' + assert h2_result['transcript']['peer']['response']['trailers'] == [['x-trailer-one', 'yes'], ['x-trailer-two', 'done']] + assert h2_result['transcript']['peer']['response']['stream_ended'] is True + + h3_result = _load_json(INDEPENDENT / 'http3-trailer-fields-aioquic-client' / 'result.json') + assert h3_result['passed'] is True + assert h3_result['peer']['exit_code'] == 0 + assert h3_result['negotiation']['peer']['protocol'] == 'h3' + for name in ['sut_transcript', 'peer_transcript', 'sut_negotiation', 'peer_negotiation']: + assert h3_result['artifacts'][name]['exists'] is True + + local_index = _load_json(LOCAL_BEHAVIOR / 'index.json') + assert {entry['id'] for entry in local_index['scenarios']} == { + 'http11-request-trailers-pass', + 'http11-request-trailers-drop', + 'http11-request-trailers-strict-invalid', + 'http11-response-trailers-pass', + 'http2-request-trailers-pass', + 'http2-request-trailers-strict-invalid', + 'http2-response-trailers-pass', + 'http3-request-trailers-pass', + 'http3-request-trailers-strict-invalid', + 'http3-response-trailers-pass', + } + assert local_index['failed'] == 0 + + +def test_phase9d2_strict_boundary_tracks_trailer_progress_in_0_3_8_root() -> None: + boundary = _load_json(CONFORMANCE / 'certification_boundary.strict_target.json') + assert boundary['canonical_release_bundle'] == 'docs/review/conformance/releases/0.3.9/release-0.3.9' + assert boundary['artifact_bundles']['independent_certification'].endswith('releases/0.3.9/release-0.3.9/tigrcorn-independent-certification-release-matrix') + + report = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + failures = '\n'.join(report.failures) + assert report.passed is True + assert 'RFC 9110 §6.5 independent_certification scenario http3-trailer-fields-aioquic-client has preserved artifacts but they are not marked passing' not in failures + assert 'RFC 9110 §6.5 independent_certification scenario http11-trailer-fields-curl-client is missing preserved artifacts under the canonical independent release bundle' not in failures + assert 'RFC 9110 §6.5 independent_certification scenario http2-trailer-fields-h2-client is missing preserved artifacts under the canonical independent release bundle' not in failures + assert 'RFC 9110 §6.5 references unknown independent_certification scenario http11-trailer-fields-curl-client' not in failures + assert 'RFC 9110 §6.5 references unknown independent_certification scenario http2-trailer-fields-h2-client' not in failures + + +def test_phase9d2_independent_bundle_still_validates() -> None: + report = validate_independent_certification_bundle(INDEPENDENT) + assert report.passed is True + assert report.failures == [] diff --git a/tests/test_phase9d3_content_coding_independent_closure_pytest.py b/tests/test_phase9d3_content_coding_independent_closure_pytest.py new file mode 100644 index 00000000..78c47655 --- /dev/null +++ b/tests/test_phase9d3_content_coding_independent_closure_pytest.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, validate_independent_certification_bundle + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +INDEPENDENT = RELEASE_ROOT / 'tigrcorn-independent-certification-release-matrix' +LOCAL_BEHAVIOR = RELEASE_ROOT / 'tigrcorn-content-coding-local-behavior-artifacts' + + +def _load_json(path: Path): + return json.loads(path.read_text(encoding='utf-8')) + + +def test_phase9d3_docs_and_status_exist() -> None: + assert (CONFORMANCE / 'PHASE9D3_CONTENT_CODING_INDEPENDENT_CLOSURE.md').exists() + assert (CONFORMANCE / 'phase9d3_content_coding_independent.current.json').exists() + assert (CONFORMANCE / 'CONTENT_CODING_LOCAL_BEHAVIOR_ARTIFACTS.md').exists() + assert (CONFORMANCE / 'content_coding_local_behavior_artifacts.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9D3_CONTENT_CODING_INDEPENDENT_CLOSURE.md').exists() + + payload = _load_json(CONFORMANCE / 'phase9d3_content_coding_independent.current.json') + assert payload['phase'] == '9D3' + assert payload['status'] == 'content_coding_independent_complete_all_carriers_green' + assert payload['current_state']['authoritative_boundary_passed'] is True + assert payload['current_state']['strict_target_boundary_passed'] is True + assert payload['current_state']['promotion_target_passed'] is True + assert payload['current_state']['non_passing_independent_scenarios'] == [] + + +def test_phase9d3_release_root_contains_content_coding_artifacts_and_local_behavior_bundle() -> None: + index_payload = _load_json(INDEPENDENT / 'index.json') + entries = {entry['id']: entry for entry in index_payload['scenarios']} + assert entries['http11-content-coding-curl-client']['passed'] is True + assert entries['http2-content-coding-curl-client']['passed'] is True + assert entries['http3-content-coding-aioquic-client']['passed'] is True + + h1_result = _load_json(INDEPENDENT / 'http11-content-coding-curl-client' / 'result.json') + assert h1_result['passed'] is True + assert h1_result['transcript']['peer']['response']['content_encoding'] == 'gzip' + assert h1_result['transcript']['peer']['response']['decoded_body'] == 'compress-me' + assert h1_result['transcript']['peer']['response']['vary'] == 'accept-encoding' + + h2_result = _load_json(INDEPENDENT / 'http2-content-coding-curl-client' / 'result.json') + assert h2_result['passed'] is True + assert h2_result['transcript']['peer']['response']['content_encoding'] == 'gzip' + assert h2_result['transcript']['peer']['response']['decoded_body'] == 'compress-me' + assert h2_result['transcript']['peer']['response']['vary'] == 'accept-encoding' + assert h2_result['transcript']['peer']['response']['stream_ended'] is True + + h3_result = _load_json(INDEPENDENT / 'http3-content-coding-aioquic-client' / 'result.json') + assert h3_result['passed'] is True + assert h3_result['peer']['exit_code'] == 0 + assert h3_result['transcript']['peer']['response']['content_encoding'] == 'gzip' + assert h3_result['transcript']['peer']['response']['decoded_body'] == 'compress-me' + assert h3_result['transcript']['peer']['response']['vary'] == 'accept-encoding' + + local_index = _load_json(LOCAL_BEHAVIOR / 'index.json') + assert {entry['id'] for entry in local_index['scenarios']} == { + 'http11-content-coding-gzip-pass', + 'http11-content-coding-identity-forbidden-406', + 'http11-content-coding-strict-unsupported-406', + 'http2-content-coding-gzip-pass', + 'http2-content-coding-identity-forbidden-406', + 'http2-content-coding-strict-unsupported-406', + 'http3-content-coding-gzip-pass', + 'http3-content-coding-identity-forbidden-406', + 'http3-content-coding-strict-unsupported-406', + } + assert local_index['failed'] == 0 + + +def test_phase9d3_strict_boundary_tracks_content_coding_progress_in_0_3_8_root() -> None: + boundary = _load_json(CONFORMANCE / 'certification_boundary.strict_target.json') + assert boundary['canonical_release_bundle'] == 'docs/review/conformance/releases/0.3.9/release-0.3.9' + assert boundary['artifact_bundles']['independent_certification'].endswith('releases/0.3.9/release-0.3.9/tigrcorn-independent-certification-release-matrix') + + report = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + failures = '\n'.join(report.failures) + assert report.passed is True + assert failures == '' + assert 'RFC 9110 §8 independent_certification scenario http11-content-coding-curl-client is missing preserved artifacts under the canonical independent release bundle' not in failures + assert 'RFC 9110 §8 independent_certification scenario http2-content-coding-curl-client is missing preserved artifacts under the canonical independent release bundle' not in failures + assert 'RFC 9110 §8 references unknown independent_certification scenario http11-content-coding-curl-client' not in failures + assert 'RFC 9110 §8 references unknown independent_certification scenario http2-content-coding-curl-client' not in failures + + +def test_phase9d3_independent_bundle_still_validates() -> None: + report = validate_independent_certification_bundle(INDEPENDENT) + assert report.passed is True + assert report.failures == [] diff --git a/tests/test_phase9e_ocsp_independent_closure_pytest.py b/tests/test_phase9e_ocsp_independent_closure_pytest.py new file mode 100644 index 00000000..f5d06f86 --- /dev/null +++ b/tests/test_phase9e_ocsp_independent_closure_pytest.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, validate_independent_certification_bundle + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' +INDEPENDENT = RELEASE_ROOT / 'tigrcorn-independent-certification-release-matrix' +LOCAL_VALIDATION = RELEASE_ROOT / 'tigrcorn-ocsp-local-validation-artifacts' + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + + +def test_phase9e_docs_and_status_exist() -> None: + assert (CONFORMANCE / 'PHASE9E_OCSP_INDEPENDENT_CLOSURE.md').exists() + assert (CONFORMANCE / 'phase9e_ocsp_independent.current.json').exists() + assert (CONFORMANCE / 'OCSP_LOCAL_VALIDATION_ARTIFACTS.md').exists() + assert (CONFORMANCE / 'ocsp_local_validation_artifacts.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9E_OCSP_INDEPENDENT_CLOSURE.md').exists() + + payload = _load_json(CONFORMANCE / 'phase9e_ocsp_independent.current.json') + assert payload['phase'] == '9E' + assert payload['status'] == 'ocsp_independent_green_remaining_http3_blockers' + assert payload['current_state']['authoritative_boundary_passed'] is True + assert payload['current_state']['strict_target_boundary_passed'] is False + assert payload['current_state']['strict_failure_count'] == 2 + assert payload['current_state']['remaining_missing_independent_scenarios'] == [] + assert payload['current_state']['non_passing_independent_scenarios'] == [ + 'http3-content-coding-aioquic-client', + ] + + +def test_phase9e_release_root_contains_passing_ocsp_artifact_and_local_vectors() -> None: + index_payload = _load_json(INDEPENDENT / 'index.json') + entries = {entry['id']: entry for entry in index_payload['scenarios']} + assert entries['tls-server-ocsp-validation-openssl-client']['passed'] is True + + result = _load_json(INDEPENDENT / 'tls-server-ocsp-validation-openssl-client' / 'result.json') + assert result['passed'] is True + assert result['peer']['exit_code'] == 0 + assert result['transcript']['peer']['handshake_established'] is True + assert result['transcript']['peer']['response']['status'] == 200 + assert result['negotiation']['peer']['verification'] == 'OK' + assert result['ocsp_responder']['good_request_count'] >= 1 + + local_index = _load_json(LOCAL_VALIDATION / 'index.json') + assert {entry['id'] for entry in local_index['scenarios']} == { + 'ocsp-good-response-cache-reuse-client-auth', + 'ocsp-stale-response-require-fails', + 'ocsp-revoked-client-certificate-fails', + 'ocsp-unreachable-soft-fail-vs-require', + } + assert local_index['failed'] == 0 + + +def test_phase9e_strict_boundary_and_validator_reflect_ocsp_progress() -> None: + report = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + failures = '\n'.join(report.failures) + assert report.passed is True + assert report.failures == [] + assert 'RFC 6960 independent_certification scenario tls-server-ocsp-validation-openssl-client is missing preserved artifacts under the canonical independent release bundle' not in failures + assert 'RFC 9110 §9.3.6 independent_certification scenario http3-connect-relay-aioquic-client has preserved artifacts but they are not marked passing' not in failures + assert 'RFC 6960 requires independent_certification evidence, but the resolved evidence only reaches local_conformance' not in failures + + validation = validate_independent_certification_bundle(INDEPENDENT, required_scenarios=['tls-server-ocsp-validation-openssl-client']) + assert validation.passed is True + assert validation.failures == [] + + +def test_phase9e_external_matrix_declares_openssl_ocsp_row() -> None: + matrix = _load_json(CONFORMANCE / 'external_matrix.release.json') + entries = {entry['id']: entry for entry in matrix['scenarios']} + row = entries['tls-server-ocsp-validation-openssl-client'] + assert row['peer'] == 'openssl' + assert row['peer_process']['metadata']['wrapper_id'] == 'openssl.tls_client' + assert row['sut']['env']['INTEROP_OCSP_MODE'] == 'require' diff --git a/tests/test_phase9e_ocsp_local_validation_pytest.py b/tests/test_phase9e_ocsp_local_validation_pytest.py new file mode 100644 index 00000000..3f6dc1ae --- /dev/null +++ b/tests/test_phase9e_ocsp_local_validation_pytest.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from cryptography.x509 import ocsp + +from tests.fixtures_pkg.interop_ocsp_fixtures import ( + CertificateFactory, + ResponseSpec, + der_ocsp, + pem_certificate, + revocation_http_server, +) +from tigrcorn.errors import ProtocolError +from tigrcorn.security.x509.path import ( + CertificatePurpose, + CertificateValidationPolicy, + RevocationFetchPolicy, + RevocationMode, + verify_certificate_chain, +) + + +def _trust_bundle(root, issuer) -> list[bytes]: + return [pem_certificate(root), pem_certificate(issuer)] + + +def test_good_ocsp_response_is_cached_for_client_auth() -> None: + factory = CertificateFactory() + with revocation_http_server({}) as server: + root, root_key = factory.make_ca('Root CA', path_length=1) + issuer, issuer_key = factory.make_ca('Issuer CA', issuer_cert=root, issuer_key=root_key, path_length=0) + leaf, _ = factory.make_client_leaf('client.good.local', issuer_cert=issuer, issuer_key=issuer_key, ocsp_uris=(server.url('/ocsp-good'),)) + server.responses[('POST', '/ocsp-good')] = ResponseSpec( + body=der_ocsp(factory.make_ocsp_response(leaf, issuer, issuer_key, next_update=datetime.now(timezone.utc) + timedelta(minutes=30))), + headers={'Content-Type': 'application/ocsp-response', 'Cache-Control': 'max-age=600'}, + ) + policy = CertificateValidationPolicy(purpose=CertificatePurpose.CLIENT_AUTH, revocation_mode=RevocationMode.REQUIRE) + verified = verify_certificate_chain([pem_certificate(leaf), pem_certificate(issuer)], _trust_bundle(root, issuer), policy=policy) + assert verified.serial_number == leaf.serial_number + assert server.count('POST', '/ocsp-good') == 1 + verified = verify_certificate_chain([pem_certificate(leaf), pem_certificate(issuer)], _trust_bundle(root, issuer), policy=policy) + assert verified.serial_number == leaf.serial_number + assert server.count('POST', '/ocsp-good') == 1 + + +def test_stale_ocsp_response_fails_in_require_mode() -> None: + factory = CertificateFactory() + with revocation_http_server({}) as server: + root, root_key = factory.make_ca('Root CA', path_length=1) + issuer, issuer_key = factory.make_ca('Issuer CA', issuer_cert=root, issuer_key=root_key, path_length=0) + leaf, _ = factory.make_client_leaf('client.stale.local', issuer_cert=issuer, issuer_key=issuer_key, ocsp_uris=(server.url('/ocsp-stale'),)) + server.responses[('POST', '/ocsp-stale')] = ResponseSpec( + body=der_ocsp(factory.make_ocsp_response(leaf, issuer, issuer_key, next_update=datetime.now(timezone.utc) - timedelta(hours=1), this_update=datetime.now(timezone.utc) - timedelta(days=1))), + headers={'Content-Type': 'application/ocsp-response'}, + ) + policy = CertificateValidationPolicy(purpose=CertificatePurpose.CLIENT_AUTH, revocation_mode=RevocationMode.REQUIRE) + with pytest.raises(ProtocolError, match='revocation status could not be established'): + verify_certificate_chain([pem_certificate(leaf), pem_certificate(issuer)], _trust_bundle(root, issuer), policy=policy) + + +def test_revoked_client_certificate_fails_in_require_mode() -> None: + factory = CertificateFactory() + with revocation_http_server({}) as server: + root, root_key = factory.make_ca('Root CA', path_length=1) + issuer, issuer_key = factory.make_ca('Issuer CA', issuer_cert=root, issuer_key=root_key, path_length=0) + leaf, _ = factory.make_client_leaf('client.revoked.local', issuer_cert=issuer, issuer_key=issuer_key, ocsp_uris=(server.url('/ocsp-revoked'),)) + server.responses[('POST', '/ocsp-revoked')] = ResponseSpec( + body=der_ocsp(factory.make_ocsp_response(leaf, issuer, issuer_key, cert_status=ocsp.OCSPCertStatus.REVOKED, next_update=datetime.now(timezone.utc) + timedelta(minutes=30))), + headers={'Content-Type': 'application/ocsp-response'}, + ) + policy = CertificateValidationPolicy(purpose=CertificatePurpose.CLIENT_AUTH, revocation_mode=RevocationMode.REQUIRE) + with pytest.raises(ProtocolError, match='revoked'): + verify_certificate_chain([pem_certificate(leaf), pem_certificate(issuer)], _trust_bundle(root, issuer), policy=policy) + + +def test_unreachable_responder_soft_fail_and_require_modes_diverge() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca('Root CA', path_length=1) + issuer, issuer_key = factory.make_ca('Issuer CA', issuer_cert=root, issuer_key=root_key, path_length=0) + leaf, _ = factory.make_client_leaf('client.unreachable.local', issuer_cert=issuer, issuer_key=issuer_key, ocsp_uris=('http://127.0.0.1:9/unreachable',)) + soft_policy = CertificateValidationPolicy( + purpose=CertificatePurpose.CLIENT_AUTH, + revocation_mode=RevocationMode.SOFT_FAIL, + revocation_fetch_policy=RevocationFetchPolicy(timeout_seconds=0.25), + ) + require_policy = CertificateValidationPolicy( + purpose=CertificatePurpose.CLIENT_AUTH, + revocation_mode=RevocationMode.REQUIRE, + revocation_fetch_policy=RevocationFetchPolicy(timeout_seconds=0.25), + ) + verified = verify_certificate_chain([pem_certificate(leaf), pem_certificate(issuer)], _trust_bundle(root, issuer), policy=soft_policy) + assert verified.serial_number == leaf.serial_number + with pytest.raises(ProtocolError, match='OCSP http://127.0.0.1:9/unreachable'): + verify_certificate_chain([pem_certificate(leaf), pem_certificate(issuer)], _trust_bundle(root, issuer), policy=require_policy) diff --git a/tests/test_phase9f1_tls_cipher_policy_closure_pytest.py b/tests/test_phase9f1_tls_cipher_policy_closure_pytest.py new file mode 100644 index 00000000..7fa8f596 --- /dev/null +++ b/tests/test_phase9f1_tls_cipher_policy_closure_pytest.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import socket +import ssl +import tempfile +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_promotion_target +from tigrcorn.config.load import build_config +from tigrcorn.constants import DEFAULT_QUIC_SECRET +from tigrcorn.errors import ConfigError +from tigrcorn.security.tls import build_server_ssl_context +from tigrcorn.security.tls13.handshake import generate_self_signed_certificate +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic.connection import QuicConnection +from tigrcorn.transports.quic.handshake import QuicTlsHandshakeDriver + +import pytest +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' + + +async def _start_tls_server(*, ssl_ciphers: str): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + cert_pem, key_pem = generate_self_signed_certificate('server.example') + tmpdir = tempfile.TemporaryDirectory() + certfile = os.path.join(tmpdir.name, 'server-cert.pem') + keyfile = os.path.join(tmpdir.name, 'server-key.pem') + Path(certfile).write_bytes(cert_pem) + Path(keyfile).write_bytes(key_pem) + config = build_config( + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['1.1'], + ssl_certfile=certfile, + ssl_keyfile=keyfile, + ssl_ciphers=ssl_ciphers, + ) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return tmpdir, cert_pem, server, port + + +async def _start_http3_server(*, ssl_ciphers: str): + async def app(scope, receive, send): + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + cert_pem, key_pem = generate_self_signed_certificate('server.example') + tmpdir = tempfile.TemporaryDirectory() + certfile = os.path.join(tmpdir.name, 'server-cert.pem') + keyfile = os.path.join(tmpdir.name, 'server-key.pem') + Path(certfile).write_bytes(cert_pem) + Path(keyfile).write_bytes(key_pem) + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + ssl_certfile=certfile, + ssl_keyfile=keyfile, + ssl_ciphers=ssl_ciphers, + ) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].transport.get_extra_info('sockname')[1] + return tmpdir, cert_pem, server, port + + + +def test_cli_and_config_runtime_fields_resolve_ssl_ciphers(): + config = build_config( + host='127.0.0.1', + port=0, + ssl_certfile='tests/fixtures_certs/interop-localhost-cert.pem', + ssl_keyfile='tests/fixtures_certs/interop-localhost-key.pem', + ssl_ciphers='TLS_AES_128_GCM_SHA256', + ) + assert config.tls.resolved_cipher_suites == (0x1301,) + assert config.listeners[0].ssl_ciphers == 'TLS_AES_128_GCM_SHA256' + assert config.listeners[0].resolved_cipher_suites == (0x1301,) +def test_invalid_ssl_cipher_expressions_fail_fast(): + with pytest.raises(ConfigError): + build_config( + host='127.0.0.1', + port=0, + ssl_certfile='tests/fixtures_certs/interop-localhost-cert.pem', + ssl_keyfile='tests/fixtures_certs/interop-localhost-key.pem', + ssl_ciphers='TLS_FAKE_CIPHER', + ) + +def test_build_server_ssl_context_carries_resolved_cipher_suites(): + config = build_config( + host='127.0.0.1', + port=0, + ssl_certfile='tests/fixtures_certs/interop-localhost-cert.pem', + ssl_keyfile='tests/fixtures_certs/interop-localhost-key.pem', + ssl_ciphers='TLS_AES_128_GCM_SHA256', + ) + context = build_server_ssl_context(config.listeners[0]) + assert context is not None + assert context is not None + assert context.cipher_suites == (0x1301,) +async def test_tcp_tls_negotiated_suite_changes_with_configured_allowlist(): + tmpdir, cert_pem, server, port = await _start_tls_server(ssl_ciphers='TLS_AES_128_GCM_SHA256') + try: + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=None) + cafile = Path(tmpdir.name) / 'trusted.pem' + cafile.write_bytes(cert_pem) + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=str(cafile)) + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + ctx.set_alpn_protocols(['http/1.1']) + reader, writer = await asyncio.open_connection('127.0.0.1', port, ssl=ctx, server_hostname='server.example') + try: + assert writer.get_extra_info('ssl_object').cipher()[0] == 'TLS_AES_128_GCM_SHA256' + writer.write(b'POST / HTTP/1.1\r\nHost: server.example\r\nContent-Length: 0\r\n\r\n') + await writer.drain() + data = await reader.read(65535) + assert b'204 No Content' in data + finally: + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + finally: + await server.close() + tmpdir.cleanup() + +async def test_quic_tls_negotiated_suite_changes_with_configured_allowlist(): + tmpdir, cert_pem, server, port = await _start_http3_server(ssl_ciphers='TLS_AES_128_GCM_SHA256') + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=DEFAULT_QUIC_SECRET, local_cid=b'cli1cli1') + client.configure_handshake( + QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[cert_pem], + ) + ) + loop = asyncio.get_running_loop() + sock.sendto(client.start_handshake(), ('127.0.0.1', port)) + for _ in range(12): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for _event in client.receive_datagram(data): + pass + for datagram in client.take_handshake_datagrams(): + sock.sendto(datagram, ('127.0.0.1', port)) + if client.handshake_driver is not None and client.handshake_driver.complete: + break + assert client.handshake_driver is not None + assert client.handshake_driver is not None + assert client.handshake_driver.complete + assert client.handshake_driver._selected_cipher_suite == 0x1301 + sock.close() + finally: + await server.close() + tmpdir.cleanup() + +def test_phase9f1_status_snapshot_matches_current_flag_surface_state(): + payload = json.loads((CONFORMANCE / 'phase9f1_tls_cipher_policy.current.json').read_text(encoding='utf-8')) + assert payload['phase'] == '9F1' + assert payload['implemented_flag'] == '--ssl-ciphers' + assert '--ssl-ciphers' not in payload['current_state']['remaining_flag_runtime_blockers'] + failures = '\n'.join(evaluate_promotion_target(ROOT).flag_surface.failures) + assert '--ssl-ciphers' not in failures diff --git a/tests/test_phase9f2_logging_exporter_closure_pytest.py b/tests/test_phase9f2_logging_exporter_closure_pytest.py new file mode 100644 index 00000000..63ea6a35 --- /dev/null +++ b/tests/test_phase9f2_logging_exporter_closure_pytest.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import socket +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch +from urllib.error import URLError + +from tigrcorn.cli import build_parser +from tigrcorn.compat.release_gates import evaluate_promotion_target +from tigrcorn.config.load import build_config, build_config_from_namespace +from tigrcorn.errors import ConfigError +from tigrcorn.observability.logging import configure_logging, resolve_logging_config +from tigrcorn.observability.metrics import StatsdExporter +from tigrcorn.observability.tracing import OtelExporter +from tigrcorn.server.runner import TigrCornServer + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / "docs" / "review" / "conformance" + + +async def _noop_app(scope, receive, send): + if scope["type"] == "lifespan": + return + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + +class Test_CaptureHandler(BaseHTTPRequestHandler): + requests: list[dict[str, object]] = [] + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length) + self.__class__.requests.append( + { + "path": self.path, + "headers": dict(self.headers.items()), + "payload": json.loads(body.decode("utf-8")), + } + ) + self.send_response(200) + self.send_header("content-length", "0") + self.end_headers() + + def log_message(self, _format, *args): # pragma: no cover + return + + + +def test_log_config_file_is_real_runtime_input_and_cli_flags_override_it(): + parser = build_parser() + with tempfile.TemporaryDirectory() as tmp: + tmpdir = Path(tmp) + profile_path = tmpdir / "logging.json" + access_from_file = tmpdir / "access-from-file.log" + error_from_file = tmpdir / "error-from-file.log" + access_from_cli = tmpdir / "access-from-cli.log" + error_from_cli = tmpdir / "error-from-cli.log" + profile_path.write_text( + json.dumps( + { + "logging": { + "level": "error", + "structured": False, + "access_log_file": str(access_from_file), + "error_log_file": str(error_from_file), + "access_log_format": "FILE {peer}", + "stream": False, + } + } + ), + encoding="utf-8", + ) + + ns = parser.parse_args( + [ + "tests.fixtures_pkg.appmod:app", + "--log-config", + str(profile_path), + "--log-level", + "debug", + "--structured-log", + "--access-log-file", + str(access_from_cli), + "--error-log-file", + str(error_from_cli), + ] + ) + config = build_config_from_namespace(ns) + resolved = resolve_logging_config(config.log_level, config=config.logging) + assert resolved.level == "debug" + assert resolved.structured + assert resolved.access_log_file == str(access_from_cli) + assert resolved.error_log_file == str(error_from_cli) + logger = configure_logging(config.log_level, config=config.logging) + logger.debug("phase9f2-log-config-debug") + for handler in logger.handlers: + handler.flush() + assert access_from_cli.exists() + assert error_from_cli.exists() + assert not (access_from_file.exists()) + payload = access_from_cli.read_text(encoding="utf-8") + assert "phase9f2-log-config-debug" in payload + assert '"message": "phase9f2-log-config-debug"' in payload + +def test_log_config_file_wins_when_no_explicit_cli_logging_overrides_exist(): + with tempfile.TemporaryDirectory() as tmp: + tmpdir = Path(tmp) + profile_path = tmpdir / "logging.json" + error_path = tmpdir / "errors.log" + profile_path.write_text( + json.dumps( + { + "logging": { + "level": "error", + "structured": True, + "error_log_file": str(error_path), + "stream": False, + } + } + ), + encoding="utf-8", + ) + config = build_config(config={"logging": {"log_config": str(profile_path)}}) + resolved = resolve_logging_config(config.log_level, config=config.logging) + assert resolved.level == "error" + assert resolved.structured + logger = configure_logging(config.log_level, config=config.logging) + logger.debug("debug-not-emitted") + logger.error("error-emitted") + for handler in logger.handlers: + handler.flush() + data = error_path.read_text(encoding="utf-8") + assert "error-emitted" in data + assert "debug-not-emitted" not in data + +def test_invalid_log_config_fails_fast(): + parser = build_parser() + with tempfile.TemporaryDirectory() as tmp: + bad_path = Path(tmp) / "bad.json" + bad_path.write_text( + json.dumps({"logging": {"unsupported": True}}), encoding="utf-8" + ) + ns = parser.parse_args( + ["tests.fixtures_pkg.appmod:app", "--log-config", str(bad_path)] + ) + with pytest.raises(ConfigError): + build_config_from_namespace(ns) + +async def test_statsd_exporter_emits_real_udp_traffic_during_server_lifecycle(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + sock.setblocking(False) + host, port = sock.getsockname() + config = build_config(config={"metrics": {"statsd_host": f"{host}:{port}"}}) + server = TigrCornServer(_noop_app, config) + try: + await server.start() + data, _addr = await asyncio.wait_for( + asyncio.get_running_loop().sock_recvfrom(sock, 65535), 2.0 + ) + payload = data.decode("utf-8") + assert "tigrcorn.connections_opened" in payload + assert "tigrcorn.requests_served" in payload + assert server._statsd_exporter.sent_packets >= 1 + finally: + await server.close() + sock.close() + +async def test_otel_exporter_posts_metrics_and_lifecycle_spans(): + _CaptureHandler.requests = [] + httpd = ThreadingHTTPServer(("127.0.0.1", 0), _CaptureHandler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + endpoint = f"http://127.0.0.1:{httpd.server_address[1]}/v1/telemetry" + config = build_config(config={"metrics": {"otel_endpoint": endpoint}}) + server = TigrCornServer(_noop_app, config) + try: + await server.start() + await asyncio.sleep(0.25) + finally: + await server.close() + httpd.shutdown() + httpd.server_close() + thread.join(timeout=1.0) + assert len(_CaptureHandler.requests) >= 2 + span_names = [] + metrics_seen = False + for item in _CaptureHandler.requests: + payload = item["payload"] + assert "resourceMetrics" in payload + assert "resourceSpans" in payload + if payload["resourceMetrics"][0]["scopeMetrics"][0]["metrics"]: + metrics_seen = True + for span_payload in payload["resourceSpans"][0]["scopeSpans"][0]["spans"]: + span_names.append(span_payload["name"]) + assert metrics_seen + assert "server.start" in span_names + assert "server.shutdown" in span_names + +async def test_exporter_failures_are_bounded_and_do_not_abort_server_startup(): + config = build_config( + config={ + "metrics": { + "statsd_host": "127.0.0.1:8125", + "otel_endpoint": "http://127.0.0.1:9/v1/telemetry", + } + } + ) + with ( + patch.object( + StatsdExporter, "_ensure_socket", side_effect=OSError("statsd boom") + ), + patch.object(OtelExporter, "_post_json", side_effect=URLError("otel boom")), + ): + server = TigrCornServer(_noop_app, config) + await server.start() + assert server._statsd_exporter is not None + assert server._otel_exporter is not None + assert server._statsd_exporter.send_failures >= 1 + assert server._otel_exporter.send_failures >= 1 + await server.close() + +def test_phase9f2_status_snapshot_matches_current_flag_surface_state(): + payload = json.loads( + (CONFORMANCE / "phase9f2_logging_exporter.current.json").read_text( + encoding="utf-8" + ) + ) + assert payload["phase"] == "9F2" + for flag in ["--log-config", "--statsd-host", "--otel-endpoint"]: + assert ( + flag not in payload["current_state"]["remaining_flag_runtime_blockers"] + ) + failures = "\n".join(evaluate_promotion_target(ROOT).flag_surface.failures) + assert "--log-config" not in failures + assert "--statsd-host" not in failures + assert "--otel-endpoint" not in failures + assert "--limit-concurrency" not in failures + assert evaluate_promotion_target(ROOT).flag_surface.passed diff --git a/tests/test_phase9f3_concurrency_keepalive_checkpoint_pytest.py b/tests/test_phase9f3_concurrency_keepalive_checkpoint_pytest.py new file mode 100644 index 00000000..c9c78ed3 --- /dev/null +++ b/tests/test_phase9f3_concurrency_keepalive_checkpoint_pytest.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_promotion_target + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / "docs" / "review" / "conformance" + + + +def test_phase9f3_docs_and_status_exist(): + assert (CONFORMANCE / 'PHASE9F3_CONCURRENCY_WEBSOCKET_KEEPALIVE_CLOSURE.md').exists() + assert (CONFORMANCE / 'phase9f3_concurrency_keepalive.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9F3_CONCURRENCY_WEBSOCKET_KEEPALIVE_CLOSURE.md').exists() + + payload = json.loads((CONFORMANCE / 'phase9f3_concurrency_keepalive.current.json').read_text(encoding='utf-8')) + assert payload['phase'] == '9F3' + assert payload['checkpoint'] == 'phase9f3_concurrency_keepalive_closure' + assert payload['current_state']['authoritative_boundary_passed'] is True + assert payload['current_state']['strict_target_boundary_passed'] is False + assert payload['current_state']['promotion_target_passed'] is False + assert payload['current_state']['flag_surface_passed'] is True + assert payload['current_state']['remaining_flag_runtime_blockers'] == [] + assert '--limit-concurrency' in payload['implemented_flags'] + +def test_flag_contracts_now_mark_all_rows_promotion_ready(): + payload = json.loads((CONFORMANCE / 'flag_contracts.json').read_text(encoding='utf-8')) + assert payload['current_state']['promotion_ready_rows'] == payload['public_flag_string_count'] + assert payload['current_state']['runtime_gap_flags'] == [] + rows = {row['flag_strings'][0]: row for row in payload['contracts']} + for flag in ['--limit-concurrency', '--websocket-ping-interval', '--websocket-ping-timeout']: + assert rows[flag]['status']['promotion_ready'] is True + assert rows[flag]['status']['current_runtime_state'] == 'implemented' + +def test_phase8_snapshot_and_current_promotion_report_have_green_flag_surface(): + snapshot = json.loads((CONFORMANCE / 'phase8_strict_promotion_target_status.current.json').read_text(encoding='utf-8')) + assert snapshot['flag_surface_passed'] is True + assert snapshot['blockers']['flag_surface'] == [] + report = evaluate_promotion_target(ROOT) + assert report.flag_surface.passed is True + assert report.flag_surface.failures == [] diff --git a/tests/test_phase9f3_concurrency_keepalive_closure_pytest.py b/tests/test_phase9f3_concurrency_keepalive_closure_pytest.py new file mode 100644 index 00000000..5b48eeb2 --- /dev/null +++ b/tests/test_phase9f3_concurrency_keepalive_closure_pytest.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import asyncio +import base64 +import os +import socket +from contextlib import suppress + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, decode_settings, serialize_settings +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.http3.codec import SETTING_ENABLE_CONNECT_PROTOCOL +from tigrcorn.protocols.websocket.frames import decode_close_payload, encode_frame, parse_frame_bytes, read_frame +from tigrcorn.scheduler import ProductionScheduler, SchedulerPolicy +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +import pytest +def _frame_wire_length(data: bytes) -> int: + if len(data) < 2: + raise AssertionError('websocket frame is truncated') + masked = bool(data[1] & 0x80) + length = data[1] & 0x7F + pos = 2 + if length == 126: + if len(data) < pos + 2: + raise AssertionError('websocket frame is truncated') + length = int.from_bytes(data[pos:pos + 2], 'big') + pos += 2 + elif length == 127: + if len(data) < pos + 8: + raise AssertionError('websocket frame is truncated') + length = int.from_bytes(data[pos:pos + 8], 'big') + pos += 8 + if masked: + pos += 4 + total = pos + length + if len(data) < total: + raise AssertionError('websocket frame is truncated') + return total + + +async def _start_server(app, *, http_versions: list[str], transport: str = 'tcp', scheduler: dict | None = None, websocket: dict | None = None, protocols: list[str] | None = None): + payload = {} + if scheduler is not None: + payload['scheduler'] = scheduler + if websocket is not None: + payload['websocket'] = websocket + kwargs = { + 'host': '127.0.0.1', + 'port': 0, + 'lifespan': 'off', + 'http_versions': http_versions, + 'config': payload or None, + } + if transport == 'udp': + kwargs.update({'transport': 'udp', 'protocols': protocols or ['http3'], 'quic_secret': b'shared'}) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == 'udp': + port = server._listeners[0].transport.get_extra_info('sockname')[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + + +async def test_scheduler_limit_concurrency_is_real_global_inflight_cap(): + scheduler = ProductionScheduler(SchedulerPolicy(limit_concurrency=1)) + first = scheduler.acquire_work() + assert first is not None + assert first is not None + assert scheduler.current_inflight == 1 + assert scheduler.acquire_work() is None + first.release() + assert scheduler.current_inflight == 0 + gate = asyncio.Event() + + async def sleeper(): + await gate.wait() + + task = scheduler.spawn(sleeper()) + await asyncio.sleep(0) + assert scheduler.current_inflight == 1 + with pytest.raises(RuntimeError): + scheduler.spawn(asyncio.sleep(0)) + gate.set() + await task + assert scheduler.current_inflight == 0 + await scheduler.close() + +async def test_http11_limit_concurrency_returns_503_on_second_request(): + release = asyncio.Event() + started = asyncio.Event() + + async def app(scope, receive, send): + if scope['type'] != 'http': + return + if scope['path'] == '/hold': + started.set() + await release.wait() + await send({'type': 'http.response.start', 'status': 200, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port = await _start_server(app, http_versions=['1.1'], scheduler={'limit_concurrency': 1}) + try: + r1, w1 = await asyncio.open_connection('127.0.0.1', port) + w1.write(b'GET /hold HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n') + await w1.drain() + await asyncio.wait_for(started.wait(), 1.0) + + r2, w2 = await asyncio.open_connection('127.0.0.1', port) + w2.write(b'GET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n') + await w2.drain() + response = await asyncio.wait_for(r2.readuntil(b'\r\n\r\n'), 1.0) + assert b'503' in response + assert b'scheduler overloaded' in await asyncio.wait_for(r2.read(), 1.0) + assert server.state.metrics.scheduler_rejections >= 1 + release.set() + data = await asyncio.wait_for(r1.readuntil(b'\r\n\r\n'), 1.0) + assert b'200' in data + w1.close(); w2.close() + await w1.wait_closed(); await w2.wait_closed() + finally: + release.set() + await server.close() + +async def test_http2_limit_concurrency_returns_503_on_second_stream(): + release = asyncio.Event() + started = asyncio.Event() + + async def app(scope, receive, send): + if scope['type'] != 'http': + return + if scope['path'] == '/hold': + started.set() + await release.wait() + await send({'type': 'http.response.start', 'status': 200, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port = await _start_server(app, http_versions=['2'], scheduler={'limit_concurrency': 1}) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + hold_headers = encode_header_block([ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/hold'), + (b':authority', b'example'), + ]) + next_headers = encode_header_block([ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/next'), + (b':authority', b'example'), + ]) + writer.write(frame_writer.headers(1, hold_headers, end_stream=True)) + await writer.drain() + await asyncio.wait_for(started.wait(), 1.0) + writer.write(frame_writer.headers(3, next_headers, end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + statuses: dict[int, bytes] = {} + bodies: dict[int, bytearray] = {1: bytearray(), 3: bytearray()} + while 3 not in statuses: + data = await asyncio.wait_for(reader.read(65535), 1.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS and frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + header_map = dict(decode_header_block(frame.payload)) + statuses[frame.stream_id] = header_map.get(b':status', b'0') + elif frame.frame_type == FRAME_DATA: + bodies.setdefault(frame.stream_id, bytearray()).extend(frame.payload) + assert statuses[3] == b'503' + assert b'scheduler overloaded' in bytes(bodies[3]) + assert server.state.metrics.scheduler_rejections >= 1 + release.set() + writer.close() + await writer.wait_closed() + finally: + release.set() + await server.close() + +async def test_http3_websocket_admission_returns_503_when_limit_reached(): + async def app(scope, receive, send): + if scope['type'] != 'websocket': + return + event = await receive() + assert event['type'] == 'websocket.connect' + await send({'type': 'websocket.accept', 'headers': []}) + await asyncio.sleep(0.25) + + server, port = await _start_server( + app, + http_versions=['3'], + transport='udp', + protocols=['http3'], + scheduler={'limit_concurrency': 1}, + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-admit') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(4): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if core.state.remote_settings.get(SETTING_ENABLE_CONNECT_PROTOCOL) == 1: + break + + payload1 = core.get_request(0).encode_request([ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'https'), + (b':path', b'/ws1'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + ], b'') + sock.sendto(client.send_stream_data(0, payload1, fin=False), ('127.0.0.1', port)) + + first_response = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if event.stream_id == 0 and state.headers: + first_response = state + if first_response is not None: + break + assert first_response is not None + assert first_response is not None + assert (b':status' in b'200'), first_response.headers + payload2 = core.get_request(4).encode_request([ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'https'), + (b':path', b'/ws2'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + ], b'') + sock.sendto(client.send_stream_data(4, payload2, fin=True), ('127.0.0.1', port)) + + second_response = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if event.stream_id == 4 and state.headers: + second_response = state + if second_response is not None and second_response.ended: + break + assert second_response is not None + assert second_response is not None + assert (b':status' in b'503'), second_response.headers + assert second_response.body == b'scheduler overloaded' + assert server.state.metrics.scheduler_rejections >= 1 + finally: + sock.close() + await server.close() + +async def test_http11_websocket_keepalive_sends_ping_then_closes_on_timeout(): + async def app(scope, receive, send): + if scope['type'] != 'websocket': + return + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.accept', 'headers': []}) + await asyncio.sleep(0.25) + + server, port = await _start_server(app, http_versions=['1.1'], websocket={'ping_interval': 0.05, 'ping_timeout': 0.05}) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + key = base64.b64encode(os.urandom(16)) + request = ( + b'GET /ws HTTP/1.1\r\n' + b'Host: localhost\r\n' + b'Upgrade: websocket\r\n' + b'Connection: Upgrade\r\n' + b'Sec-WebSocket-Version: 13\r\n' + b'Sec-WebSocket-Key: ' + key + b'\r\n\r\n' + ) + writer.write(request) + await writer.drain() + response = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), 1.0) + assert b'101 Switching Protocols' in response + ping = await read_frame(reader, max_payload_size=1024, expect_masked=False) + close = await read_frame(reader, max_payload_size=1024, expect_masked=False) + assert ping.opcode == 0x9 + assert close.opcode == 0x8 + code, reason = decode_close_payload(close.payload) + assert code == 1011 + assert reason == 'ping timeout' + assert server.state.metrics.websocket_pings_sent >= 1 + assert server.state.metrics.websocket_ping_timeouts >= 1 + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http2_websocket_keepalive_sends_ping_then_closes_on_timeout(): + async def app(scope, receive, send): + if scope['type'] != 'websocket': + return + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.accept', 'headers': []}) + await asyncio.sleep(0.25) + + server, port = await _start_server(app, http_versions=['2'], websocket={'ping_interval': 0.05, 'ping_timeout': 0.05}) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + header_block = encode_header_block([ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'http'), + (b':path', b'/chat'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + ]) + writer.write(frame_writer.headers(1, header_block, end_stream=False)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + ws_data = bytearray() + end_stream = False + while not end_stream: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS and frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + end_stream = True + elif frame.frame_type == FRAME_DATA: + ws_data.extend(frame.payload) + if frame.flags & 0x1: + end_stream = True + if response_headers and end_stream: + break + assert (b':status' in b'200'), response_headers + first_len = _frame_wire_length(bytes(ws_data)) + ping = parse_frame_bytes(bytes(ws_data[:first_len]), expect_masked=False) + close = parse_frame_bytes(bytes(ws_data[first_len:]), expect_masked=False) + assert ping.opcode == 0x9 + code, reason = decode_close_payload(close.payload) + assert code == 1011 + assert reason == 'ping timeout' + assert server.state.metrics.websocket_pings_sent >= 1 + assert server.state.metrics.websocket_ping_timeouts >= 1 + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http3_websocket_keepalive_sends_ping_then_closes_on_timeout(): + async def app(scope, receive, send): + if scope['type'] != 'websocket': + return + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.accept', 'headers': []}) + await asyncio.sleep(0.25) + + server, port = await _start_server(app, http_versions=['3'], transport='udp', protocols=['http3'], websocket={'ping_interval': 0.05, 'ping_timeout': 0.05}) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-keep') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(4): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if core.state.remote_settings.get(SETTING_ENABLE_CONNECT_PROTOCOL) == 1: + break + + payload = core.get_request(0).encode_request([ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'https'), + (b':path', b'/chat'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + ], b'') + sock.sendto(client.send_stream_data(0, payload, fin=False), ('127.0.0.1', port)) + response_state = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if response_state is not None and response_state.ended: + break + assert response_state is not None + assert response_state is not None + assert (b':status' in b'200'), response_state.headers + first_len = _frame_wire_length(response_state.body) + ping = parse_frame_bytes(response_state.body[:first_len], expect_masked=False) + close = parse_frame_bytes(response_state.body[first_len:], expect_masked=False) + assert ping.opcode == 0x9 + code, reason = decode_close_payload(close.payload) + assert code == 1011 + assert reason == 'ping timeout' + assert server.state.metrics.websocket_pings_sent >= 1 + assert server.state.metrics.websocket_ping_timeouts >= 1 + finally: + sock.close() + await server.close() + + diff --git a/tests/test_phase9g_strict_performance_closure_pytest.py b/tests/test_phase9g_strict_performance_closure_pytest.py new file mode 100644 index 00000000..c8995e5d --- /dev/null +++ b/tests/test_phase9g_strict_performance_closure_pytest.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_promotion_target +from tigrcorn.compat.perf_runner import validate_performance_artifacts + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +PERFORMANCE = ROOT / 'docs' / 'review' / 'performance' +CURRENT_ROOT = ROOT / 'docs' / 'review' / 'performance' / 'artifacts' / 'phase6_current_release' + + + +def _load_json(relative_path: str) -> dict: + return json.loads((ROOT / relative_path).read_text(encoding='utf-8')) + +def test_phase9g_docs_and_status_exist() -> None: + assert (CONFORMANCE / 'PHASE9G_STRICT_PERFORMANCE_CLOSURE.md').exists() + assert (CONFORMANCE / 'phase9g_strict_performance.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9G_STRICT_PERFORMANCE_CLOSURE.md').exists() + status = _load_json('docs/review/conformance/phase9g_strict_performance.current.json') + assert status['phase'] == '9G' + assert status['checkpoint'] == 'phase9g_strict_performance_closure' + assert status['current_state']['performance_passed'] + assert not (status['current_state']['promotion_target_passed']) +def test_matrix_declares_required_lanes_platforms_and_threshold_keys() -> None: + matrix = _load_json('docs/review/performance/performance_matrix.json') + lanes = {profile['lane'] for profile in matrix['profiles']} + assert lanes == {'component_regression', 'end_to_end_release'} + assert matrix['metadata']['certification_platforms'] + for profile in matrix['profiles']: + thresholds = profile['thresholds'] + assert 'max_p50_ms' in thresholds + assert 'max_p95_ms' in thresholds + assert 'max_p99_ms' in thresholds + assert 'max_p99_9_ms' in thresholds + assert 'max_time_to_first_byte_ms' in thresholds + assert 'max_handshake_latency_ms' in thresholds + assert 'max_protocol_stalls' in thresholds + assert 'max_rss_kib' in thresholds + assert 'max_scheduler_rejections' in thresholds + budget = profile['relative_regression_budget'] + assert 'max_p99_9_increase_fraction' in budget + assert 'max_cpu_increase_fraction' in budget + assert 'max_rss_increase_fraction' in budget +def test_current_artifacts_expose_required_metric_keys_and_files() -> None: + matrix = _load_json('docs/review/performance/performance_matrix.json') + for profile in matrix['profiles']: + profile_dir = CURRENT_ROOT / profile['profile_id'] + assert (profile_dir / 'result.json').exists(), profile['profile_id'] + assert (profile_dir / 'summary.json').exists(), profile['profile_id'] + assert (profile_dir / 'correctness.json').exists(), profile['profile_id'] + result = json.loads((profile_dir / 'result.json').read_text(encoding='utf-8')) + assert 'p99_9_ms' in result['metrics'] + assert 'time_to_first_byte_ms' in result['metrics'] + assert 'handshake_latency_ms' in result['metrics'] + assert 'protocol_stalls' in result['metrics'] +def test_preserved_artifacts_validate_and_promotion_report_performance_section_is_green() -> None: + failures = validate_performance_artifacts( + ROOT, + artifact_root='docs/review/performance/artifacts/phase6_current_release', + baseline_root='docs/review/performance/artifacts/phase6_reference_baseline', + require_relative_regression=True, + ) + assert failures == [] + report = evaluate_promotion_target(ROOT) + assert report.performance.passed, report.performance.failures + assert not (report.passed) + assert not (report.strict_target_boundary.passed) diff --git a/tests/test_phase9h_promotion_evaluator_hardening_pytest.py b/tests/test_phase9h_promotion_evaluator_hardening_pytest.py new file mode 100644 index 00000000..6ec556b3 --- /dev/null +++ b/tests/test_phase9h_promotion_evaluator_hardening_pytest.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path + +from tigrcorn.compat.release_gates import _evaluate_performance_target + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PROMOTION_TARGET = json.loads((ROOT / 'docs/review/conformance/promotion_gate.target.json').read_text(encoding='utf-8')) +PERFORMANCE_CONFIG = dict(PROMOTION_TARGET['performance']) + + + +def _copy_performance_tree() -> Path: + tmpdir = tempfile.mkdtemp(prefix='tigrcorn-phase9h-') + root = Path(tmpdir) + shutil.copytree(ROOT / 'docs/review/performance', root / 'docs/review/performance', dirs_exist_ok=True) + return root + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + +def test_actual_repository_performance_section_passes_under_hardened_evaluator() -> None: + report = _evaluate_performance_target(ROOT, PERFORMANCE_CONFIG) + assert report.passed, '\n'.join(report.failures) + assert report.failures == [] +def test_missing_metric_key_fails() -> None: + root = _copy_performance_tree() + try: + artifact_root = root / 'docs/review/performance/artifacts/phase6_current_release' + for result_file in artifact_root.glob('*/result.json'): + payload = _load_json(result_file) + payload['metrics'].pop('protocol_stalls', None) + result_file.write_text(json.dumps(payload, indent=2, sort_keys=True) + '\n', encoding='utf-8') + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + assert 'performance artifacts are missing required SLO metric keys' in '\n'.join(report.failures) + finally: + shutil.rmtree(root) + +def test_missing_threshold_key_fails() -> None: + root = _copy_performance_tree() + try: + matrix_file = root / 'docs/review/performance/performance_matrix.json' + matrix = _load_json(matrix_file) + matrix['profiles'][0]['thresholds'].pop('max_p99_9_ms', None) + matrix_file.write_text(json.dumps(matrix, indent=2, sort_keys=False) + '\n', encoding='utf-8') + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + assert 'http11_baseline missing required threshold keys' in '\n'.join(report.failures) + finally: + shutil.rmtree(root) + +def test_missing_relative_budget_key_fails() -> None: + root = _copy_performance_tree() + try: + matrix_file = root / 'docs/review/performance/performance_matrix.json' + matrix = _load_json(matrix_file) + matrix['profiles'][0]['relative_regression_budget'].pop('max_cpu_increase_fraction', None) + matrix_file.write_text(json.dumps(matrix, indent=2, sort_keys=False) + '\n', encoding='utf-8') + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + assert 'http11_baseline missing required relative regression budget keys' in '\n'.join(report.failures) + finally: + shutil.rmtree(root) + +def test_missing_root_artifact_file_fails() -> None: + root = _copy_performance_tree() + try: + (root / 'docs/review/performance/artifacts/phase6_current_release/summary.json').unlink() + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + failures = '\n'.join(report.failures) + assert 'missing performance summary file' in failures + assert 'performance artifact root is missing required files' in failures + finally: + shutil.rmtree(root) + +def test_missing_profile_artifact_file_fails() -> None: + root = _copy_performance_tree() + try: + (root / 'docs/review/performance/artifacts/phase6_current_release/http11_baseline/correctness.json').unlink() + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + failures = '\n'.join(report.failures) + assert 'missing artifact file for http11_baseline' in failures + assert 'http11_baseline performance artifact directory is missing required files' in failures + finally: + shutil.rmtree(root) + +def test_missing_required_lane_fails() -> None: + root = _copy_performance_tree() + try: + matrix_file = root / 'docs/review/performance/performance_matrix.json' + matrix = _load_json(matrix_file) + for profile in matrix['profiles']: + if profile['lane'] == 'component_regression': + profile['lane'] = 'end_to_end_release' + profile['live_listener_required'] = True + matrix_file.write_text(json.dumps(matrix, indent=2, sort_keys=False) + '\n', encoding='utf-8') + + summary_file = root / 'docs/review/performance/artifacts/phase6_current_release/summary.json' + summary = _load_json(summary_file) + summary['lane_counts'].pop('component_regression', None) + summary['lane_counts']['end_to_end_release'] = summary['passed'] + summary_file.write_text(json.dumps(summary, indent=2, sort_keys=False) + '\n', encoding='utf-8') + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + failures = '\n'.join(report.failures) + assert 'performance matrix is missing required lanes' in failures + assert 'performance artifact summary is missing required lane counts' in failures + finally: + shutil.rmtree(root) + +def test_missing_certification_platform_declaration_fails() -> None: + root = _copy_performance_tree() + try: + matrix_file = root / 'docs/review/performance/performance_matrix.json' + matrix = _load_json(matrix_file) + matrix['metadata']['certification_platforms'] = [] + matrix['profiles'][0]['certification_platforms'] = [] + matrix_file.write_text(json.dumps(matrix, indent=2, sort_keys=False) + '\n', encoding='utf-8') + env_file = root / 'docs/review/performance/artifacts/phase6_current_release/http11_baseline/env.json' + env_payload = _load_json(env_file) + env_payload.pop('certification_platform', None) + env_payload['matrix_declared_platforms'] = [] + env_file.write_text(json.dumps(env_payload, indent=2, sort_keys=True) + '\n', encoding='utf-8') + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + failures = '\n'.join(report.failures) + assert 'performance matrix metadata is missing certification_platforms declarations' in failures + assert 'http11_baseline missing profile certification_platforms declarations in matrix' in failures + assert 'http11_baseline missing env.json certification_platform declaration' in failures + finally: + shutil.rmtree(root) + +def test_missing_rfc_correctness_checks_fails() -> None: + root = _copy_performance_tree() + try: + correctness_file = root / 'docs/review/performance/artifacts/phase6_current_release/http11_baseline/correctness.json' + correctness = _load_json(correctness_file) + correctness['required'] = False + correctness['checks'] = {} + correctness_file.write_text(json.dumps(correctness, indent=2, sort_keys=True) + '\n', encoding='utf-8') + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + failures = '\n'.join(report.failures) + assert 'http11_baseline correctness.json is not marked required=true for an RFC-scoped profile' in failures + assert 'http11_baseline correctness.json is missing correctness checks for an RFC-scoped profile' in failures + finally: + shutil.rmtree(root) + +def test_missing_live_listener_metadata_fails() -> None: + root = _copy_performance_tree() + try: + command_file = root / 'docs/review/performance/artifacts/phase6_current_release/http11_baseline/command.json' + payload = _load_json(command_file) + payload['live_listener_required'] = False + payload['lane'] = 'component_regression' + command_file.write_text(json.dumps(payload, indent=2, sort_keys=True) + '\n', encoding='utf-8') + report = _evaluate_performance_target(root, PERFORMANCE_CONFIG) + assert not (report.passed) + failures = '\n'.join(report.failures) + assert 'http11_baseline command.json does not preserve live_listener_required=true' in failures + assert 'http11_baseline command.json does not preserve lane="end_to_end_release"' in failures + finally: + shutil.rmtree(root) + + diff --git a/tests/test_phase9i_release_assembly_checkpoint_pytest.py b/tests/test_phase9i_release_assembly_checkpoint_pytest.py new file mode 100644 index 00000000..75a9befa --- /dev/null +++ b/tests/test_phase9i_release_assembly_checkpoint_pytest.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_release_gates, evaluate_promotion_target +from tigrcorn.cli import build_parser +import argparse + +ROOT = Path(__file__).resolve().parents[1] +CONFORMANCE = ROOT / 'docs' / 'review' / 'conformance' +RELEASE_ROOT = CONFORMANCE / 'releases' / '0.3.9' / 'release-0.3.9' + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + + +def current_public_flag_count() -> int: + parser = build_parser() + flags: set[str] = set() + for action in parser._actions: + if isinstance(action, argparse._HelpAction): + continue + if action.help == argparse.SUPPRESS: + continue + for flag in action.option_strings: + if flag.startswith('--'): + flags.add(flag) + return len(flags) + + +def test_phase9i_docs_and_status_exist() -> None: + assert (CONFORMANCE / 'PHASE9I_RELEASE_ASSEMBLY_AND_CERTIFIABLE_CHECKPOINT.md').exists() + assert (CONFORMANCE / 'phase9i_release_assembly.current.json').exists() + assert (ROOT / 'docs/review/conformance/delivery/DELIVERY_NOTES_PHASE9I_RELEASE_ASSEMBLY_AND_CERTIFIABLE_CHECKPOINT.md').exists() + + status = load_json(CONFORMANCE / 'phase9i_release_assembly.current.json') + assert status['phase'] == '9I' + assert status['checkpoint'] == 'phase9i_release_assembly_and_certifiable_checkpoint' + assert status['current_state']['authoritative_boundary_passed'] is True + assert status['current_state']['strict_target_boundary_passed'] is True + assert status['current_state']['flag_surface_passed'] is True + assert status['current_state']['operator_surface_passed'] is True + assert status['current_state']['performance_passed'] is True + assert status['current_state']['documentation_passed'] is True + assert status['current_state']['promotion_target_passed'] is True + assert status['current_state']['current_package_version'] == '0.3.9' + assert status['release_assembly']['version_bump_performed'] is True + assert status['release_assembly']['release_notes_promoted'] is True + + +def test_phase9i_release_root_contains_final_bundle_set() -> None: + expected_bundles = { + 'tigrcorn-independent-certification-release-matrix', + 'tigrcorn-same-stack-replay-matrix', + 'tigrcorn-mixed-compatibility-release-matrix', + 'tigrcorn-flag-surface-certification-bundle', + 'tigrcorn-operator-surface-certification-bundle', + 'tigrcorn-performance-certification-bundle', + } + actual = {path.name for path in RELEASE_ROOT.iterdir() if path.is_dir()} + assert expected_bundles.issubset(actual) + + manifest = load_json(RELEASE_ROOT / 'manifest.json') + assert manifest['source_checkpoint'] == 'phase9i_release_assembly' + assert manifest['status'] == 'phase9i_release_assembly_certifiably_promotable' + assert manifest['promotion_ready'] is True + assert manifest['strict_target_complete'] is True + for key in ['flag_surface', 'operator_surface', 'performance', 'certification_environment', 'aioquic_adapter_preflight']: + assert key in manifest['bundles'] + for key in ['flag_surface', 'operator_surface', 'performance']: + assert manifest['bundles'][key]['release_gate_eligible'] is True + + bundle_index = load_json(RELEASE_ROOT / 'bundle_index.json') + bundle_summary = load_json(RELEASE_ROOT / 'bundle_summary.json') + assert bundle_index['source_checkpoint'] == 'phase9i_release_assembly' + assert bundle_index['promotion_ready'] is True + assert bundle_index['strict_target_complete'] is True + assert bundle_index['independent_certification_failed'] == 0 + assert bundle_summary['promotion_ready'] is True + assert bundle_summary['strict_target_complete'] is True + assert bundle_summary['independent_certification_failed'] == 0 + + +def test_phase9i_flag_operator_and_performance_bundles_are_current() -> None: + flag_index = load_json(RELEASE_ROOT / 'tigrcorn-flag-surface-certification-bundle' / 'index.json') + expected_public_flag_count = current_public_flag_count() + assert flag_index['public_flag_count'] == expected_public_flag_count + assert flag_index['promotion_ready_count'] == expected_public_flag_count + assert flag_index['hazard_clusters_green'] is True + + operator_index = load_json(RELEASE_ROOT / 'tigrcorn-operator-surface-certification-bundle' / 'index.json') + assert operator_index['implemented_count'] == 7 + assert operator_index['implemented']['metrics_endpoint'] is True + assert operator_index['implemented']['workers_process_supervision'] is True + + perf_index = load_json(RELEASE_ROOT / 'tigrcorn-performance-certification-bundle' / 'index.json') + assert perf_index['profile_count'] == 32 + assert perf_index['lane_counts'] == {'component_regression': 9, 'end_to_end_release': 23} + current_index = load_json(RELEASE_ROOT / 'tigrcorn-performance-certification-bundle' / 'artifacts' / 'phase6_current_release' / 'index.json') + assert current_index['passed'] == 32 + assert current_index['failed'] == 0 + + +def test_phase9i_current_gate_truth_matches_live_evaluators() -> None: + authoritative = evaluate_release_gates(ROOT) + strict = evaluate_release_gates(ROOT, boundary_path='docs/review/conformance/certification_boundary.strict_target.json') + promotion = evaluate_promotion_target(ROOT) + status = load_json(CONFORMANCE / 'phase9i_release_assembly.current.json') + release_gate_status = load_json(CONFORMANCE / 'release_gate_status.current.json') + package_review = load_json(CONFORMANCE / 'package_compliance_review_phase9i.current.json') + + assert status['validation']['evaluate_release_gates_authoritative']['passed'] == authoritative.passed + assert status['validation']['evaluate_release_gates_strict_target']['passed'] == strict.passed + assert status['validation']['evaluate_promotion_target']['passed'] == promotion.passed + assert release_gate_status['passed'] == authoritative.passed + assert release_gate_status['strict_target_passed'] == strict.passed + assert release_gate_status['promotion_target_passed'] == promotion.passed + assert package_review['summary']['current_package_certifiably_fully_featured'] is True + assert package_review['summary']['remaining_non_passing_independent_scenarios'] == [] + assert authoritative.passed is True + assert strict.passed is True + assert promotion.passed is True + + strict_failures = '\n'.join(strict.failures) + assert strict_failures == '' diff --git a/tests/test_pipe_and_inproc_pytest.py b/tests/test_pipe_and_inproc_pytest.py new file mode 100644 index 00000000..9df851a8 --- /dev/null +++ b/tests/test_pipe_and_inproc_pytest.py @@ -0,0 +1,41 @@ +import asyncio +import os +import tempfile + +from tigrcorn.listeners.inproc import InProcListener +from tigrcorn.listeners.pipe import PipeListener + + +import pytest + +async def test_inproc_listener_dispatch(): + seen = [] + + async def handler(data): + seen.append(data) + + listener = InProcListener() + await listener.start(handler) + await listener.dispatch(b'payload') + await listener.close() + assert seen == [b'payload'] +@pytest.mark.skipif(not hasattr(os, 'mkfifo'), reason='named pipes unavailable') +async def test_pipe_listener_start_close(): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, 'sock.pipe') + listener = PipeListener(path) + events = [] + + async def handler(connection, data): + events.append((connection.path, data)) + + await listener.start(handler) + assert os.path.exists(path) + fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK) + try: + os.write(fd, b'payload') + await asyncio.sleep(0.05) + finally: + os.close(fd) + await listener.close() + assert events == [(path, b'payload')] \ No newline at end of file diff --git a/tests/test_prebuffered_reader_and_custom_pytest.py b/tests/test_prebuffered_reader_and_custom_pytest.py new file mode 100644 index 00000000..35127558 --- /dev/null +++ b/tests/test_prebuffered_reader_and_custom_pytest.py @@ -0,0 +1,24 @@ +import asyncio + +from tigrcorn.protocols.custom.adapters import adapt_inbound, adapt_outbound, adapt_scope +from tigrcorn.transports.inproc.channel import InProcChannel +from tigrcorn.transports.tcp.reader import PrebufferedReader + + +import pytest + +async def test_prebuffered_reader(): + reader = asyncio.StreamReader() + reader.feed_data(b'world\nrest') + reader.feed_eof() + wrapped = PrebufferedReader(reader, b'hello ') + assert await wrapped.readuntil(b'\n') == b'hello world\n' + assert await wrapped.read() == b'rest' +async def test_inproc_channel_and_custom_adapters(): + channel = InProcChannel(capacity=1) + await channel.send(b'data') + assert await channel.recv() == b'data' + scope = adapt_scope({'type': 'tigrcorn.stream'}) + assert 'tigrcorn.custom' in scope['extensions'] + assert adapt_inbound(b'a')['type'] == 'tigrcorn.stream.receive' + assert adapt_outbound(b'b')['type'] == 'tigrcorn.stream.send' \ No newline at end of file diff --git a/tests/test_provisional_all_surfaces_gap_bundle_pytest.py b/tests/test_provisional_all_surfaces_gap_bundle_pytest.py new file mode 100644 index 00000000..086eb1f3 --- /dev/null +++ b/tests/test_provisional_all_surfaces_gap_bundle_pytest.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = ROOT / 'docs/review/conformance/releases/0.3.6/release-0.3.6' +PROVISIONAL_ROOT = RELEASE_ROOT / 'tigrcorn-provisional-all-surfaces-gap-bundle' +STRICT_BOUNDARY = ( + ROOT / 'docs/review/conformance/certification_boundary.all_surfaces_independent.json' +) +EXPECTED_SCENARIOS = { + 'websocket-http11-server-websockets-client-permessage-deflate': 'websocket-permessage-deflate', + 'websocket-http2-server-h2-client-permessage-deflate': 'websocket-permessage-deflate', + 'websocket-http3-server-aioquic-client-permessage-deflate': 'websocket-permessage-deflate', + 'http11-connect-relay-curl-client': 'http-connect-relay', + 'http2-connect-relay-h2-client': 'http-connect-relay', + 'http3-connect-relay-aioquic-client': 'http-connect-relay', + 'http11-trailer-fields-curl-client': 'http-trailer-fields', + 'http2-trailer-fields-h2-client': 'http-trailer-fields', + 'http3-trailer-fields-aioquic-client': 'http-trailer-fields', + 'http11-content-coding-curl-client': 'http-content-coding', + 'http2-content-coding-curl-client': 'http-content-coding', + 'http3-content-coding-aioquic-client': 'http-content-coding', + 'tls-server-ocsp-validation-openssl-client': 'ocsp-revocation-validation', +} + + +def test_bundle_index_registers_relative_non_certifying_bundle() -> None: + payload = json.loads((RELEASE_ROOT / 'bundle_index.json').read_text(encoding='utf-8')) + provisional = payload['bundles']['provisional_all_surfaces_gap_bundle'] + assert provisional == ( + 'docs/review/conformance/releases/0.3.6/release-0.3.6/tigrcorn-provisional-all-surfaces-gap-bundle' + ) + assert 'all-surfaces gap bundle' in '\n'.join(payload['notes']) + + +def test_mapping_file_covers_all_strict_profile_scenarios() -> None: + mapping_payload = json.loads( + (PROVISIONAL_ROOT / 'scenario_mapping.json').read_text(encoding='utf-8') + ) + mappings = { + entry['provisional_id']: entry['source_local_vector'] + for entry in mapping_payload['mappings'] + } + assert mappings == EXPECTED_SCENARIOS + + +def test_each_provisional_result_is_explicitly_ineligible_for_release_gates() -> None: + for scenario_id, source_vector in EXPECTED_SCENARIOS.items(): + result = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'result.json').read_text(encoding='utf-8') + ) + metadata = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'provisional_metadata.json').read_text( + encoding='utf-8' + ) + ) + vector = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'source_local_vector.json').read_text( + encoding='utf-8' + ) + ) + assert result['passed'] + assert result['provisional_non_certifying_substitution'] + assert not result['release_gate_eligible'] + assert result['strict_profile_only'] + assert result['source_local_conformance_vector'] == source_vector + assert metadata['source_local_conformance_vector'] == source_vector + assert not metadata['release_gate_eligible'] + assert result['artifact_dir'].startswith( + 'docs/review/conformance/releases/0.3.6/release-0.3.6/' + ) + assert vector['name'] == source_vector + + +def test_strict_boundary_records_independent_tier_for_policy_bounded_rfcs() -> None: + payload = json.loads(STRICT_BOUNDARY.read_text(encoding='utf-8')) + assert not payload['authoritative'] + assert payload['derived_from'] == 'docs/review/conformance/certification_boundary.json' + for rfc in ('RFC 7692', 'RFC 9110 §9.3.6', 'RFC 9110 §6.5', 'RFC 9110 §8', 'RFC 6960'): + assert ( + payload['required_rfc_evidence'][rfc]['highest_required_evidence_tier'] + == 'independent_certification' + ) diff --git a/tests/test_provisional_flow_control_gap_bundle_pytest.py b/tests/test_provisional_flow_control_gap_bundle_pytest.py new file mode 100644 index 00000000..9ec83127 --- /dev/null +++ b/tests/test_provisional_flow_control_gap_bundle_pytest.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = ROOT / 'docs/review/conformance/releases/0.3.6/release-0.3.6' +PROVISIONAL_ROOT = RELEASE_ROOT / 'tigrcorn-provisional-flow-control-gap-bundle' +EXPECTED_SCENARIOS = { + 'http3-flow-control-public-client-post': 'http3-server-public-client-post', + 'http3-flow-control-public-client-post-retry': 'http3-server-public-client-post-retry', + 'http3-flow-control-public-client-post-zero-rtt': 'http3-server-public-client-post-zero-rtt', + 'http3-flow-control-public-client-post-migration': 'http3-server-public-client-post-migration', + 'http3-flow-control-public-client-post-goaway-qpack': 'http3-server-public-client-post-goaway-qpack', +} + + +def test_bundle_index_registers_relative_non_certifying_bundle() -> None: + payload = json.loads((RELEASE_ROOT / 'bundle_index.json').read_text(encoding='utf-8')) + provisional = payload['bundles']['provisional_flow_control_gap_bundle'] + assert provisional == ( + 'docs/review/conformance/releases/0.3.6/release-0.3.6/tigrcorn-provisional-flow-control-gap-bundle' + ) + assert 'flow-control gap bundle' in '\n'.join(payload['notes']) + + +def test_mapping_file_covers_all_flow_control_review_scenarios() -> None: + mapping_payload = json.loads( + (PROVISIONAL_ROOT / 'scenario_mapping.json').read_text(encoding='utf-8') + ) + mappings = { + entry['provisional_id']: entry['source_same_stack_id'] + for entry in mapping_payload['mappings'] + } + assert mappings == EXPECTED_SCENARIOS + + +def test_each_provisional_result_is_explicitly_ineligible_for_release_gates() -> None: + for scenario_id, source_id in EXPECTED_SCENARIOS.items(): + result = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'result.json').read_text(encoding='utf-8') + ) + metadata = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'provisional_metadata.json').read_text( + encoding='utf-8' + ) + ) + vectors = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'source_local_vectors.json').read_text( + encoding='utf-8' + ) + ) + assert result['passed'] + assert result['provisional_non_certifying_substitution'] + assert result['flow_control_review_only'] + assert not result['release_gate_eligible'] + assert result['source_same_stack_scenario'] == source_id + assert metadata['source_same_stack_scenario'] == source_id + assert not metadata['release_gate_eligible'] + assert vectors diff --git a/tests/test_provisional_http3_gap_bundle_pytest.py b/tests/test_provisional_http3_gap_bundle_pytest.py new file mode 100644 index 00000000..9dd9f265 --- /dev/null +++ b/tests/test_provisional_http3_gap_bundle_pytest.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = ROOT / 'docs/review/conformance/releases/0.3.6/release-0.3.6' +PROVISIONAL_ROOT = RELEASE_ROOT / 'tigrcorn-provisional-http3-gap-bundle' +EXPECTED_SCENARIOS = { + 'http3-server-aioquic-client-post': 'http3-server-public-client-post', + 'http3-server-aioquic-client-post-mtls': 'http3-server-public-client-post-mtls', + 'http3-server-aioquic-client-post-retry': 'http3-server-public-client-post-retry', + 'http3-server-aioquic-client-post-resumption': 'http3-server-public-client-post-resumption', + 'http3-server-aioquic-client-post-zero-rtt': 'http3-server-public-client-post-zero-rtt', + 'http3-server-aioquic-client-post-migration': 'http3-server-public-client-post-migration', + 'http3-server-aioquic-client-post-goaway-qpack': 'http3-server-public-client-post-goaway-qpack', + 'websocket-http3-server-aioquic-client': 'websocket-http3-server-public-client', + 'websocket-http3-server-aioquic-client-mtls': 'websocket-http3-server-public-client-mtls', +} + + +def test_bundle_index_registers_relative_non_certifying_bundle() -> None: + payload = json.loads((RELEASE_ROOT / 'bundle_index.json').read_text(encoding='utf-8')) + provisional = payload['bundles']['provisional_http3_gap_bundle'] + assert provisional == ( + 'docs/review/conformance/releases/0.3.6/release-0.3.6/tigrcorn-provisional-http3-gap-bundle' + ) + assert 'explicitly non-certifying' in '\n'.join(payload['notes']) + + +def test_mapping_file_covers_all_missing_scenarios() -> None: + mapping_payload = json.loads( + (PROVISIONAL_ROOT / 'scenario_mapping.json').read_text(encoding='utf-8') + ) + mappings = { + entry['provisional_id']: entry['source_same_stack_id'] + for entry in mapping_payload['mappings'] + } + assert mappings == EXPECTED_SCENARIOS + + +def test_each_provisional_result_is_explicitly_ineligible_for_release_gates() -> None: + for scenario_id, source_id in EXPECTED_SCENARIOS.items(): + result = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'result.json').read_text(encoding='utf-8') + ) + metadata = json.loads( + (PROVISIONAL_ROOT / scenario_id / 'provisional_metadata.json').read_text( + encoding='utf-8' + ) + ) + assert result['passed'] + assert result['provisional_non_certifying_substitution'] + assert not result['release_gate_eligible'] + assert result['source_same_stack_scenario'] == source_id + assert metadata['source_same_stack_scenario'] == source_id + assert not metadata['release_gate_eligible'] + assert result['artifact_dir'].startswith( + 'docs/review/conformance/releases/0.3.6/release-0.3.6/' + ) diff --git a/tests/test_public_api_cli_mtls_surface_pytest.py b/tests/test_public_api_cli_mtls_surface_pytest.py new file mode 100644 index 00000000..96670e1d --- /dev/null +++ b/tests/test_public_api_cli_mtls_surface_pytest.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from tigrcorn import api +from tigrcorn.cli import main + + +import pytest +async def _app(scope, receive, send): + return None + + + +async def test_serve_builds_config_with_client_certificate_options(): + config = object() + server = SimpleNamespace(serve_forever=AsyncMock(), request_shutdown=object()) + with ( + patch('tigrcorn.api.build_config', return_value=config) as build_config, + patch('tigrcorn.api.TigrCornServer', return_value=server) as server_type, + patch('tigrcorn.api.install_signal_handlers') as install_signal_handlers, + ): + await api.serve( + _app, + transport='udp', + protocols=['http3'], + http_versions=['3'], + ssl_certfile='server-cert.pem', + ssl_keyfile='server-key.pem', + ssl_ca_certs='client-ca.pem', + ssl_require_client_cert=True, + ) + + assert build_config.call_count == 1 + kwargs = build_config.call_args.kwargs + assert kwargs['transport'] == 'udp' + assert kwargs['protocols'] == ['http3'] + assert kwargs['http_versions'] == ['3'] + assert kwargs['ssl_certfile'] == 'server-cert.pem' + assert kwargs['ssl_keyfile'] == 'server-key.pem' + assert kwargs['ssl_ca_certs'] == 'client-ca.pem' + assert kwargs['ssl_require_client_cert'] + server_type.assert_called_once_with(app=_app, config=config) + install_signal_handlers.assert_called_once() + server.serve_forever.assert_awaited_once() + +async def test_serve_import_string_forwards_client_certificate_options(): + serve = AsyncMock() + with ( + patch('tigrcorn.api.load_app', return_value=_app) as load_app, + patch('tigrcorn.api.serve', new=serve), + ): + await api.serve_import_string( + 'tests.fixtures_pkg.appmod:app', + transport='udp', + protocols=['http3'], + http_versions=['3'], + ssl_certfile='server-cert.pem', + ssl_keyfile='server-key.pem', + ssl_ca_certs='client-ca.pem', + ssl_require_client_cert=True, + factory=True, + ) + + load_app.assert_called_once_with('tests.fixtures_pkg.appmod:app', factory=True) + serve.assert_awaited_once() + kwargs = serve.await_args.kwargs + assert kwargs['transport'] == 'udp' + assert kwargs['protocols'] == ['http3'] + assert kwargs['http_versions'] == ['3'] + assert kwargs['ssl_certfile'] == 'server-cert.pem' + assert kwargs['ssl_keyfile'] == 'server-key.pem' + assert kwargs['ssl_ca_certs'] == 'client-ca.pem' + assert kwargs['ssl_require_client_cert'] + +def test_run_with_import_string_forwards_client_certificate_options(): + real_asyncio_run = asyncio.run + serve_import_string = AsyncMock() + with ( + patch('tigrcorn.api.serve_import_string', new=serve_import_string), + patch('tigrcorn.api.asyncio.run', side_effect=real_asyncio_run), + ): + api.run( + 'tests.fixtures_pkg.appmod:app', + transport='udp', + protocols=['http3'], + http_versions=['3'], + ssl_certfile='server-cert.pem', + ssl_keyfile='server-key.pem', + ssl_ca_certs='client-ca.pem', + ssl_require_client_cert=True, + factory=True, + ) + + serve_import_string.assert_awaited_once() + args = serve_import_string.await_args.args + kwargs = serve_import_string.await_args.kwargs + assert args == ('tests.fixtures_pkg.appmod:app',) + assert kwargs['transport'] == 'udp' + assert kwargs['protocols'] == ['http3'] + assert kwargs['http_versions'] == ['3'] + assert kwargs['ssl_certfile'] == 'server-cert.pem' + assert kwargs['ssl_keyfile'] == 'server-key.pem' + assert kwargs['ssl_ca_certs'] == 'client-ca.pem' + assert kwargs['ssl_require_client_cert'] + assert kwargs['factory'] +def test_run_with_app_instance_forwards_client_certificate_options(): + real_asyncio_run = asyncio.run + serve = AsyncMock() + with ( + patch('tigrcorn.api.serve', new=serve), + patch('tigrcorn.api.asyncio.run', side_effect=real_asyncio_run), + ): + api.run( + _app, + transport='udp', + protocols=['http3'], + http_versions=['3'], + ssl_certfile='server-cert.pem', + ssl_keyfile='server-key.pem', + ssl_ca_certs='client-ca.pem', + ssl_require_client_cert=True, + ) + + serve.assert_awaited_once() + args = serve.await_args.args + kwargs = serve.await_args.kwargs + assert args == (_app,) + assert kwargs['transport'] == 'udp' + assert kwargs['protocols'] == ['http3'] + assert kwargs['http_versions'] == ['3'] + assert kwargs['ssl_certfile'] == 'server-cert.pem' + assert kwargs['ssl_keyfile'] == 'server-key.pem' + assert kwargs['ssl_ca_certs'] == 'client-ca.pem' + assert kwargs['ssl_require_client_cert'] +def test_cli_main_forwards_client_certificate_options(): + with patch('tigrcorn.cli.run_config') as run_config: + rc = main([ + 'tests.fixtures_pkg.appmod:app', + '--transport', 'udp', + '--protocol', 'http3', + '--http', '3', + '--ssl-certfile', 'server-cert.pem', + '--ssl-keyfile', 'server-key.pem', + '--ssl-ca-certs', 'client-ca.pem', + '--ssl-require-client-cert', + '--factory', + ]) + + assert rc == 0 + run_config.assert_called_once() + config = run_config.call_args.args[0] + listener = config.listeners[0] + assert config.app.target == 'tests.fixtures_pkg.appmod:app' + assert config.app.factory + assert listener.kind == 'udp' + assert listener.protocols == ['quic', 'http3'] + assert listener.http_versions == ['3'] + assert listener.ssl_certfile == 'server-cert.pem' + assert listener.ssl_keyfile == 'server-key.pem' + assert listener.ssl_ca_certs == 'client-ca.pem' + assert listener.ssl_require_client_cert diff --git a/tests/test_public_api_tls_cipher_surface_pytest.py b/tests/test_public_api_tls_cipher_surface_pytest.py new file mode 100644 index 00000000..29e28736 --- /dev/null +++ b/tests/test_public_api_tls_cipher_surface_pytest.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from tigrcorn.api import serve +from tigrcorn.cli import main +from tigrcorn.config.load import build_config + + +import pytest + +def test_serve_forwards_ssl_ciphers(): + async def _app(scope, receive, send): + return None + + fake_server = MagicMock() + fake_server.serve_forever = AsyncMock(return_value=None) + fake_server.request_shutdown = MagicMock() + with ( + patch('tigrcorn.api.build_config', wraps=build_config) as build_config_mock, + patch('tigrcorn.api.TigrCornServer', return_value=fake_server), + patch('tigrcorn.api.install_signal_handlers', return_value=None), + ): + asyncio.run(serve(_app, ssl_ciphers='TLS_AES_128_GCM_SHA256')) + + build_config_mock.assert_called_once() + assert build_config_mock.call_args.kwargs['ssl_ciphers'] == 'TLS_AES_128_GCM_SHA256' + fake_server.serve_forever.assert_awaited_once() + + + +def test_cli_main_forwards_ssl_ciphers(): + with patch('tigrcorn.cli.run_config') as run_config: + rc = main([ + 'tests.fixtures_pkg.appmod:app', + '--ssl-certfile', 'server-cert.pem', + '--ssl-keyfile', 'server-key.pem', + '--ssl-ciphers', 'TLS_AES_128_GCM_SHA256', + ]) + assert rc == 0 + run_config.assert_called_once() + config = run_config.call_args.args[0] + assert config.app.target == 'tests.fixtures_pkg.appmod:app' + assert config.tls.ciphers == 'TLS_AES_128_GCM_SHA256' + assert config.listeners[0].ssl_ciphers == 'TLS_AES_128_GCM_SHA256' diff --git a/tests/test_public_quic_tls_packaging_pytest.py b/tests/test_public_quic_tls_packaging_pytest.py new file mode 100644 index 00000000..0d36e317 --- /dev/null +++ b/tests/test_public_quic_tls_packaging_pytest.py @@ -0,0 +1,324 @@ +import asyncio +import os +import socket +import tempfile + +from tigrcorn.config.load import build_config +from tigrcorn.constants import DEFAULT_QUIC_SECRET +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.websocket.frames import decode_close_payload, encode_frame, parse_frame_bytes +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.transports.quic.handshake import QuicTlsHandshakeDriver, generate_self_signed_certificate + + +import pytest +def _frame_wire_length(data: bytes) -> int: + if len(data) < 2: + raise AssertionError('websocket frame is truncated') + masked = bool(data[1] & 0x80) + length = data[1] & 0x7F + pos = 2 + if length == 126: + if len(data) < pos + 2: + raise AssertionError('websocket frame is truncated') + length = int.from_bytes(data[pos:pos + 2], 'big') + pos += 2 + elif length == 127: + if len(data) < pos + 8: + raise AssertionError('websocket frame is truncated') + length = int.from_bytes(data[pos:pos + 8], 'big') + pos += 8 + if masked: + pos += 4 + total = pos + length + if len(data) < total: + raise AssertionError('websocket frame is truncated') + return total + + + +def test_client_initial_datagrams_meet_the_rfc_minimum_size(): + client = QuicConnection(is_client=True, secret=DEFAULT_QUIC_SECRET, local_cid=b'cli1cli1') + assert len(client.build_initial()) >= 1200 + cert_pem, _key_pem = generate_self_signed_certificate('server.example') + client.configure_handshake( + QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[cert_pem], + ) + ) + assert len(client.start_handshake()) >= 1200 + +async def test_http3_roundtrip_works_with_public_udp_tls_config(): + async def app(scope, receive, send): + assert scope['type'] == 'http' + assert scope['http_version'] == '3' + assert scope['path'] == '/h3' + event = await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'echo:' + event['body'], 'more_body': False}) + + cert_pem, key_pem = generate_self_signed_certificate('server.example') + with tempfile.TemporaryDirectory() as tmpdir: + certfile = os.path.join(tmpdir, 'server-cert.pem') + keyfile = os.path.join(tmpdir, 'server-key.pem') + with open(certfile, 'wb') as handle: + handle.write(cert_pem) + with open(keyfile, 'wb') as handle: + handle.write(key_pem) + + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + ssl_certfile=certfile, + ssl_keyfile=keyfile, + ) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].transport.get_extra_info('sockname')[1] + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=DEFAULT_QUIC_SECRET, local_cid=b'cli1cli1') + client.configure_handshake( + QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[cert_pem], + ) + ) + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.start_handshake(), ('127.0.0.1', port)) + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + for datagram in client.take_handshake_datagrams(): + sock.sendto(datagram, ('127.0.0.1', port)) + if client.handshake_driver is not None and client.handshake_driver.complete: + break + assert client.handshake_driver is not None + assert client.handshake_driver is not None + assert client.handshake_driver.complete + payload = core.get_request(0).encode_request( + [(b':method', b'POST'), (b':path', b'/h3'), (b':scheme', b'https')], + b'hello', + ) + sock.sendto(client.send_stream_data(0, payload, fin=True), ('127.0.0.1', port)) + + response_state = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if response_state is not None: + break + + assert response_state is not None + assert response_state is not None + assert (b':status' in b'200'), response_state.headers + assert response_state.body == b'echo:hello' + finally: + sock.close() + await server.close() + +async def test_http3_roundtrip_supports_quic_client_auth(): + async def app(scope, receive, send): + assert scope['type'] == 'http' + assert scope['http_version'] == '3' + event = await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'mtls:' + event['body'], 'more_body': False}) + + server_cert, server_key = generate_self_signed_certificate('server.example', purpose='server') + client_cert, client_key = generate_self_signed_certificate('client.example', purpose='client') + with tempfile.TemporaryDirectory() as tmpdir: + certfile = os.path.join(tmpdir, 'server-cert.pem') + keyfile = os.path.join(tmpdir, 'server-key.pem') + cafile = os.path.join(tmpdir, 'client-ca.pem') + with open(certfile, 'wb') as handle: + handle.write(server_cert) + with open(keyfile, 'wb') as handle: + handle.write(server_key) + with open(cafile, 'wb') as handle: + handle.write(client_cert) + + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + ssl_certfile=certfile, + ssl_keyfile=keyfile, + ssl_ca_certs=cafile, + ssl_require_client_cert=True, + ) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].transport.get_extra_info('sockname')[1] + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=DEFAULT_QUIC_SECRET, local_cid=b'cli3cli3') + client.configure_handshake( + QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[server_cert], + certificate_pem=client_cert, + private_key_pem=client_key, + ) + ) + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.start_handshake(), ('127.0.0.1', port)) + for _ in range(12): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + for datagram in client.take_handshake_datagrams(): + sock.sendto(datagram, ('127.0.0.1', port)) + if client.handshake_driver is not None and client.handshake_driver.complete: + break + assert client.handshake_driver is not None + assert client.handshake_driver is not None + assert client.handshake_driver.complete + payload = core.get_request(0).encode_request( + [(b':method', b'POST'), (b':path', b'/mtls'), (b':scheme', b'https')], + b'hello', + ) + sock.sendto(client.send_stream_data(0, payload, fin=True), ('127.0.0.1', port)) + + response_state = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if response_state is not None: + break + + assert response_state is not None + assert response_state is not None + assert (b':status' in b'200'), response_state.headers + assert response_state.body == b'mtls:hello' + finally: + sock.close() + await server.close() + +async def test_http3_websocket_roundtrip_works_with_public_udp_tls_config(): + seen = {} + + async def app(scope, receive, send): + assert scope['type'] == 'websocket' + assert scope['http_version'] == '3' + assert scope['path'] == '/chat' + assert scope['scheme'] == 'wss' + await receive() + await send({'type': 'websocket.accept', 'subprotocol': 'chat', 'headers': []}) + event = await receive() + seen['text'] = event['text'] + await send({'type': 'websocket.send', 'text': event['text']}) + await send({'type': 'websocket.close', 'code': 1000}) + + cert_pem, key_pem = generate_self_signed_certificate('server.example') + with tempfile.TemporaryDirectory() as tmpdir: + certfile = os.path.join(tmpdir, 'server-cert.pem') + keyfile = os.path.join(tmpdir, 'server-key.pem') + with open(certfile, 'wb') as handle: + handle.write(cert_pem) + with open(keyfile, 'wb') as handle: + handle.write(key_pem) + + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + ssl_certfile=certfile, + ssl_keyfile=keyfile, + ) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].transport.get_extra_info('sockname')[1] + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=DEFAULT_QUIC_SECRET, local_cid=b'cli2cli2') + client.configure_handshake( + QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[cert_pem], + ) + ) + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.start_handshake(), ('127.0.0.1', port)) + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + for datagram in client.take_handshake_datagrams(): + sock.sendto(datagram, ('127.0.0.1', port)) + if client.handshake_driver is not None and client.handshake_driver.complete: + break + assert client.handshake_driver is not None + assert client.handshake_driver is not None + assert client.handshake_driver.complete + payload = core.get_request(0).encode_request( + [ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'https'), + (b':path', b'/chat'), + (b':authority', b'server.example'), + (b'sec-websocket-version', b'13'), + (b'sec-websocket-protocol', b'chat'), + ], + encode_frame(0x1, b'hello-h3-tls', masked=True), + ) + sock.sendto(client.send_stream_data(0, payload, fin=False), ('127.0.0.1', port)) + + response_state = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if response_state is not None and response_state.ended: + break + + assert response_state is not None + assert response_state is not None + assert (b':status' in b'200'), response_state.headers + assert (b'sec-websocket-protocol' in b'chat'), response_state.headers + assert seen['text'] == 'hello-h3-tls' + first_len = _frame_wire_length(response_state.body) + message_frame = parse_frame_bytes(response_state.body[:first_len], expect_masked=False) + assert message_frame.payload.decode('utf-8') == 'hello-h3-tls' + close_frame = parse_frame_bytes(response_state.body[first_len:], expect_masked=False) + code, reason = decode_close_payload(close_frame.payload) + assert code == 1000 + assert reason == '' + finally: + sock.close() + await server.close() diff --git a/tests/test_qpack_completion_pytest.py b/tests/test_qpack_completion_pytest.py new file mode 100644 index 00000000..620ea385 --- /dev/null +++ b/tests/test_qpack_completion_pytest.py @@ -0,0 +1,192 @@ +import pytest + +from tigrcorn.protocols.http3.codec import ( + FRAME_HEADERS, + QPACK_DECODER_STREAM_ERROR, + QPACK_DECOMPRESSION_FAILED, + QPACK_ENCODER_STREAM_ERROR, + HTTP3ConnectionError, + encode_frame, +) +from tigrcorn.protocols.http3.qpack import ( + QpackBlocked, + QpackDecoder, + QpackDecoderStreamError, + QpackEncoder, + decode_qpack_integer, + encode_duplicate, + encode_insert_count_increment, + encode_insert_with_literal_name, + encode_section_ack, + encode_qpack_integer, + encode_set_dynamic_table_capacity, +) +from tigrcorn.protocols.http3.streams import ( + HTTP3ConnectionCore, + STREAM_TYPE_QPACK_DECODER, + STREAM_TYPE_QPACK_ENCODER, +) +from tigrcorn.utils.bytes import encode_quic_varint + + +def test_encoder_respects_blocked_stream_limit_until_entries_are_acknowledged(): + encoder = QpackEncoder(max_table_capacity=256, blocked_streams=1) + decoder = QpackDecoder(max_table_capacity=256, blocked_streams=1) + headers = [(b':method', b'GET'), (b'x-demo', b'value')] + + field1 = encoder.encode_field_section(headers, stream_id=0) + with pytest.raises(QpackBlocked): + decoder.decode_field_section(field1, stream_id=0) + + encoder_stream = encoder.take_encoder_stream_data() + assert encoder_stream + decoder.receive_encoder_stream(encoder_stream) + + field2 = encoder.encode_field_section(headers, stream_id=4) + encoded_required, _ = decode_qpack_integer(field2, 0, 8) + assert encoded_required == 0 + assert decoder.decode_field_section(field2, stream_id=4).headers == headers + + encoder.receive_decoder_stream(decoder.take_decoder_stream_data()) + field3 = encoder.encode_field_section(headers, stream_id=8) + section3 = decoder.decode_field_section(field3, stream_id=8) + assert section3.used_dynamic + assert section3.headers == headers + + +def test_encoder_avoids_unsafe_eviction_until_cancel_and_insert_ack(): + encoder = QpackEncoder(max_table_capacity=64, blocked_streams=2) + decoder = QpackDecoder(max_table_capacity=64, blocked_streams=2) + + field1 = encoder.encode_field_section([(b'x-a', b'1')], stream_id=0) + encoder_stream1 = encoder.take_encoder_stream_data() + assert encoder_stream1 + + with pytest.raises(QpackBlocked): + decoder.decode_field_section(field1, stream_id=0) + + field2 = encoder.encode_field_section([(b'x-b', b'2')], stream_id=4) + assert encoder.take_encoder_stream_data() == b'' + assert encoder.dynamic_table.lookup_dynamic_exact(b'x-b', b'2') is None + assert encoder.dynamic_table.insert_count == 1 + assert field2 is not None + + decoder.cancel_stream(0) + decoder.receive_encoder_stream(encoder_stream1) + encoder.receive_decoder_stream(decoder.take_decoder_stream_data()) + + field3 = encoder.encode_field_section([(b'x-b', b'2')], stream_id=8) + encoder_stream3 = encoder.take_encoder_stream_data() + assert encoder_stream3 + assert encoder.dynamic_table.lookup_dynamic_exact(b'x-b', b'2') is not None + decoder.receive_encoder_stream(encoder_stream3) + assert decoder.decode_field_section(field3, stream_id=8).headers == [(b'x-b', b'2')] + + +def test_extra_section_ack_is_decoder_stream_error(): + encoder = QpackEncoder(max_table_capacity=256, blocked_streams=4) + decoder = QpackDecoder(max_table_capacity=256, blocked_streams=4) + + headers = [(b':method', b'GET'), (b'x-demo', b'value')] + field = encoder.encode_field_section(headers, stream_id=0) + decoder.receive_encoder_stream(encoder.take_encoder_stream_data()) + decoder.decode_field_section(field, stream_id=0) + encoder.receive_decoder_stream(decoder.take_decoder_stream_data()) + + with pytest.raises(QpackDecoderStreamError): + encoder.receive_decoder_stream(encode_section_ack(0)) + + +def test_http3_request_backpressure_preserves_body_until_qpack_unblocks(): + sender = HTTP3ConnectionCore(role='client') + receiver = HTTP3ConnectionCore(role='server') + receiver_settings = receiver.encode_control_stream({1: 256, 6: 1200, 7: 1}) + assert sender.receive_stream_data(3, receiver_settings, fin=False) is None + + payload = sender.get_request(0).encode_request( + [(b':method', b'GET'), (b':path', b'/'), (b':scheme', b'https'), (b'x-demo', b'value')], + body=b'hello', + ) + request_state = receiver.receive_stream_data(0, payload, fin=True) + assert request_state is not None + assert not request_state.ready + assert request_state.blocked_header_sections + assert request_state.body == b'' + + encoder_stream = sender.take_encoder_stream_data() + assert encoder_stream + assert ( + receiver.receive_stream_data( + 2, + encode_quic_varint(STREAM_TYPE_QPACK_ENCODER) + encoder_stream, + fin=False, + ) + is None + ) + request_state = receiver.get_request(0).state + assert request_state.ready + assert request_state.body == b'hello' + assert (b'x-demo', b'value') in request_state.headers + + +def test_http3_maps_qpack_stream_and_field_section_errors(): + core = HTTP3ConnectionCore(role='server') + core.encode_control_stream({1: 256, 6: 1200, 7: 1}) + + with pytest.raises(HTTP3ConnectionError) as encoder_exc: + core.receive_stream_data( + 2, + encode_quic_varint(STREAM_TYPE_QPACK_ENCODER) + encode_duplicate(0), + fin=False, + ) + assert encoder_exc.value.error_code == QPACK_ENCODER_STREAM_ERROR + + with pytest.raises(HTTP3ConnectionError) as decoder_exc: + core.receive_stream_data( + 6, + encode_quic_varint(STREAM_TYPE_QPACK_DECODER) + + encode_insert_count_increment(1), + fin=False, + ) + assert decoder_exc.value.error_code == QPACK_DECODER_STREAM_ERROR + + invalid_field_section = b'\x00\x00' + encode_qpack_integer(0, 6, 0x80) + with pytest.raises(HTTP3ConnectionError) as field_exc: + core.receive_stream_data(0, encode_frame(FRAME_HEADERS, invalid_field_section), fin=True) + assert field_exc.value.error_code == QPACK_DECOMPRESSION_FAILED + + +def test_decoder_handles_post_base_reference(): + decoder = QpackDecoder(max_table_capacity=256, blocked_streams=8) + encoder_stream = b''.join( + [ + encode_set_dynamic_table_capacity(256), + encode_insert_with_literal_name(b'x-a', b'0'), + encode_insert_with_literal_name(b'x-b', b'1'), + encode_insert_with_literal_name(b'x-c', b'2'), + ] + ) + decoder.receive_encoder_stream(encoder_stream) + required_insert_count = 3 + encoded_required = (required_insert_count % (2 * decoder.dynamic_table.max_entries())) + 1 + prefix = encode_qpack_integer(encoded_required, 8, 0x00) + encode_qpack_integer(0, 7, 0x80) + post_base_indexed = encode_qpack_integer(0, 4, 0x10) + section = decoder.decode_field_section(prefix + post_base_indexed, stream_id=0) + assert section.headers == [(b'x-c', b'2')] + assert section.used_dynamic + + +def test_decoder_handles_required_insert_count_wraparound(): + decoder = QpackDecoder(max_table_capacity=64, blocked_streams=1) + encoder_stream = bytearray([*encode_set_dynamic_table_capacity(64)]) + for value in range(5): + encoder_stream.extend(encode_insert_with_literal_name(b'x', str(value).encode('ascii'))) + decoder.receive_encoder_stream(bytes(encoder_stream)) + + required_insert_count = 5 + encoded_required = (required_insert_count % (2 * decoder.dynamic_table.max_entries())) + 1 + prefix = encode_qpack_integer(encoded_required, 8, 0x00) + encode_qpack_integer(0, 7, 0x00) + dynamic_indexed = encode_qpack_integer(0, 6, 0x80) + section = decoder.decode_field_section(prefix + dynamic_indexed, stream_id=0) + assert section.headers == [(b'x', b'4')] + assert section.used_dynamic diff --git a/tests/test_quic_custom_server_pytest.py b/tests/test_quic_custom_server_pytest.py new file mode 100644 index 00000000..7a21593b --- /dev/null +++ b/tests/test_quic_custom_server_pytest.py @@ -0,0 +1,49 @@ +import asyncio +import socket + +from tigrcorn.config.load import build_config +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +import pytest + +async def test_quic_custom_scope_roundtrip(): + async def app(scope, receive, send): + assert scope['type'] == 'tigrcorn.quic' + event = await receive() + assert event['type'] == 'tigrcorn.stream.receive' + await send({'type': 'tigrcorn.stream.send', 'data': event['data'][::-1], 'more_data': False}) + + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + protocols=['quic'], + quic_secret=b'shared', + ) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].transport.get_extra_info('sockname')[1] + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli2') + loop = asyncio.get_running_loop() + try: + sock.sendto(client.send_stream_data(0, b'abcdef', fin=True), ('127.0.0.1', port)) + got_stream = None + for _ in range(3): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + got_stream = event + break + if got_stream is not None: + break + assert got_stream is not None + assert got_stream is not None + assert got_stream.data == b'fedcba' + finally: + sock.close() + await server.close() diff --git a/tests/test_quic_http3_additional_rfc_pytest.py b/tests/test_quic_http3_additional_rfc_pytest.py new file mode 100644 index 00000000..92f42abc --- /dev/null +++ b/tests/test_quic_http3_additional_rfc_pytest.py @@ -0,0 +1,99 @@ +import asyncio +import socket + +from tigrcorn.config.load import build_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.config.defaults import default_config +from tigrcorn.errors import ProtocolError +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.protocols.http3.handler import HTTP3DatagramHandler +from tigrcorn.protocols.http3.streams import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.transports.quic.streams import QuicStreamFrame, QuicStreamState + +import pytest + + +async def _start_h3_server(app): + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.transport.get_extra_info('sockname')[1] + return server, port + + + +def test_send_offsets_advance_across_packets(): + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'c1', remote_cid=b's1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b's1', remote_cid=b'c1') + events1 = server.receive_datagram(client.send_stream_data(0, b'hello', fin=False)) + events2 = server.receive_datagram(client.send_stream_data(0, b'world', fin=True)) + stream1 = [event for event in events1 if event.kind == 'stream'][0] + stream2 = [event for event in events2 if event.kind == 'stream'][0] + assert stream1.data == b'hello' + assert stream2.data == b'world' + assert stream2.fin +def test_out_of_order_stream_data_is_reassembled(): + state = QuicStreamState(0) + assert state.apply(QuicStreamFrame(stream_id=0, offset=5, data=b'world', fin=True)) == b'' + assert state.apply(QuicStreamFrame(stream_id=0, offset=0, data=b'hello')) == b'helloworld' + assert state.received_final + +async def test_server_control_stream_uses_server_unidirectional_id_and_stays_open(): + async def app(scope, receive, send): + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + server, port = await _start_h3_server(app) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1') + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + control_event = None + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + control_event = event + break + if control_event is not None: + break + assert control_event is not None + assert control_event is not None + assert control_event.stream_id == 3 + assert not (control_event.fin) + finally: + sock.close() + await server.close() + +async def test_connection_core_accepts_control_stream_on_server_uni_id(): + sender = HTTP3ConnectionCore() + receiver = HTTP3ConnectionCore() + payload = sender.encode_control_stream({1: 0, 6: 1200}) + assert receiver.receive_stream_data(3, payload, fin=False) is None + assert receiver.state.remote_control_stream_id == 3 + assert receiver.state.remote_settings == {1: 0, 6: 1200} +async def test_validate_request_headers_rejects_uppercase_field_name(): + async def app(scope, receive, send): + return None + + handler = HTTP3DatagramHandler( + app=app, + config=default_config(), + listener=ListenerConfig(kind='udp', host='127.0.0.1', port=1, protocols=['http3']), + access_logger=AccessLogger(configure_logging('warning'), enabled=False), + ) + with pytest.raises(ProtocolError): + handler._validate_request_headers([(b':method', b'GET'), (b':path', b'/'), (b':scheme', b'https'), (b'Content-Type', b'text/plain')]) diff --git a/tests/test_quic_http3_pytest.py b/tests/test_quic_http3_pytest.py new file mode 100644 index 00000000..66fedb95 --- /dev/null +++ b/tests/test_quic_http3_pytest.py @@ -0,0 +1,30 @@ + +from tigrcorn.protocols.http3 import HTTP3ConnectionCore, decode_field_section, encode_field_section, encode_frame, parse_frames +from tigrcorn.protocols.http3.codec import FRAME_HEADERS +from tigrcorn.transports.quic import QuicConnection + + +import pytest + +def test_quic_stream_roundtrip(): + client = QuicConnection(is_client=True, local_cid=b'c1', remote_cid=b's1', secret=b'shared-secret') + server = QuicConnection(is_client=False, local_cid=b's1', remote_cid=b'c1', secret=b'shared-secret') + data = client.send_stream_data(0, b'hello', fin=True) + events = server.receive_datagram(data) + stream_events = [event for event in events if event.kind == 'stream'] + assert len(stream_events) == 1 + assert stream_events[0].data == b'hello' + assert stream_events[0].fin +def test_http3_field_section_roundtrip(): + headers = [(b':method', b'GET'), (b':path', b'/'), (b'content-type', b'text/plain')] + encoded = encode_field_section(headers) + assert decode_field_section(encoded) == headers +def test_http3_request_stream(): + core = HTTP3ConnectionCore() + request = core.get_request(0) + payload = request.encode_request([(b':method', b'GET'), (b':path', b'/')], b'hello') + state = core.receive_stream_data(0, payload) + assert state is not None + assert state is not None + assert state.body == b'hello' + assert (b':method' in b'GET'), state.headers diff --git a/tests/test_quic_packets_rfc9000_pytest.py b/tests/test_quic_packets_rfc9000_pytest.py new file mode 100644 index 00000000..fa63431a --- /dev/null +++ b/tests/test_quic_packets_rfc9000_pytest.py @@ -0,0 +1,83 @@ + +import pytest +from tigrcorn.transports.quic.packets import ( + QuicLongHeaderPacket, + QuicLongHeaderType, + QuicRetryPacket, + QuicShortHeaderPacket, + QuicStatelessResetPacket, + QuicVersionNegotiationPacket, + decode_packet, + parse_stateless_reset, +) + + + +def test_initial_long_header_roundtrip(): + packet = QuicLongHeaderPacket( + packet_type=QuicLongHeaderType.INITIAL, + version=1, + destination_connection_id=b'clientcid', + source_connection_id=b'servercid', + token=b'token', + packet_number=b'\x12\x34', + payload=b'hello-world', + ) + decoded = decode_packet(packet.encode()) + assert isinstance(decoded, QuicLongHeaderPacket) + assert isinstance(decoded, QuicLongHeaderPacket) + assert decoded.packet_type == QuicLongHeaderType.INITIAL + assert decoded.destination_connection_id == b'clientcid' + assert decoded.source_connection_id == b'servercid' + assert decoded.token == b'token' + assert decoded.packet_number == b'\x12\x34' + assert decoded.payload == b'hello-world' + assert decoded.pn_offset == packet.pn_offset +def test_short_header_roundtrip(): + packet = QuicShortHeaderPacket( + destination_connection_id=b'12345678', + packet_number=b'\x01\x02', + payload=b'abc', + key_phase=True, + spin_bit=True, + ) + decoded = decode_packet(packet.encode(), destination_connection_id_length=8) + assert isinstance(decoded, QuicShortHeaderPacket) + assert isinstance(decoded, QuicShortHeaderPacket) + assert decoded.destination_connection_id == b'12345678' + assert decoded.packet_number == b'\x01\x02' + assert decoded.payload == b'abc' + assert decoded.key_phase + assert decoded.spin_bit +def test_version_negotiation_roundtrip(): + packet = QuicVersionNegotiationPacket( + destination_connection_id=b'cidA', + source_connection_id=b'cidB', + supported_versions=[1, 0x709A50C4], + ) + decoded = decode_packet(packet.encode()) + assert isinstance(decoded, QuicVersionNegotiationPacket) + assert isinstance(decoded, QuicVersionNegotiationPacket) + assert decoded.destination_connection_id == b'cidA' + assert decoded.source_connection_id == b'cidB' + assert decoded.supported_versions == [1, 0x709A50C4] +def test_retry_roundtrip_and_validation(): + original_dcid = bytes.fromhex('8394c8f03e515708') + packet = QuicRetryPacket( + version=1, + destination_connection_id=b'', + source_connection_id=bytes.fromhex('f067a5502a4262b5'), + token=b'token', + ) + encoded = packet.encode(original_destination_connection_id=original_dcid) + decoded = decode_packet(encoded) + assert isinstance(decoded, QuicRetryPacket) + assert isinstance(decoded, QuicRetryPacket) + assert decoded.token == b'token' + assert decoded.validate(original_destination_connection_id=original_dcid) +def test_stateless_reset_parse(): + token = b'0123456789abcdef' + packet = QuicStatelessResetPacket(stateless_reset_token=token, unpredictable_bits=b'xxxxx') + parsed = parse_stateless_reset(packet.encode(), expected_token=token) + assert parsed.stateless_reset_token == token + assert parsed.unpredictable_bits == b'xxxxx' \ No newline at end of file diff --git a/tests/test_quic_primitives_pytest.py b/tests/test_quic_primitives_pytest.py new file mode 100644 index 00000000..9c2d4534 --- /dev/null +++ b/tests/test_quic_primitives_pytest.py @@ -0,0 +1,52 @@ + +from tigrcorn.transports.quic import QuicConnection, derive_secret, generate_connection_id, protect_payload, unprotect_payload +from tigrcorn.transports.quic.datagrams import QuicDatagram, QuicHeader, QuicPacketType, decode_datagram, encode_datagram +from tigrcorn.transports.quic.flow import QuicFlowControl +from tigrcorn.transports.quic.streams import QuicAckFrame, QuicStreamFrame, decode_frame, encode_frame + + +import pytest + +def test_secret_and_payload_protection(): + key = derive_secret(b'seed', b'label') + payload = b'hello world' + protected = protect_payload(key, 3, payload) + assert protected != payload + assert unprotect_payload(key == 3, protected), payload +def test_datagram_roundtrip(): + header = QuicHeader(packet_type=QuicPacketType.SHORT, version=1, dst_cid=b'a', src_cid=b'b', packet_number=5) + datagram = QuicDatagram(header=header, payload=b'data', tag=b'tag') + decoded = decode_datagram(encode_datagram(datagram)) + assert decoded.header.packet_number == 5 + assert decoded.payload == b'data' + assert decoded.tag == b'tag' +def test_frame_roundtrip(): + frame = QuicStreamFrame(stream_id=0, data=b'abc', fin=True) + decoded, _ = decode_frame(encode_frame(frame)) + assert decoded.stream_id == 0 + assert decoded.data == b'abc' + assert decoded.fin +def test_ack_frame_with_ranges_roundtrip(): + frame = QuicAckFrame(largest_acked=10, ack_delay=3, first_ack_range=2, ack_ranges=[(1, 1), (0, 0)]) + decoded, _ = decode_frame(encode_frame(frame)) + assert decoded.largest_acked == 10 + assert decoded.ack_delay == 3 + assert decoded.first_ack_range == 2 + assert decoded.ack_ranges == [(1, 1), (0, 0)] +def test_flow_control(): + flow = QuicFlowControl(connection_window=10) + assert flow.can_send(0, 5) + flow.consume_send(0, 5) + assert not (flow.can_send(0, 6)) + flow.credit_connection(10) + flow.credit_stream(0, 10) + assert flow.can_send(0, 6) +def test_quic_connection_ack(): + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'c') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b's') + data = client.build_initial() + events = server.receive_datagram(data) + assert any(event.kind == 'ping' for event in events) + ack = server.acknowledge(0) + client_events = client.receive_datagram(ack) + assert any(event.kind == 'ack' for event in client_events) \ No newline at end of file diff --git a/tests/test_quic_recovery_live_runtime_integration_pytest.py b/tests/test_quic_recovery_live_runtime_integration_pytest.py new file mode 100644 index 00000000..c3fba1b8 --- /dev/null +++ b/tests/test_quic_recovery_live_runtime_integration_pytest.py @@ -0,0 +1,90 @@ +import time + +from tigrcorn.config.defaults import default_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.protocols.http3.handler import HTTP3DatagramHandler, HTTP3Session +from tigrcorn.transports.quic import QuicConnection, decode_packet +from tigrcorn.transports.quic.connection import PACKET_SPACE_APPLICATION +from tigrcorn.transports.quic.packets import QuicShortHeaderPacket + + +import pytest + +def _pair() -> tuple[QuicConnection, QuicConnection]: + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + return client, server + +def test_ack_driven_loss_detection_queues_retransmission(): + client, server = _pair() + packets = [client.send_stream_data(0, chunk, fin=False) for chunk in (b'a', b'b', b'c', b'd')] + server.receive_datagram(packets[2]) + server.receive_datagram(packets[3]) + acknowledgements = server.take_pending_datagrams() + assert len(acknowledgements) == 1 + client.receive_datagram(acknowledgements[0]) + retransmissions = client.take_pending_datagrams() + assert retransmissions + retransmit_packet = decode_packet(retransmissions[0], destination_connection_id_length=len(server.local_cid)) + assert isinstance(retransmit_packet, QuicShortHeaderPacket) + events = [] + for datagram in retransmissions: + events.extend(server.receive_datagram(datagram)) + assert any(event.kind == 'stream' and event.stream_id == 0 and event.data == b'a' for event in events) +def test_pto_expiry_generates_probe_packets(): + client, _server = _pair() + client.send_stream_data(0, b'probe-me', fin=False) + outstanding = client.recovery.spaces[PACKET_SPACE_APPLICATION].outstanding[0] + outstanding.sent_time = time.monotonic() - 2.0 + client._update_runtime_timers(now=time.monotonic()) + probes = client.drain_scheduled_datagrams() + assert probes + assert client.recovery.pto_count > 0 +def test_recovery_state_is_tracked_per_path_after_rebinding(): + stationary = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + mover = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + stationary.receive_datagram(mover.send_stream_data(0, b'first', fin=False), addr=('127.0.0.1', 1000)) + first_path = stationary.recovery + stationary.receive_datagram(mover.send_stream_data(4, b'second', fin=False), addr=('127.0.0.1', 1001)) + second_path = stationary.recovery + assert first_path is not second_path + assert ('127.0.0.1' in 1000), stationary._path_states + assert ('127.0.0.1' in 1001), stationary._path_states + +def test_handler_defers_and_flushes_recovery_blocked_datagrams(): + async def app(scope, receive, send): + raise AssertionError('app should not be invoked') + + handler = HTTP3DatagramHandler( + app=app, + config=default_config(), + listener=ListenerConfig(kind='udp', host='127.0.0.1', port=1, protocols=['http3'], quic_secret=b'shared'), + access_logger=AccessLogger(configure_logging('warning'), enabled=False), + ) + + class Endpoint: + def __init__(): + self.sent = [] + self.local_addr = ('127.0.0.1', 4433) + + def send(data, addr): + self.sent.append((data, addr)) + + endpoint = Endpoint() + session = HTTP3Session( + addr=('127.0.0.1', 50000), + quic=QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1'), + address_validated=True, + ) + session.quic.address_validated = True + raw = session.quic.send_stream_data(1, b'response', fin=True) + session.quic.recovery.congestion_window = 0 + handler._queue_or_send(session, raw, endpoint, session.addr) + assert endpoint.sent == [] + assert len(session.pending_outbound) == 1 + session.quic.recovery.congestion_window = 64_000 + session.quic.recovery.pacing_budget = 64_000 + handler._flush_pending_outbound(session, endpoint) + assert len(endpoint.sent) == 1 + assert session.pending_outbound == [] diff --git a/tests/test_quic_recovery_rfc9002_pytest.py b/tests/test_quic_recovery_rfc9002_pytest.py new file mode 100644 index 00000000..3b975dfa --- /dev/null +++ b/tests/test_quic_recovery_rfc9002_pytest.py @@ -0,0 +1,32 @@ +from tigrcorn.transports.quic.recovery import QuicLossRecovery + + +def test_rtt_and_pto_are_maintained() -> None: + recovery = QuicLossRecovery(max_datagram_size=1200) + recovery.on_packet_sent(1, 1200, sent_time=1.0) + recovery.on_ack_received([1], now=1.1) + assert recovery.rtt.smoothed_rtt > 0 + assert recovery.pto_timeout() > recovery.rtt.smoothed_rtt + assert recovery.pto_count == 0 + + +def test_loss_detection_and_cwnd_reduction() -> None: + recovery = QuicLossRecovery(max_datagram_size=1200) + for pn, sent in [(1, 1.0), (2, 1.01), (3, 1.02), (4, 1.03), (5, 1.04)]: + recovery.on_packet_sent(pn, 1200, sent_time=sent) + initial_cwnd = recovery.congestion_window + recovery.on_ack_received([5], now=1.20) + recovery.on_ack_received([5], now=1.20) + assert 1 not in recovery.outstanding + assert recovery.congestion_window <= initial_cwnd + + +def test_pto_backoff_increments() -> None: + recovery = QuicLossRecovery(max_datagram_size=1200) + recovery.on_packet_sent(1, 1200, sent_time=1.0) + first = recovery.next_pto_deadline(now=1.0) + recovery.on_pto_expired() + second = recovery.next_pto_deadline(now=1.0) + assert first is not None + assert second is not None + assert second > first diff --git a/tests/test_quic_rfc_upgrade_paths_pytest.py b/tests/test_quic_rfc_upgrade_paths_pytest.py new file mode 100644 index 00000000..1b09a23c --- /dev/null +++ b/tests/test_quic_rfc_upgrade_paths_pytest.py @@ -0,0 +1,87 @@ + +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.transports.quic.handshake import QuicTlsHandshakeDriver, TransportParameters, generate_self_signed_certificate +from tigrcorn.transports.quic.recovery import QuicLossRecovery + + +import pytest + +def test_transport_parameters_binary_roundtrip_preserves_extensions(): + params = TransportParameters( + max_data=123456, + max_stream_data_bidi_local=2222, + max_stream_data_bidi_remote=3333, + max_stream_data_uni=4444, + max_streams_bidi=10, + max_streams_uni=11, + idle_timeout=45_000, + active_connection_id_limit=6, + max_udp_payload_size=1400, + ack_delay_exponent=4, + max_ack_delay=31, + disable_active_migration=True, + initial_source_connection_id=b'cli-cid', + original_destination_connection_id=b'orig-cid', + stateless_reset_token=b'0123456789abcdef', + unknown_parameters={0x40: b'opaque'}, + ) + decoded = TransportParameters.from_bytes(params.to_bytes()) + assert decoded.max_data == 123456 + assert decoded.max_stream_data_uni == 4444 + assert decoded.active_connection_id_limit == 6 + assert decoded.disable_active_migration + assert decoded.initial_source_connection_id == b'cli-cid' + assert decoded.original_destination_connection_id == b'orig-cid' + assert decoded.stateless_reset_token == b'0123456789abcdef' + assert decoded.unknown_parameters[0x40] == b'opaque' +def test_server_handshake_flight_is_split_across_initial_and_handshake_spaces(): + cert_pem, key_pem = generate_self_signed_certificate('server.example') + client = QuicTlsHandshakeDriver(is_client=True, server_name='server.example', trusted_certificates=[cert_pem]) + server = QuicTlsHandshakeDriver(is_client=False, server_name='server.example', certificate_pem=cert_pem, private_key_pem=key_pem) + server_flight = server.receive(client.initiate()) + flights = server.outbound_flights(server_flight) + assert [flight.packet_space for flight in flights] == ['initial', 'handshake'] +def test_crypto_reassembly_waits_for_missing_prefix(): + cert_pem, key_pem = generate_self_signed_certificate('server.example') + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + client.configure_handshake(QuicTlsHandshakeDriver(is_client=True, server_name='server.example', trusted_certificates=[cert_pem])) + server.configure_handshake(QuicTlsHandshakeDriver(is_client=False, server_name='server.example', certificate_pem=cert_pem, private_key_pem=key_pem)) + payload = client.handshake_driver.initiate() + midpoint = len(payload) // 2 + server.receive_datagram(client.send_crypto_data(payload[midpoint:], offset=midpoint, packet_space='initial')) + assert server.take_handshake_datagrams() == [] + server.receive_datagram(client.send_crypto_data(payload[:midpoint], offset=0, packet_space='initial')) + assert server.take_handshake_datagrams() +def test_key_update_allows_post_update_short_packets(): + left = QuicConnection(is_client=True, secret=b'shared-secret', local_cid=b'c1c1c1c1', remote_cid=b's1s1s1s1') + right = QuicConnection(is_client=False, secret=b'shared-secret', local_cid=b's1s1s1s1', remote_cid=b'c1c1c1c1') + events = right.receive_datagram(left.send_stream_data(0, b'before', fin=True)) + assert any(event.kind == 'stream' and event.data == b'before' for event in events) + left.initiate_key_update() + events = right.receive_datagram(left.send_stream_data(4, b'after', fin=True)) + assert any(event.kind == 'stream' and event.data == b'after' for event in events) + +def test_packet_number_spaces_are_isolated(): + recovery = QuicLossRecovery(max_datagram_size=1200) + recovery.on_packet_sent(1, 1200, sent_time=0.0, packet_space='initial') + recovery.on_packet_sent(1, 1200, sent_time=0.0, packet_space='application') + recovery.on_ack_received([1], now=0.1, packet_space='application') + assert 1 in recovery.spaces['initial'].outstanding + assert 1 not in recovery.spaces['application'].outstanding +def test_persistent_congestion_collapses_congestion_window(): + recovery = QuicLossRecovery(max_datagram_size=1200) + recovery.on_packet_sent(0, 1200, sent_time=0.0) + recovery.on_ack_received([0], now=0.1) + for packet_number, sent_time in [(1, 0.2), (2, 1.0), (3, 2.0), (4, 3.0)]: + recovery.on_packet_sent(packet_number, 1200, sent_time=sent_time, packet_space='application') + recovery.on_ack_received([4], now=3.2, packet_space='application') + assert recovery.persistent_congestion + assert recovery.congestion_window == recovery.minimum_congestion_window +def test_pacing_budget_replenishes_after_time_passes(): + recovery = QuicLossRecovery(max_datagram_size=1200) + recovery.on_packet_sent(1, 1200, sent_time=0.0) + depleted_budget = recovery.pacing_budget + replenished_budget = recovery.available_send_budget(now=1.0) + assert depleted_budget < recovery.congestion_window + assert replenished_budget > depleted_budget diff --git a/tests/test_quic_runtime_additions_pytest.py b/tests/test_quic_runtime_additions_pytest.py new file mode 100644 index 00000000..f49251c6 --- /dev/null +++ b/tests/test_quic_runtime_additions_pytest.py @@ -0,0 +1,82 @@ +import asyncio + +from tigrcorn.config.defaults import default_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.protocols.http3.handler import HTTP3DatagramHandler +from tigrcorn.protocols.http3.streams import HTTP3ConnectionCore +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.transports.quic.handshake import QuicTlsHandshakeDriver, generate_self_signed_certificate +from tigrcorn.transports.udp.packet import UDPPacket + +import pytest + + + +def test_connection_id_issue_and_retire_roundtrip(): + left = QuicConnection(is_client=False, secret=b'shared', local_cid=b'servcid1', remote_cid=b'clicid01') + right = QuicConnection(is_client=True, secret=b'shared', local_cid=b'clicid01', remote_cid=b'servcid1') + sequence, cid, token, raw = left.issue_connection_id() + events = right.receive_datagram(raw) + assert any(event.kind == 'new_connection_id' for event in events) + assert sequence in right.peer_connection_ids + retire = right.retire_connection_id(sequence) + left.receive_datagram(retire) + assert sequence not in left.issued_connection_ids +def test_handshake_driver_integrates_with_connection_crypto_frames(): + cert_pem, key_pem = generate_self_signed_certificate('server.example') + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + client.configure_handshake(QuicTlsHandshakeDriver(is_client=True, server_name='server.example', trusted_certificates=[cert_pem])) + server.configure_handshake(QuicTlsHandshakeDriver(is_client=False, server_name='server.example', certificate_pem=cert_pem, private_key_pem=key_pem)) + initial = client.start_handshake() + server_events = server.receive_datagram(initial) + assert any(event.kind == 'crypto' for event in server_events) + server_outbound = server.take_handshake_datagrams() + assert server_outbound + client_events = [] + for raw in server_outbound: + client_events.extend(client.receive_datagram(raw)) + assert any(event.kind == 'handshake_complete' for event in client_events) + client_outbound = client.take_handshake_datagrams() + assert client_outbound + server_events = [] + for raw in client_outbound: + server_events.extend(server.receive_datagram(raw)) + assert any(event.kind == 'handshake_complete' for event in server_events) + assert client.address_validated + assert server.address_validated + +async def test_http3_runtime_applies_anti_amplification_limit(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'x' * 12000, 'more_body': False}) + + handler = HTTP3DatagramHandler( + app=app, + config=default_config(), + listener=ListenerConfig(kind='udp', host='127.0.0.1', port=1, protocols=['http3'], quic_secret=b'shared'), + access_logger=AccessLogger(configure_logging('warning'), enabled=False), + ) + + class Endpoint: + def __init__(): + self.sent = [] + self.local_addr = ('127.0.0.1', 4433) + def send(data, addr): + self.sent.append((data, addr)) + + endpoint = Endpoint() + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1') + await handler.handle_packet(UDPPacket(data=client.build_initial(), addr=('127.0.0.1', 50000)), endpoint) + core = HTTP3ConnectionCore() + for raw, _addr in endpoint.sent: + for event in client.receive_datagram(raw): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + endpoint.sent.clear() + request_payload = core.get_request(0).encode_request([(b':method', b'POST'), (b':path', b'/big'), (b':scheme', b'https')], b'hi') + await handler.handle_packet(UDPPacket(data=client.send_stream_data(0, request_payload, fin=True), addr=('127.0.0.1', 50000)), endpoint) + session = next(iter(handler.sessions.values())) + assert sum(len(raw) for raw, _ in endpoint.sent) <= session.bytes_received * 3 \ No newline at end of file diff --git a/tests/test_quic_stream_flow_state_machine_pytest.py b/tests/test_quic_stream_flow_state_machine_pytest.py new file mode 100644 index 00000000..35fd27f3 --- /dev/null +++ b/tests/test_quic_stream_flow_state_machine_pytest.py @@ -0,0 +1,134 @@ +import time + +from tigrcorn.errors import ProtocolError +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.transports.quic.connection import PACKET_SPACE_APPLICATION +from tigrcorn.transports.quic.handshake import TransportParameters +from tigrcorn.transports.quic.streams import QuicResetStreamFrame + + +import pytest + +def _pair() -> tuple[QuicConnection, QuicConnection]: + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + return client, server + +def test_peer_initiated_stream_limit_is_enforced(): + client, server = _pair() + server.streams.configure_local_initial_limits(bidirectional=1, unidirectional=0) + server.receive_datagram(client.send_stream_data(0, b'a', fin=True)) + with pytest.raises(ProtocolError): + server.receive_datagram(client.send_stream_data(4, b'b', fin=True)) + +def test_unidirectional_stream_role_is_enforced(): + client, server = _pair() + server.receive_datagram(client.send_stream_data(2, b'hello', fin=True)) + with pytest.raises(ProtocolError): + server.send_stream_data(2, b'reply') + +def test_distinct_bidi_local_bidi_remote_and_uni_send_windows_are_applied(): + # peer-initiated bidirectional stream uses peer bidi_local credit + client, server = _pair() + server.streams.configure_peer_initial_limits(bidirectional=10, unidirectional=10) + server.flow.configure_peer_initial_limits( + max_data=128, + max_stream_data_bidi_local=3, + max_stream_data_bidi_remote=7, + max_stream_data_uni=11, + ) + server.receive_datagram(client.send_stream_data(0, b'x', fin=False)) + with pytest.raises(ProtocolError): + server.send_stream_data(0, b'abcd') + assert isinstance(server.send_stream_data(0, b'abc'), bytes) + # local-initiated bidirectional stream uses peer bidi_remote credit + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + server.streams.configure_peer_initial_limits(bidirectional=10, unidirectional=10) + server.flow.configure_peer_initial_limits( + max_data=128, + max_stream_data_bidi_local=3, + max_stream_data_bidi_remote=7, + max_stream_data_uni=11, + ) + with pytest.raises(ProtocolError): + server.send_stream_data(1, b'abcdefgh') + assert isinstance(server.send_stream_data(1, b'abcdefg'), bytes) + # local-initiated unidirectional stream uses peer uni credit + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + server.streams.configure_peer_initial_limits(bidirectional=10, unidirectional=10) + server.flow.configure_peer_initial_limits( + max_data=128, + max_stream_data_bidi_local=3, + max_stream_data_bidi_remote=7, + max_stream_data_uni=11, + ) + with pytest.raises(ProtocolError): + server.send_stream_data(3, b'abcdefghijkl') + assert isinstance(server.send_stream_data(3, b'abcdefghijk'), bytes) +def test_receive_side_flow_control_rejects_stream_data_beyond_local_limits(): + client, server = _pair() + server.flow.configure_local_initial_limits( + max_data=4, + max_stream_data_bidi_local=4, + max_stream_data_bidi_remote=4, + max_stream_data_uni=4, + ) + with pytest.raises(ProtocolError): + server.receive_datagram(client.send_stream_data(0, b'abcde', fin=False)) + +def test_reset_stream_final_size_counts_toward_receive_flow_control(): + client, server = _pair() + server.flow.configure_local_initial_limits( + max_data=4, + max_stream_data_bidi_local=10, + max_stream_data_bidi_remote=10, + max_stream_data_uni=10, + ) + server.receive_datagram(client.send_stream_data(0, b'abc', fin=False)) + oversized_reset = client.send_frames( + [QuicResetStreamFrame(stream_id=0, error_code=1, final_size=5)], + packet_space=PACKET_SPACE_APPLICATION, + ) + with pytest.raises(ProtocolError): + server.receive_datagram(oversized_reset) + +def test_stop_sending_queues_reset_stream(): + client, server = _pair() + server.receive_datagram(client.send_stream_data(0, b'abc', fin=False)) + stop_events = client.receive_datagram(server.stop_sending(0, 99)) + assert any(event.kind == 'stop_sending' and event.stream_id == 0 for event in stop_events) + pending = client.take_pending_datagrams() + assert len(pending) == 1 + reset_events = server.receive_datagram(pending[0]) + assert any(event.kind == 'reset_stream' and event.stream_id == 0 for event in reset_events) +def test_closed_peer_stream_recycles_max_streams_credit(): + client, server = _pair() + server.streams.configure_local_initial_limits(bidirectional=1, unidirectional=0) + server.receive_datagram(client.send_stream_data(0, b'request', fin=True)) + server.send_stream_data(0, b'response', fin=True) + pending = server.take_pending_datagrams() + assert len(pending) == 1 + events = client.receive_datagram(pending[0]) + max_stream_events = [event for event in events if event.kind == 'max_streams'] + assert len(max_stream_events) == 1 + assert max_stream_events[0].detail.bidirectional + assert max_stream_events[0].detail.maximum_streams == 2 +def test_ack_delay_exponent_is_used_when_encoding_ack_frames(): + _client, server = _pair() + server.local_transport_parameters = TransportParameters(ack_delay_exponent=4) + server._mark_received(PACKET_SPACE_APPLICATION, 7) + server._space_state(PACKET_SPACE_APPLICATION).received_packet_times[7] = time.monotonic() - 0.032 + ack = server._build_ack_frame(PACKET_SPACE_APPLICATION) + assert ack.ack_delay >= 1500 +def test_credit_connection_and_stream_expand_local_receive_limits_only(): + _client, server = _pair() + send_limit = server.flow.connection_window + local_limit = server.flow.local_connection_window + server.credit_connection(10) + assert server.flow.connection_window == send_limit + assert server.flow.local_connection_window == local_limit + 10 + stream_send_limit = server.flow.window_for_stream(0) + stream_recv_limit = server.flow.receive_window_for_stream(0) + server.credit_stream(0, 5) + assert server.flow.window_for_stream(0) == stream_send_limit + assert server.flow.receive_window_for_stream(0) == stream_recv_limit + 5 diff --git a/tests/test_quic_tls_external_interop_regressions_pytest.py b/tests/test_quic_tls_external_interop_regressions_pytest.py new file mode 100644 index 00000000..2a77e94c --- /dev/null +++ b/tests/test_quic_tls_external_interop_regressions_pytest.py @@ -0,0 +1,31 @@ +from __future__ import annotations + + +from cryptography import x509 + +from tigrcorn.security.tls13.handshake import QuicTlsHandshakeDriver +from tigrcorn.transports.quic.connection import QuicConnection +from tigrcorn.transports.quic.packets import decode_packet + + +import pytest + +def test_tls_certificate_entries_are_der_encoded(): + driver = QuicTlsHandshakeDriver(is_client=False, server_name='localhost') + entries = driver._certificate_entry_chain() + assert entries + certificate = x509.load_der_x509_certificate(entries[0].cert_data) + assert certificate.subject.rfc4514_string() == 'CN=localhost' + assert not (entries[0].cert_data.startswith(b'-----BEGIN CERTIFICATE-----')) +def test_zero_length_remote_cid_is_preserved_when_encoding_initial(): + connection = QuicConnection( + is_client=False, + secret=b'shared-secret', + local_cid=b'servercid', + remote_cid=b'', + ) + raw = connection._encode_initial([]) + packet = decode_packet(raw) + assert packet.destination_connection_id == b'' + assert packet.source_connection_id == b'servercid' + assert connection.remote_cid == b'' \ No newline at end of file diff --git a/tests/test_quic_tls_handshake_driver_pytest.py b/tests/test_quic_tls_handshake_driver_pytest.py new file mode 100644 index 00000000..4e8605e9 --- /dev/null +++ b/tests/test_quic_tls_handshake_driver_pytest.py @@ -0,0 +1,103 @@ +import pytest + +from tigrcorn.security.tls13 import CIPHER_TLS_AES_256_GCM_SHA384 +from tigrcorn.transports.quic.handshake import QuicTlsHandshakeDriver, TlsAlertError, TransportParameters, generate_self_signed_certificate + + +def test_custom_crypto_handshake_progression_and_certificate_verification() -> None: + cert_pem, key_pem = generate_self_signed_certificate("server.example") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + transport_parameters=TransportParameters(max_data=12345, active_connection_id_limit=6), + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=cert_pem, + private_key_pem=key_pem, + transport_parameters=TransportParameters(max_data=99999, active_connection_id_limit=8), + ) + client_hello = client.initiate() + server_flight = server.receive(client_hello) + client_finished = client.receive(server_flight) + assert client.complete + assert not server.complete + assert client.peer_transport_parameters.max_data == 99999 + assert client.peer_transport_parameters.active_connection_id_limit == 8 + server.receive(client_finished) + assert server.complete + assert server.peer_transport_parameters.max_data == 12345 + assert server.peer_transport_parameters.active_connection_id_limit == 6 + + +def test_untrusted_certificate_is_rejected() -> None: + cert_pem, key_pem = generate_self_signed_certificate("server.example") + other_cert, _other_key = generate_self_signed_certificate("other.example") + client = QuicTlsHandshakeDriver(is_client=True, trusted_certificates=[other_cert]) + server = QuicTlsHandshakeDriver(is_client=False, certificate_pem=cert_pem, private_key_pem=key_pem) + with pytest.raises(Exception): + client.receive(server.receive(client.initiate())) + + +def test_handshake_can_negotiate_tls_aes_256_gcm_sha384() -> None: + cert_pem, key_pem = generate_self_signed_certificate("server.example") + client = QuicTlsHandshakeDriver(is_client=True, server_name="server.example", trusted_certificates=[cert_pem]) + server = QuicTlsHandshakeDriver(is_client=False, server_name="server.example", certificate_pem=cert_pem, private_key_pem=key_pem) + server_flight = server.receive(client.initiate()) + client_finished = client.receive(server_flight) + server.receive(client_finished) + assert client._selected_cipher_suite == CIPHER_TLS_AES_256_GCM_SHA384 + assert server._selected_cipher_suite == CIPHER_TLS_AES_256_GCM_SHA384 + assert client.cipher_parameters.hash_name == "sha384" + assert server.cipher_parameters.key_length == 32 + + +def test_mutual_tls_handshake_completes_when_client_certificate_is_supplied() -> None: + server_cert, server_key = generate_self_signed_certificate("server.example", purpose="server") + client_cert, client_key = generate_self_signed_certificate("client.example", purpose="client") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[server_cert], + certificate_pem=client_cert, + private_key_pem=client_key, + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=server_cert, + private_key_pem=server_key, + trusted_certificates=[client_cert], + require_client_certificate=True, + ) + server_flight = server.receive(client.initiate()) + client_finished = client.receive(server_flight) + server.receive(client_finished) + assert client.complete + assert server.complete + assert server.peer_certificate_pem == client_cert + + +def test_mutual_tls_rejects_a_missing_client_certificate() -> None: + server_cert, server_key = generate_self_signed_certificate("server.example", purpose="server") + client_cert, _client_key = generate_self_signed_certificate("client.example", purpose="client") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[server_cert], + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=server_cert, + private_key_pem=server_key, + trusted_certificates=[client_cert], + require_client_certificate=True, + ) + server_flight = server.receive(client.initiate()) + client_finished = client.receive(server_flight) + with pytest.raises(TlsAlertError) as ctx: + server.receive(client_finished) + assert ctx.value.description == 116 diff --git a/tests/test_quic_tls_rfc9001_pytest.py b/tests/test_quic_tls_rfc9001_pytest.py new file mode 100644 index 00000000..dd6472a6 --- /dev/null +++ b/tests/test_quic_tls_rfc9001_pytest.py @@ -0,0 +1,137 @@ +from tigrcorn.transports.quic.crypto import ( + aes_header_protection_mask, + compute_retry_integrity_tag, + derive_initial_packet_protection_keys, + derive_initial_secret, + protect_quic_packet, + update_quic_secret, +) + + +def test_initial_secret_and_keys_match_rfc9001_appendix_a(): + cid = bytes.fromhex('8394c8f03e515708') + assert ( + derive_initial_secret(cid).hex() + == '7db5df06e7a69e432496adedb00851923595221596ae2ae9fb8115c1e9ed0a44' + ) + client, server = derive_initial_packet_protection_keys(cid) + assert ( + client.secret.hex() + == 'c00cf151ca5be075ed0ebfb5c80323c42d6b7db67881289af4008f1f6c357aea' + ) + assert client.key.hex() == '1f369613dd76d5467730efcbe3b1a22d' + assert client.iv.hex() == 'fa044b2f42a3fd3b46fb255c' + assert client.hp.hex() == '9f50449e04a0e810283a1e9933adedd2' + assert ( + server.secret.hex() + == '3c199828fd139efd216c155ad844cc81fb82fa8d7446fa7d78be803acdda951b' + ) + assert server.key.hex() == 'cf3a5331653c364c88f0f379b6067e37' + assert server.iv.hex() == '0ac1493ca1905853b0bba03e' + assert server.hp.hex() == 'c206b8d9b9f0f37644430b490eeaa314' + + +def test_header_protection_mask_matches_rfc9001_appendix_a2(): + cid = bytes.fromhex('8394c8f03e515708') + client, _server = derive_initial_packet_protection_keys(cid) + sample = bytes.fromhex('d1b1c98dd7689fb8ec11d242b123dc9b') + assert aes_header_protection_mask(client.hp, sample).hex() == '437b9aec36' + + +def test_client_initial_packet_matches_rfc9001_appendix_a2(): + cid = bytes.fromhex('8394c8f03e515708') + client, _server = derive_initial_packet_protection_keys(cid) + payload_prefix = bytes.fromhex( + '060040f1010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e868' + '04fe3a47f06a2b69484c00000413011302010000c000000010000e00000b6578' + '616d706c652e636f6dff01000100000a00080006001d00170018001000070005' + '04616c706e000500050100000000003300260024001d00209370b2c9caa47fba' + 'baf4559fedba753de171fa71f50f1ce15d43e994ec74d748002b000302030400' + '0d0010000e0403050306030203080408050806002d00020101001c0002400100' + '3900320408ffffffffffffffff05048000ffff07048000ffff08011001048000' + '75300901100f088394c8f03e51570806048000ffff' + ) + payload = payload_prefix + (b'\x00' * 917) + header = bytes.fromhex('c300000001088394c8f03e5157080000449e00000002') + packet = protect_quic_packet(header, payload, packet_number=2, pn_offset=18, keys=client) + expected = bytes.fromhex( + 'c000000001088394c8f03e5157080000449e7b9aec34d1b1c98dd7689fb8ec11' + 'd242b123dc9bd8bab936b47d92ec356c0bab7df5976d27cd449f63300099f399' + '1c260ec4c60d17b31f8429157bb35a1282a643a8d2262cad67500cadb8e7378c' + '8eb7539ec4d4905fed1bee1fc8aafba17c750e2c7ace01e6005f80fcb7df6212' + '30c83711b39343fa028cea7f7fb5ff89eac2308249a02252155e2347b63d58c5' + '457afd84d05dfffdb20392844ae812154682e9cf012f9021a6f0be17ddd0c208' + '4dce25ff9b06cde535d0f920a2db1bf362c23e596d11a4f5a6cf3948838a3aec' + '4e15daf8500a6ef69ec4e3feb6b1d98e610ac8b7ec3faf6ad760b7bad1db4ba3' + '485e8a94dc250ae3fdb41ed15fb6a8e5eba0fc3dd60bc8e30c5c4287e53805db' + '059ae0648db2f64264ed5e39be2e20d82df566da8dd5998ccabdae053060ae6c' + '7b4378e846d29f37ed7b4ea9ec5d82e7961b7f25a9323851f681d582363aa5f8' + '9937f5a67258bf63ad6f1a0b1d96dbd4faddfcefc5266ba6611722395c906556' + 'be52afe3f565636ad1b17d508b73d8743eeb524be22b3dcbc2c7468d54119c74' + '68449a13d8e3b95811a198f3491de3e7fe942b330407abf82a4ed7c1b311663a' + 'c69890f4157015853d91e923037c227a33cdd5ec281ca3f79c44546b9d90ca00' + 'f064c99e3dd97911d39fe9c5d0b23a229a234cb36186c4819e8b9c5927726632' + '291d6a418211cc2962e20fe47feb3edf330f2c603a9d48c0fcb5699dbfe58964' + '25c5bac4aee82e57a85aaf4e2513e4f05796b07ba2ee47d80506f8d2c25e50fd' + '14de71e6c418559302f939b0e1abd576f279c4b2e0feb85c1f28ff18f58891ff' + 'ef132eef2fa09346aee33c28eb130ff28f5b766953334113211996d20011a198' + 'e3fc433f9f2541010ae17c1bf202580f6047472fb36857fe843b19f5984009dd' + 'c324044e847a4f4a0ab34f719595de37252d6235365e9b84392b061085349d73' + '203a4a13e96f5432ec0fd4a1ee65accdd5e3904df54c1da510b0ff20dcc0c77f' + 'cb2c0e0eb605cb0504db87632cf3d8b4dae6e705769d1de354270123cb11450e' + 'fc60ac47683d7b8d0f811365565fd98c4c8eb936bcab8d069fc33bd801b03ade' + 'a2e1fbc5aa463d08ca19896d2bf59a071b851e6c239052172f296bfb5e724047' + '90a2181014f3b94a4e97d117b438130368cc39dbb2d198065ae3986547926cd2' + '162f40a29f0c3c8745c0f50fba3852e566d44575c29d39a03f0cda721984b6f4' + '40591f355e12d439ff150aab7613499dbd49adabc8676eef023b15b65bfc5ca0' + '6948109f23f350db82123535eb8a7433bdabcb909271a6ecbcb58b936a88cd4e' + '8f2e6ff5800175f113253d8fa9ca8885c2f552e657dc603f252e1a8e308f76f0' + 'be79e2fb8f5d5fbbe2e30ecadd220723c8c0aea8078cdfcb3868263ff8f09400' + '54da48781893a7e49ad5aff4af300cd804a6b6279ab3ff3afb64491c85194aab' + '760d58a606654f9f4400e8b38591356fbf6425aca26dc85244259ff2b19c41b9' + 'f96f3ca9ec1dde434da7d2d392b905ddf3d1f9af93d1af5950bd493f5aa731b4' + '056df31bd267b6b90a079831aaf579be0a39013137aac6d404f518cfd4684064' + '7e78bfe706ca4cf5e9c5453e9f7cfd2b8b4c8d169a44e55c88d4a9a7f9474241' + 'e221af44860018ab0856972e194cd934' + ) + assert packet == expected + + +def test_server_initial_packet_matches_rfc9001_appendix_a3(): + cid = bytes.fromhex('8394c8f03e515708') + _client, server = derive_initial_packet_protection_keys(cid) + payload = bytes.fromhex( + '02000000000600405a020000560303eefce7f7b37ba1d1632e96677825ddf739' + '88cfc79825df566dc5430b9a045a1200130100002e00330024001d00209d3c94' + '0d89690b84d08a60993c144eca684d1081287c834d5311bcf32bb9da1a002b00' + '020304' + ) + header = bytes.fromhex('c1000000010008f067a5502a4262b50040750001') + packet = protect_quic_packet(header, payload, packet_number=1, pn_offset=18, keys=server) + expected = bytes.fromhex( + 'cf000000010008f067a5502a4262b5004075c0d95a482cd0991cd25b0aac406a' + '5816b6394100f37a1c69797554780bb38cc5a99f5ede4cf73c3ec2493a1839b3' + 'dbcba3f6ea46c5b7684df3548e7ddeb9c3bf9c73cc3f3bded74b562bfb19fb84' + '022f8ef4cdd93795d77d06edbb7aaf2f58891850abbdca3d20398c276456cbc4' + '2158407dd074ee' + ) + assert packet == expected + + +def test_retry_integrity_tag_matches_rfc9001_appendix_a4(): + cid = bytes.fromhex('8394c8f03e515708') + retry_without_tag = bytes.fromhex('ff000000010008f067a5502a4262b5746f6b656e') + assert ( + compute_retry_integrity_tag(retry_without_tag, cid).hex() + == '04a265ba2eff4d829058fb3f0f2496ba' + ) + + +def test_key_update_secret_matches_rfc9001_appendix_a5(): + secret = bytes.fromhex( + '9ac312a7f877468ebe69422748ad00a15443f18203a07d6060f688f30f21632b' + ) + assert ( + update_quic_secret(secret).hex() + == '1223504755036d556342ee9361d253421a826c9ecdf3c7148684b36b714881f9' + ) diff --git a/tests/test_quic_transport_runtime_completion_pytest.py b/tests/test_quic_transport_runtime_completion_pytest.py new file mode 100644 index 00000000..93d6afa6 --- /dev/null +++ b/tests/test_quic_transport_runtime_completion_pytest.py @@ -0,0 +1,183 @@ +from tigrcorn.transports.quic import QuicConnection, decode_packet +from tigrcorn.transports.quic.connection import PACKET_SPACE_INITIAL +from tigrcorn.transports.quic.handshake import ( + QuicTlsHandshakeDriver, + TransportParameters, + generate_self_signed_certificate, +) +from tigrcorn.transports.quic.packets import ( + QuicVersionNegotiationPacket, + split_coalesced_packets, +) +from tigrcorn.transports.quic.streams import FRAME_PING + + +def _issue_0rtt_ticket() -> tuple[bytes, bytes, object]: + cert_pem, key_pem = generate_self_signed_certificate('server.example') + client = QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[cert_pem], + enable_early_data=True, + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name='server.example', + certificate_pem=cert_pem, + private_key_pem=key_pem, + enable_early_data=True, + ) + client_finished = client.receive(server.receive(client.initiate())) + server.receive(client_finished) + ticket_bytes = server.issue_session_ticket(max_early_data_size=1) + client.receive(ticket_bytes) + assert client.received_session_ticket is not None + return cert_pem, key_pem, client.received_session_ticket + + +def test_client_initial_packets_roundtrip_even_when_minimum_size_padding_prevents_coalescing(): + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + datagrams = client.build_coalesced_datagrams( + [ + (PACKET_SPACE_INITIAL, [FRAME_PING], None), + (PACKET_SPACE_INITIAL, [FRAME_PING], None), + ] + ) + assert all(len(datagram) <= client.max_datagram_size for datagram in datagrams) + packets = [] + events = [] + for datagram in datagrams: + packets.extend(split_coalesced_packets(datagram)) + events.extend(server.receive_datagram(datagram)) + assert len(packets) == 2 + assert sum(1 for event in events if event.kind == 'packet') == 2 + assert all(event.packet_space == PACKET_SPACE_INITIAL for event in events if event.kind == 'packet') + + +def test_version_negotiation_is_generated_and_client_switches_to_supported_version(): + client = QuicConnection( + is_client=True, + version=0x1A2A3A4A, + supported_versions=(1, 0x1A2A3A4A), + secret=b'shared', + local_cid=b'cli1cli1', + remote_cid=b'srv1srv1', + ) + server = QuicConnection( + is_client=False, + version=1, + supported_versions=(1, 2), + secret=b'shared', + local_cid=b'srv1srv1', + ) + events = server.receive_datagram(client.build_initial(), addr=('127.0.0.1', 4444)) + assert any(event.kind == 'version_negotiation_sent' for event in events) + outbound = server.take_pending_datagrams() + assert len(outbound) == 1 + negotiated = decode_packet(outbound[0]) + assert isinstance(negotiated, QuicVersionNegotiationPacket) + client_events = client.receive_datagram(outbound[0]) + assert any(event.kind == 'version_negotiation' for event in client_events) + assert client.version == 1 + assert client.state == 'version_negotiated' + + +def test_retry_roundtrip_and_new_token_runtime_validation(): + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', require_retry=True) + first_initial = client.build_initial() + events = server.receive_datagram(first_initial, addr=('127.0.0.1', 4444)) + assert any(event.kind == 'retry' for event in events) + retry_datagram = server.take_pending_datagrams()[0] + retry_events = client.receive_datagram(retry_datagram) + assert any(event.kind == 'retry' for event in retry_events) + second_initial = client.build_initial() + post_retry_events = server.receive_datagram(second_initial, addr=('127.0.0.1', 4444)) + assert any(event.kind == 'packet' for event in post_retry_events) + assert server.address_validated + + token_server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + token_client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + token, new_token_datagram = token_server.issue_new_token(addr=('127.0.0.1', 5555)) + token_events = token_client.receive_datagram(new_token_datagram) + assert any(event.kind == 'new_token' for event in token_events) + assert token_client.peer_new_tokens == (token,) + + +def test_zero_rtt_stream_data_is_decrypted_after_client_hello(): + cert_pem, key_pem, ticket = _issue_0rtt_ticket() + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + client.configure_handshake( + QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[cert_pem], + session_ticket=ticket, + enable_early_data=True, + ) + ) + server.configure_handshake( + QuicTlsHandshakeDriver( + is_client=False, + server_name='server.example', + certificate_pem=cert_pem, + private_key_pem=key_pem, + enable_early_data=True, + ) + ) + server.receive_datagram(client.start_handshake(), addr=('127.0.0.1', 1111)) + zero_rtt_events = server.receive_datagram(client.send_early_stream_data(0, b'early', fin=False), addr=('127.0.0.1', 1111)) + assert any( + event.kind == 'stream' + and event.packet_space == '0rtt' + and event.stream_id == 0 + and event.data == b'early' + for event in zero_rtt_events + ) + + +def test_blocked_frames_and_connection_close_surface_runtime_events(): + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + blocked_events = server.receive_datagram(client.send_data_blocked()) + blocked_events.extend(server.receive_datagram(client.send_stream_data_blocked(0))) + blocked_events.extend(server.receive_datagram(client.send_streams_blocked(10, bidirectional=False))) + assert any(event.kind == 'data_blocked' for event in blocked_events) + assert any(event.kind == 'stream_data_blocked' and event.stream_id == 0 for event in blocked_events) + assert any(event.kind == 'streams_blocked' for event in blocked_events) + + close_events = server.receive_datagram(client.close(error_code=7, reason='bye', application=True)) + assert any(event.kind == 'application_close' for event in close_events) + assert any(event.kind == 'close' and getattr(event.detail, 'application', False) for event in close_events) + + +def test_disable_active_migration_rejects_rebinding_and_preferred_address_is_reported(): + mover = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli1cli1', remote_cid=b'srv1srv1') + stationary = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv1srv1', remote_cid=b'cli1cli1') + stationary.local_transport_parameters = TransportParameters(disable_active_migration=True) + stationary.address_validated = True + stationary.receive_datagram(mover.send_stream_data(0, b'a'), addr=('127.0.0.1', 1000)) + migration_events = stationary.receive_datagram(mover.send_stream_data(4, b'b'), addr=('127.0.0.1', 1001)) + assert any(event.kind == 'close' for event in migration_events) + assert stationary.take_pending_datagrams() + + cert_pem, key_pem = generate_self_signed_certificate('server.example') + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli2cli2', remote_cid=b'srv2srv2') + server = QuicConnection(is_client=False, secret=b'shared', local_cid=b'srv2srv2', remote_cid=b'cli2cli2') + client.configure_handshake(QuicTlsHandshakeDriver(is_client=True, server_name='server.example', trusted_certificates=[cert_pem])) + server.configure_handshake( + QuicTlsHandshakeDriver( + is_client=False, + server_name='server.example', + certificate_pem=cert_pem, + private_key_pem=key_pem, + transport_parameters=TransportParameters(preferred_address=b'new-path'), + ) + ) + server.receive_datagram(client.start_handshake(), addr=('127.0.0.1', 2000)) + client_events = [] + for datagram in server.take_handshake_datagrams(): + client_events.extend(client.receive_datagram(datagram, addr=('127.0.0.1', 2001))) + assert any(event.kind == 'preferred_address' and event.detail == b'new-path' for event in client_events) diff --git a/tests/test_rawframed_handler_pytest.py b/tests/test_rawframed_handler_pytest.py new file mode 100644 index 00000000..a51d0217 --- /dev/null +++ b/tests/test_rawframed_handler_pytest.py @@ -0,0 +1,34 @@ + +from tigrcorn.config.model import ListenerConfig, ServerConfig +from tigrcorn.observability.logging import AccessLogger, configure_logging +from tigrcorn.protocols.rawframed.frames import encode_frame +from tigrcorn.protocols.rawframed.handler import RawFramedApplicationHandler + + +import pytest +class _FakeConnection: + def __init__(self): + self.writes = bytearray() + + def write(self, data: bytes) -> int: + self.writes.extend(data) + return len(data) + + + +async def test_frame_dispatch(): + async def app(scope, receive, send): + assert scope['type'] == 'tigrcorn.rawframed' + event = await receive() + assert event['data'] == b'abcdef' + await send({'type': 'tigrcorn.stream.send', 'data': b'fedcba', 'more_data': False}) + + handler = RawFramedApplicationHandler( + app=app, + config=ServerConfig(), + listener=ListenerConfig(kind='pipe', path='/tmp/test.pipe'), + access_logger=AccessLogger(configure_logging('warning'), enabled=False), + ) + conn = _FakeConnection() + await handler.feed_bytes(conn, encode_frame(b'abcdef')) + assert bytes(conn.writes) == encode_frame(b'fedcba') \ No newline at end of file diff --git a/tests/test_registries_models_pytest.py b/tests/test_registries_models_pytest.py new file mode 100644 index 00000000..38236426 --- /dev/null +++ b/tests/test_registries_models_pytest.py @@ -0,0 +1,32 @@ + +from tigrcorn.listeners.registry import LISTENER_TYPES +from tigrcorn.protocols.custom.registry import CustomProtocolRegistry +from tigrcorn.protocols.registry import BUILTIN_PROTOCOLS +from tigrcorn.sessions.limits import SessionLimits +from tigrcorn.sessions.metadata import SessionMetadata +from tigrcorn.transports.base import TransportDescriptor +from tigrcorn.transports.registry import TRANSPORTS +from tigrcorn.workers.model import WorkerConfig + + +import pytest + +def test_listener_and_transport_registries(): + assert 'udp' in LISTENER_TYPES + assert TRANSPORTS['quic'].multiplexed + desc = TransportDescriptor(name='x', multiplexed=True) + assert desc.multiplexed +def test_protocol_registry(): + assert 'http3' in BUILTIN_PROTOCOLS + assert BUILTIN_PROTOCOLS['http3'].asgi_scope_types == ('http',) + registry = CustomProtocolRegistry() + registry.register('demo', lambda: 'ok') + assert registry.get('demo')() == 'ok' +def test_session_and_worker_models(): + limits = SessionLimits(max_streams=2) + assert limits.allow_stream(1) + assert not (limits.allow_stream(2)) + metadata = SessionMetadata(listener_name='pub', transport='udp', label='udp://127.0.0.1:1') + assert metadata.transport == 'udp' + worker = WorkerConfig(processes=2, graceful_shutdown_timeout=5) + assert worker.processes == 2 \ No newline at end of file diff --git a/tests/test_release_gates_pytest.py b/tests/test_release_gates_pytest.py new file mode 100644 index 00000000..827028ed --- /dev/null +++ b/tests/test_release_gates_pytest.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from tigrcorn.compat.release_gates import evaluate_promotion_target, evaluate_release_gates + +import pytest +ROOT = Path(__file__).resolve().parents[1] + + + +def test_actual_repository_release_gates_pass_for_the_committed_tree(): + report = evaluate_release_gates(ROOT) + assert report.passed, '\n'.join(report.failures) + assert report.failures == [] + assert report.rfc_status['RFC 9114']['highest_observed_evidence_tier'] == 'independent_certification' + assert report.rfc_status['RFC 9220']['highest_observed_evidence_tier'] == 'independent_certification' + assert report.rfc_status['RFC 9002']['highest_observed_evidence_tier'] == 'independent_certification' +def test_synthetic_release_tree_passes_when_boundary_evidence_and_artifacts_align(): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / 'docs/review/conformance').mkdir(parents=True) + (root / 'src/tigrcorn/security').mkdir(parents=True) + for relative in [ + 'README.md', + 'docs/protocols/http3.md', + 'docs/protocols/quic.md', + 'docs/protocols/websocket.md', + 'docs/review/conformance/README.md', + 'docs/review/rfc_compliance_review.md', + 'docs/review/conformance/reports/RFC_HARDENING_REPORT.md', + 'docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md', + 'docs/review/conformance/reports/RFC_CERTIFICATION_STATUS.md', + ]: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('See docs/review/conformance/CERTIFICATION_BOUNDARY.md\n', encoding='utf-8') + (root / 'docs/review/conformance/CERTIFICATION_BOUNDARY.md').write_text('# boundary\n', encoding='utf-8') + (root / 'src/tigrcorn/security/tls.py').write_text('def build_server_tls_context():\n return None\n', encoding='utf-8') + corpus = { + 'vectors': [ + {'name': 'http3-server-surface', 'protocol': 'http3', 'rfc': '9114', 'description': 'local http3', 'fixture': 'tests/test_http3_rfc9114.py'}, + {'name': 'http3-websocket-extended-connect', 'protocol': 'http3-websocket', 'rfc': '9220', 'description': 'local h3 websocket', 'fixture': 'tests/test_http3_websocket_rfc9220.py'}, + ] + } + (root / 'docs/review/conformance/corpus.json').write_text(json.dumps(corpus), encoding='utf-8') + independent_matrix = { + 'name': 'independent', + 'metadata': {'evidence_tier': 'independent_certification'}, + 'scenarios': [ + { + 'id': 'http3-third-party-post', + 'protocol': 'http3', + 'role': 'server', + 'feature': 'post-echo', + 'peer': 'aioquic', + 'enabled': True, + 'evidence_tier': 'independent_certification', + 'metadata': {'rfc': ['RFC 9114']}, + 'sut': { + 'name': 'tigrcorn-http3', + 'adapter': 'subprocess', + 'role': 'server', + 'command': ['python', '-m', 'tigrcorn'], + 'provenance_kind': 'package_owned', + 'implementation_source': 'tigrcorn', + 'implementation_identity': 'tigrcorn-http3', + }, + 'peer_process': { + 'name': 'aioquic-http3-client', + 'adapter': 'subprocess', + 'role': 'client', + 'command': ['python', '-m', 'example.h3client'], + 'provenance_kind': 'third_party_library', + 'implementation_source': 'aioquic', + 'implementation_identity': 'aioquic-http3-client', + }, + }, + { + 'id': 'http3-third-party-websocket', + 'protocol': 'http3', + 'role': 'server', + 'feature': 'websocket-echo', + 'peer': 'aioquic', + 'enabled': True, + 'evidence_tier': 'independent_certification', + 'metadata': {'rfc': ['RFC 9220']}, + 'sut': { + 'name': 'tigrcorn-http3', + 'adapter': 'subprocess', + 'role': 'server', + 'command': ['python', '-m', 'tigrcorn'], + 'provenance_kind': 'package_owned', + 'implementation_source': 'tigrcorn', + 'implementation_identity': 'tigrcorn-http3', + }, + 'peer_process': { + 'name': 'aioquic-http3-websocket-client', + 'adapter': 'subprocess', + 'role': 'client', + 'command': ['python', '-m', 'example.h3wsclient'], + 'provenance_kind': 'third_party_library', + 'implementation_source': 'aioquic', + 'implementation_identity': 'aioquic-http3-websocket-client', + }, + }, + ], + } + same_stack_matrix = { + 'name': 'same-stack', + 'metadata': {'evidence_tier': 'same_stack_replay'}, + 'scenarios': [ + { + 'id': 'same-stack-http3', + 'protocol': 'http3', + 'role': 'server', + 'feature': 'post-echo', + 'peer': 'tigrcorn-public-client', + 'enabled': True, + 'evidence_tier': 'same_stack_replay', + 'metadata': {'rfc': ['RFC 9114']}, + 'sut': { + 'name': 'tigrcorn-http3', + 'adapter': 'subprocess', + 'role': 'server', + 'command': ['python', '-m', 'tigrcorn'], + 'provenance_kind': 'package_owned', + 'implementation_source': 'tigrcorn', + 'implementation_identity': 'tigrcorn-http3', + }, + 'peer_process': { + 'name': 'tigrcorn-public-client', + 'adapter': 'subprocess', + 'role': 'client', + 'command': ['python', '-m', 'tests.fixtures_pkg.external_http3_client'], + 'provenance_kind': 'same_stack_fixture', + 'implementation_source': 'tigrcorn.tests.fixtures_pkg', + 'implementation_identity': 'tigrcorn-public-client', + }, + } + ], + } + (root / 'docs/review/conformance/external_matrix.release.json').write_text(json.dumps(independent_matrix), encoding='utf-8') + (root / 'docs/review/conformance/external_matrix.same_stack_replay.json').write_text(json.dumps(same_stack_matrix), encoding='utf-8') + + independent_root = root / 'docs/review/conformance/releases/current/independent' + independent_root.mkdir(parents=True) + for scenario_id in ['http3-third-party-post', 'http3-third-party-websocket']: + scenario_root = independent_root / scenario_id + scenario_root.mkdir(parents=True) + (scenario_root / 'result.json').write_text(json.dumps({'passed': True}), encoding='utf-8') + (independent_root / 'index.json').write_text( + json.dumps( + { + 'scenarios': [ + {'id': 'http3-third-party-post', 'passed': True}, + {'id': 'http3-third-party-websocket', 'passed': True}, + ] + } + ), + encoding='utf-8', + ) + (independent_root / 'manifest.json').write_text(json.dumps({'bundle_kind': 'independent_certification'}), encoding='utf-8') + + same_stack_root = root / 'docs/review/conformance/releases/current/same-stack' + same_stack_root.mkdir(parents=True) + scenario_root = same_stack_root / 'same-stack-http3' + scenario_root.mkdir(parents=True) + (scenario_root / 'result.json').write_text(json.dumps({'passed': True}), encoding='utf-8') + (same_stack_root / 'index.json').write_text(json.dumps({'scenarios': [{'id': 'same-stack-http3', 'passed': True}]}), encoding='utf-8') + (same_stack_root / 'manifest.json').write_text(json.dumps({'bundle_kind': 'same_stack_replay'}), encoding='utf-8') + + boundary = { + 'canonical_doc': 'docs/review/conformance/CERTIFICATION_BOUNDARY.md', + 'artifact_bundles': { + 'independent_certification': 'docs/review/conformance/releases/current/independent', + 'same_stack_replay': 'docs/review/conformance/releases/current/same-stack', + }, + 'required_rfcs': ['RFC 9114', 'RFC 9220'], + 'required_rfc_evidence': { + 'RFC 9114': { + 'highest_required_evidence_tier': 'independent_certification', + 'declared_evidence': { + 'local_conformance': ['http3-server-surface'], + 'independent_certification': ['http3-third-party-post'], + }, + }, + 'RFC 9220': { + 'highest_required_evidence_tier': 'independent_certification', + 'declared_evidence': { + 'local_conformance': ['http3-websocket-extended-connect'], + 'independent_certification': ['http3-third-party-websocket'], + }, + }, + }, + 'gates': { + 'require_independent_matrix': True, + 'require_third_party_http3_request_response': True, + 'require_third_party_http3_websocket': True, + 'require_package_owned_tls13_subsystem': True, + 'require_docs_reference_canonical_boundary': True, + 'require_conformance_corpus': True, + 'require_rfc_evidence_map': True, + 'require_preserved_artifacts_for_independent_scenarios': True, + }, + 'docs_that_must_reference_boundary': [ + 'README.md', + 'docs/protocols/http3.md', + 'docs/protocols/quic.md', + 'docs/protocols/websocket.md', + 'docs/review/conformance/README.md', + 'docs/review/rfc_compliance_review.md', + 'docs/review/conformance/reports/RFC_HARDENING_REPORT.md', + 'docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md', + 'docs/review/conformance/reports/RFC_CERTIFICATION_STATUS.md', + ], + } + (root / 'docs/review/conformance/certification_boundary.json').write_text(json.dumps(boundary), encoding='utf-8') + report = evaluate_release_gates(root) + assert report.passed, report.failures +def test_actual_repository_promotion_evaluator_keeps_performance_and_docs_green(): + report = evaluate_promotion_target(ROOT) + assert report.performance.passed, '\n'.join(report.performance.failures) + assert report.documentation.passed, '\n'.join(report.documentation.failures) + assert report.passed diff --git a/tests/test_response_pipeline_streaming_checkpoint_pytest.py b/tests/test_response_pipeline_streaming_checkpoint_pytest.py new file mode 100644 index 00000000..6816e06b --- /dev/null +++ b/tests/test_response_pipeline_streaming_checkpoint_pytest.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import asyncio +import socket +from unittest.mock import patch + +import pytest + +from tigrcorn.asgi.send import ( + HTTPResponseCollector, + FileBodySegment, + materialize_response_body_segments, +) +from tigrcorn.constants import H2_PREFACE +from tigrcorn.http.entity import plan_file_backed_response_entity_semantics +from tigrcorn.http.etag import generate_entity_tag +from tigrcorn.protocols.http2.codec import FrameWriter, serialize_settings +from tigrcorn.protocols.http2.hpack import encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.utils.headers import get_header + +from tests.test_phase2_entity_semantics_checkpoint import ( + _read_h2_response, + _read_http1_response, + _start_server, +) +from tests.test_static_delivery_productionization_checkpoint import ( + _read_h3_response_with_client_progress, +) + +PAYLOAD = b'streaming-response-payload-' * 2048 + + +async def _streaming_app(scope, receive, send): + await receive() + await send( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-type', b'application/octet-stream')], + } + ) + chunk_size = 16384 + for offset in range(0, len(PAYLOAD), chunk_size): + chunk = PAYLOAD[offset : offset + chunk_size] + await send( + { + 'type': 'http.response.body', + 'body': chunk, + 'more_body': offset + chunk_size < len(PAYLOAD), + } + ) + + +@pytest.mark.asyncio +async def test_response_collector_spools_large_body_and_preserves_entity_metadata(): + collector = HTTPResponseCollector(spool_threshold=32) + await collector( + { + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-type', b'text/plain')], + } + ) + await collector({'type': 'http.response.body', 'body': b'a' * 20, 'more_body': True}) + await collector({'type': 'http.response.body', 'body': b'b' * 40, 'more_body': False}) + collector.finalize() + + assert collector.has_spooled_body() + assert collector.body_length == 60 + segments = collector.spooled_body_segments() + assert len(segments) == 1 + assert isinstance(segments[0], FileBodySegment) + assert segments[0].count == 60 + assert await materialize_response_body_segments(segments) == (b'a' * 20) + (b'b' * 40) + assert collector.generated_entity_tag().startswith(b'"') + collector.cleanup() + + +def test_file_backed_planning_supports_ranges_conditionals_and_materialization_boundary(): + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmp: + payload = b'0123456789' * 1024 + path = Path(tmp) / 'blob.bin' + path.write_bytes(payload) + etag = generate_entity_tag(payload) + headers = [(b'content-type', b'application/octet-stream')] + + ranged = plan_file_backed_response_entity_semantics( + method='GET', + request_headers=[(b'range', b'bytes=128-255')], + response_headers=headers, + status=200, + body_path=str(path), + body_length=len(payload), + generated_etag=etag, + ) + assert ranged.use_body_segments + assert ranged.status == 206 + assert get_header(ranged.headers, b'content-range') == f'bytes 128-255/{len(payload)}'.encode('ascii') + + not_modified = plan_file_backed_response_entity_semantics( + method='GET', + request_headers=[(b'if-none-match', etag)], + response_headers=headers, + status=200, + body_path=str(path), + body_length=len(payload), + generated_etag=etag, + ) + assert not not_modified.use_body_segments + assert not_modified.status == 304 + assert not_modified.body == b'' + + coded = plan_file_backed_response_entity_semantics( + method='GET', + request_headers=[(b'accept-encoding', b'gzip')], + response_headers=headers, + status=200, + body_path=str(path), + body_length=len(payload), + generated_etag=etag, + ) + assert coded.requires_materialization + + +async def _assert_streamed_without_materialization(*, http_versions: list[str], transport: str = 'tcp') -> None: + spool_calls: list[bool] = [] + original_ensure_spool_file = HTTPResponseCollector._ensure_spool_file + + def wrapped_ensure_spool_file(self): + spool_calls.append(True) + return original_ensure_spool_file(self) + + async def fail_materialize(self): # pragma: no cover - exercised only on regression + raise AssertionError('generic streamed response should not be materialized') + + with ( + patch('tigrcorn.asgi.send.DEFAULT_RESPONSE_BODY_SPOOL_THRESHOLD', 4096), + patch.object(HTTPResponseCollector, '_ensure_spool_file', wrapped_ensure_spool_file), + patch.object(HTTPResponseCollector, 'materialize_body', fail_materialize), + ): + server, port = await _start_server(_streaming_app, http_versions=http_versions, transport=transport) + try: + if transport == 'udp': + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-step5') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + request_payload = core.get_request(0).encode_request( + [ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/'), + (b':authority', b'localhost'), + ], + body=b'x' * 6000, + ) + target = ('127.0.0.1', port) + sock.sendto(client.send_stream_data(0, request_payload, fin=True), target) + response_headers, body = await _read_h3_response_with_client_progress(sock, core, client, target) + finally: + sock.close() + elif http_versions == ['2']: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + try: + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + frame_writer = FrameWriter() + headers = encode_header_block( + [ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/'), + (b':authority', b'localhost'), + ] + ) + writer.write(frame_writer.headers(1, headers, end_stream=True)) + await writer.drain() + response_headers, body = await _read_h2_response(reader) + finally: + writer.close() + await writer.wait_closed() + else: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + try: + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + _head, header_map, body = await _read_http1_response(reader) + response_headers = list(header_map.items()) + finally: + writer.close() + await writer.wait_closed() + + assert body == PAYLOAD + assert spool_calls + header_map = dict(response_headers) + assert header_map[b'content-length'] == str(len(PAYLOAD)).encode('ascii') + assert header_map.get(b'etag') is not None + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http11_generic_large_response_spools_and_streams_without_materialization(): + await _assert_streamed_without_materialization(http_versions=['1.1']) + + +@pytest.mark.asyncio +async def test_http2_generic_large_response_spools_and_streams_without_materialization(): + await _assert_streamed_without_materialization(http_versions=['2']) + + +@pytest.mark.asyncio +async def test_http3_generic_large_response_spools_and_streams_without_materialization(): + await _assert_streamed_without_materialization(http_versions=['3'], transport='udp') diff --git a/tests/test_response_trailers_rfc9110_pytest.py b/tests/test_response_trailers_rfc9110_pytest.py new file mode 100644 index 00000000..54f0cc98 --- /dev/null +++ b/tests/test_response_trailers_rfc9110_pytest.py @@ -0,0 +1,153 @@ +import asyncio +import socket + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import ( + FRAME_DATA, + FRAME_HEADERS, + FRAME_SETTINGS, + FrameBuffer, + FrameWriter, + decode_settings, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server(*, http_versions: list[str], transport: str = 'tcp'): + from tests.fixtures_pkg.interop_trailer_app import app + + kwargs = {'host': '127.0.0.1', 'port': 0, 'lifespan': 'off', 'http_versions': http_versions} + if transport == 'udp': + kwargs.update({'transport': 'udp', 'protocols': ['http3'], 'quic_secret': b'shared'}) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == 'udp': + port = server._listeners[0].transport.get_extra_info('sockname')[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +@pytest.mark.asyncio +async def test_http11_response_trailers_are_emitted(): + server, port = await _start_server(http_versions=['1.1']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET /trailers HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + head = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), 2.0) + rest = await asyncio.wait_for( + reader.readuntil(b'0\r\nx-trailer-one: yes\r\nx-trailer-two: done\r\n\r\n'), + 2.0, + ) + response = head + rest + assert b'transfer-encoding: chunked' in response.lower() + assert b'0\r\nx-trailer-one: yes\r\nx-trailer-two: done\r\n\r\n' in response.lower() + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_response_trailers_are_emitted(): + server, port = await _start_server(http_versions=['2']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + frame_writer = FrameWriter() + request_headers = encode_header_block( + [ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/trailers'), + (b':authority', b'localhost'), + ] + ) + writer.write(frame_writer.headers(1, request_headers, end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + response_trailers = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + decoded = decode_header_block(frame.payload) + if response_headers: + response_trailers.extend(decoded) + else: + response_headers.extend(decoded) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + assert (b':status', b'200') in response_headers + assert bytes(body) == b'ok' + assert (b'x-trailer-one', b'yes') in response_trailers + assert (b'x-trailer-two', b'done') in response_trailers + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_response_trailers_are_emitted(): + server, port = await _start_server(http_versions=['3'], transport='udp') + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-response-trailer') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + headers_payload = core.get_request(0).encode_request( + [ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/trailers'), + (b':authority', b'localhost'), + ] + ) + sock.sendto(client.send_stream_data(0, headers_payload, fin=True), ('127.0.0.1', port)) + + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream' and event.stream_id == 0: + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + assert response_state is not None + assert (b':status', b'200') in response_state.headers + assert response_state.body == b'ok' + assert (b'x-trailer-one', b'yes') in response_state.trailers + assert (b'x-trailer-two', b'done') in response_state.trailers + finally: + sock.close() + await server.close() diff --git a/tests/test_rfc7232_conditional_requests_pytest.py b/tests/test_rfc7232_conditional_requests_pytest.py new file mode 100644 index 00000000..72da867c --- /dev/null +++ b/tests/test_rfc7232_conditional_requests_pytest.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from email.utils import formatdate +from pathlib import Path +import tempfile + +from tigrcorn.http.conditional import apply_conditional_request +from tigrcorn.http.etag import generate_entity_tag +from tigrcorn.static import StaticFilesApp + + +def test_rfc7232_conditional_engine_evaluates_entity_tags_and_dates() -> None: + etag = generate_entity_tag(b'payload') + last_modified = formatdate(1_700_000_000, usegmt=True).encode('ascii') + + not_modified = apply_conditional_request( + method='GET', + request_headers=[(b'if-none-match', etag)], + response_headers=[(b'etag', etag), (b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert not_modified.status == 304 + assert not_modified.body == b'' + + precondition_failed = apply_conditional_request( + method='PUT', + request_headers=[(b'if-match', b'"other"')], + response_headers=[(b'etag', etag), (b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert precondition_failed.status == 412 + + ims = apply_conditional_request( + method='GET', + request_headers=[(b'if-modified-since', last_modified)], + response_headers=[(b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert ims.status == 304 + + ius = apply_conditional_request( + method='GET', + request_headers=[(b'if-unmodified-since', formatdate(1_699_999_000, usegmt=True).encode('ascii'))], + response_headers=[(b'last-modified', last_modified)], + body=b'payload', + status=200, + ) + assert ius.status == 412 + + +async def _receive() -> dict: + return {'type': 'http.request', 'body': b'', 'more_body': False} + + +def test_rfc7232_static_files_supports_not_modified_paths() -> None: + sent: list[dict] = [] + + async def send(message: dict) -> None: + sent.append(message) + + async def run() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'hello.txt').write_text('hello world', encoding='utf-8') + app = StaticFilesApp(root) + await app({'type': 'http', 'method': 'GET', 'path': '/hello.txt', 'headers': []}, _receive, send) + headers = dict(sent[0]['headers']) + etag = headers[b'etag'] + sent.clear() + await app({'type': 'http', 'method': 'GET', 'path': '/hello.txt', 'headers': [(b'if-none-match', etag)]}, _receive, send) + assert sent[0]['status'] == 304 + assert sent[1]['body'] == b'' + + import asyncio + asyncio.run(run()) diff --git a/tests/test_rfc7233_range_requests_pytest.py b/tests/test_rfc7233_range_requests_pytest.py new file mode 100644 index 00000000..0c792504 --- /dev/null +++ b/tests/test_rfc7233_range_requests_pytest.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from email.utils import formatdate +from pathlib import Path +import tempfile + +from tigrcorn.http.range import apply_byte_ranges +from tigrcorn.static import StaticFilesApp +from tigrcorn.utils.headers import get_header + + +def test_rfc7233_byte_range_engine_supports_single_multi_and_unsatisfied_ranges() -> None: + body = b'hello world' + headers = [(b'content-type', b'text/plain')] + + single = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=0-4')], + response_headers=headers, + body=body, + status=200, + ) + assert single.status == 206 + assert single.body == b'hello' + assert get_header(single.headers, b'content-range') == b'bytes 0-4/11' + + multi = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=0-1,6-10')], + response_headers=headers, + body=body, + status=200, + ) + assert multi.status == 206 + assert b'multipart/byteranges' in (get_header(multi.headers, b'content-type') or b'') + assert b'Content-Range: bytes 0-1/11' in multi.body + assert b'Content-Range: bytes 6-10/11' in multi.body + + unsatisfied = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=40-50')], + response_headers=headers, + body=body, + status=200, + ) + assert unsatisfied.status == 416 + assert get_header(unsatisfied.headers, b'content-range') == b'bytes */11' + + +def test_rfc7233_if_range_honors_matching_etag_and_rejects_stale_date() -> None: + body = b'hello world' + headers = [ + (b'content-type', b'text/plain'), + (b'etag', b'"tag-1"'), + (b'last-modified', formatdate(1_700_000_000, usegmt=True).encode('ascii')), + ] + accepted = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=0-4'), (b'if-range', b'"tag-1"')], + response_headers=headers, + body=body, + status=200, + ) + assert accepted.status == 206 + rejected = apply_byte_ranges( + method='GET', + request_headers=[(b'range', b'bytes=0-4'), (b'if-range', formatdate(1_699_999_000, usegmt=True).encode('ascii'))], + response_headers=headers, + body=body, + status=200, + ) + assert rejected.status == 200 + assert rejected.body == body + + +async def _receive() -> dict: + return {'type': 'http.request', 'body': b'', 'more_body': False} + + +def test_rfc7233_static_files_support_range_paths() -> None: + sent: list[dict] = [] + + async def send(message: dict) -> None: + sent.append(message) + + async def run() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'hello.txt').write_text('hello world', encoding='utf-8') + app = StaticFilesApp(root) + await app({'type': 'http', 'method': 'GET', 'path': '/hello.txt', 'headers': [(b'range', b'bytes=0-4')]}, _receive, send) + assert sent[0]['status'] == 206 + headers = dict(sent[0]['headers']) + assert headers[b'content-range'] == b'bytes 0-4/11' + assert sent[1]['body'] == b'hello' + + import asyncio + asyncio.run(run()) diff --git a/tests/test_rfc7838_alt_svc_pytest.py b/tests/test_rfc7838_alt_svc_pytest.py new file mode 100644 index 00000000..7677b55a --- /dev/null +++ b/tests/test_rfc7838_alt_svc_pytest.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.constants import H2_PREFACE +from tigrcorn.http.alt_svc import append_alt_svc_headers, configured_alt_svc_values +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, decode_settings, serialize_settings +from tigrcorn.protocols.http2.hpack import HPACKDecoder, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server(app, *, tcp_versions: list[str] | None = None, include_udp_http3: bool = False, alt_svc_auto: bool = False, alt_svc: list[str] | None = None): + config = build_config( + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=tcp_versions or ['1.1'], + quic_secret=b'shared', + alt_svc_auto=alt_svc_auto, + alt_svc=alt_svc, + ) + if include_udp_http3: + config.listeners.append( + ListenerConfig( + kind='udp', + host='127.0.0.1', + port=0, + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + ) + if tcp_versions == ['3']: + config.listeners.clear() + config.listeners.append( + ListenerConfig( + kind='udp', + host='127.0.0.1', + port=0, + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + ) + server = TigrCornServer(app, config) + await server.start() + tcp_port = None + udp_port = None + for listener in server._listeners: + if hasattr(listener, 'server') and getattr(listener, 'server', None) is not None: + sockets = listener.server.sockets or [] + if sockets: + tcp_port = sockets[0].getsockname()[1] + if hasattr(listener, 'transport') and getattr(listener, 'transport', None) is not None: + udp_port = listener.transport.get_extra_info('sockname')[1] + return server, tcp_port, udp_port + + +async def _read_http1_response(reader: asyncio.StreamReader) -> tuple[dict[bytes, bytes], bytes]: + head = await reader.readuntil(b'\r\n\r\n') + headers: dict[bytes, bytes] = {} + for line in head.split(b'\r\n')[1:]: + if not line: + continue + name, value = line.split(b':', 1) + headers[name.strip().lower()] = value.strip() + length = int(headers.get(b'content-length', b'0')) + body = await reader.readexactly(length) if length else b'' + return headers, body + + +async def _read_h2_response(reader: asyncio.StreamReader) -> tuple[list[tuple[bytes, bytes]], bytes]: + buf = FrameBuffer() + decoder = HPACKDecoder() + headers: list[tuple[bytes, bytes]] = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS and frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + headers = decoder.decode_header_block(frame.payload) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if headers and ended: + break + return headers, bytes(body) + + +async def _prime_http3(sock: socket.socket, client: QuicConnection, core: HTTP3ConnectionCore, *, port: int) -> None: + loop = asyncio.get_running_loop() + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + received = 0 + for _ in range(4): + try: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + except TimeoutError: + if received: + break + raise + received += 1 + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + +async def _read_h3_response(sock: socket.socket, client: QuicConnection, core: HTTP3ConnectionCore, *, stream_id: int): + loop = asyncio.get_running_loop() + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if event.stream_id == stream_id: + response_state = state + assert response_state is not None + return response_state + + +def test_explicit_auto_and_suppression_behavior(): + config = build_config(host='127.0.0.1', port=8080, lifespan='off', alt_svc_auto=True) + config.listeners.append( + ListenerConfig( + kind='udp', + host='127.0.0.1', + port=8443, + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + ) + assert b'h3=":8443"; ma=86400' in configured_alt_svc_values(config, request_http_version='2') + assert configured_alt_svc_values(config, request_http_version='3') == [] + + explicit = build_config(host='127.0.0.1', port=8080, lifespan='off', alt_svc=['h3=":9443"; ma=60']) + assert configured_alt_svc_values(explicit, request_http_version='3') == [b'h3=":9443"; ma=60'] + + +def test_append_alt_svc_headers_preserves_existing_field(): + config = build_config(host='127.0.0.1', port=8080, lifespan='off', alt_svc=['h3=":9443"; ma=60']) + headers = append_alt_svc_headers([(b'alt-svc', b'h3=":1111"; ma=60')], config=config, request_http_version='1.1') + assert headers == [(b'alt-svc', b'h3=":1111"; ma=60')] + + +@pytest.mark.asyncio +async def test_http11_auto_advertises_alt_svc_header_field(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port, udp_port = await _start_server(app, tcp_versions=['1.1'], include_udp_http3=True, alt_svc_auto=True) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + headers, body = await _read_http1_response(reader) + assert body == b'ok' + assert headers[b'alt-svc'] == f'h3=":{udp_port}"; ma=86400'.encode('ascii') + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_auto_advertises_alt_svc_header_field(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port, udp_port = await _start_server(app, tcp_versions=['2'], include_udp_http3=True, alt_svc_auto=True) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + request_headers = encode_header_block([ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/'), + (b':authority', b'localhost'), + ]) + writer.write(frame_writer.headers(1, request_headers, end_stream=True)) + await writer.drain() + headers, body = await _read_h2_response(reader) + assert (b'alt-svc', f'h3=":{udp_port}"; ma=86400'.encode('ascii')) in headers + assert body == b'ok' + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_can_emit_explicit_alt_svc_header_field(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, _tcp_port, udp_port = await _start_server(app, tcp_versions=['3'], alt_svc=['h3=":9443"; ma=60']) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'rfc7838') + core = HTTP3ConnectionCore(role='client') + try: + await _prime_http3(sock, client, core, port=udp_port) + request_stream_id = 0 + payload = core.get_request(request_stream_id).encode_request([ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/'), + (b':authority', b'localhost'), + ]) + sock.sendto(client.send_stream_data(request_stream_id, payload, fin=True), ('127.0.0.1', udp_port)) + response_state = await _read_h3_response(sock, client, core, stream_id=request_stream_id) + assert (b'alt-svc', b'h3=":9443"; ma=60') in response_state.headers + assert response_state.body == b'ok' + finally: + sock.close() + await server.close() diff --git a/tests/test_rfc8297_early_hints_pytest.py b/tests/test_rfc8297_early_hints_pytest.py new file mode 100644 index 00000000..df3cab4d --- /dev/null +++ b/tests/test_rfc8297_early_hints_pytest.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.config.model import ListenerConfig +from tigrcorn.constants import H2_PREFACE +from tigrcorn.http.early_hints import sanitize_informational_headers +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, decode_settings, serialize_settings +from tigrcorn.protocols.http2.hpack import HPACKDecoder, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server(app, *, tcp_versions: list[str] | None = None): + config = build_config( + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=tcp_versions or ['1.1'], + quic_secret=b'shared', + ) + if '3' in (tcp_versions or []): + config.listeners.clear() + config.listeners.append( + ListenerConfig( + kind='udp', + host='127.0.0.1', + port=0, + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + ) + server = TigrCornServer(app, config) + await server.start() + tcp_port = None + udp_port = None + for listener in server._listeners: + if hasattr(listener, 'server') and getattr(listener, 'server', None) is not None: + sockets = listener.server.sockets or [] + if sockets: + tcp_port = sockets[0].getsockname()[1] + if hasattr(listener, 'transport') and getattr(listener, 'transport', None) is not None: + udp_port = listener.transport.get_extra_info('sockname')[1] + return server, tcp_port, udp_port + + +async def _read_http1_response(reader: asyncio.StreamReader) -> tuple[bytes, dict[bytes, bytes], bytes]: + head = await reader.readuntil(b'\r\n\r\n') + headers: dict[bytes, bytes] = {} + for line in head.split(b'\r\n')[1:]: + if not line: + continue + name, value = line.split(b':', 1) + headers[name.strip().lower()] = value.strip() + length = int(headers.get(b'content-length', b'0')) + body = await reader.readexactly(length) if length else b'' + return head, headers, body + + +async def _read_h2_response_sequence(reader: asyncio.StreamReader) -> tuple[list[list[tuple[bytes, bytes]]], bytes]: + buf = FrameBuffer() + decoder = HPACKDecoder() + header_blocks: list[list[tuple[bytes, bytes]]] = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS and frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + header_blocks.append(decoder.decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if header_blocks and ended: + break + return header_blocks, bytes(body) + + +async def _prime_http3(sock: socket.socket, client: QuicConnection, core: HTTP3ConnectionCore, *, port: int) -> None: + loop = asyncio.get_running_loop() + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + received = 0 + for _ in range(4): + try: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + except TimeoutError: + if received: + break + raise + received += 1 + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + +async def _read_h3_response_state(sock: socket.socket, client: QuicConnection, core: HTTP3ConnectionCore, *, stream_id: int): + loop = asyncio.get_running_loop() + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if event.stream_id == stream_id: + response_state = state + assert response_state is not None + return response_state + + +def test_sanitize_103_allows_only_link_and_strips_connection_specific_headers(): + headers = sanitize_informational_headers( + 103, + [ + (b'Link', b'; rel=preload; as=script'), + (b'Connection', b'close'), + (b'X-Unsafe', b'no'), + (b'Link', b'; rel=preload; as=script'), + ], + ) + assert headers == [(b'link', b'; rel=preload; as=script')] + + +@pytest.mark.asyncio +async def test_http11_103_is_emitted_before_final_response(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 103, 'headers': [(b'link', b'; rel=preload; as=script'), (b'x-unsafe', b'no')]}) + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port, _ = await _start_server(app, tcp_versions=['1.1']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + writer.write(b'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + await writer.drain() + interim_head, interim_headers, interim_body = await _read_http1_response(reader) + assert b'103 Early Hints' in interim_head + assert interim_body == b'' + assert b'link' in interim_headers + assert b'x-unsafe' not in interim_headers + final_head, _final_headers, final_body = await _read_http1_response(reader) + assert b'200 OK' in final_head + assert final_body == b'ok' + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_103_is_emitted_before_final_headers(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 103, 'headers': [(b'link', b'; rel=preload; as=style'), (b'x-unsafe', b'no')]}) + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, port, _ = await _start_server(app, tcp_versions=['2']) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + request_headers = encode_header_block([ + (b':method', b'GET'), + (b':scheme', b'http'), + (b':path', b'/'), + (b':authority', b'localhost'), + ]) + writer.write(frame_writer.headers(1, request_headers, end_stream=True)) + await writer.drain() + header_blocks, body = await _read_h2_response_sequence(reader) + assert len(header_blocks) >= 2 + assert (b':status', b'103') in header_blocks[0] + assert (b'link', b'; rel=preload; as=style') in header_blocks[0] + assert (b'x-unsafe', b'no') not in header_blocks[0] + assert (b':status', b'200') in header_blocks[-1] + assert body == b'ok' + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_103_is_emitted_before_final_headers(): + async def app(scope, receive, send): + await receive() + await send({'type': 'http.response.start', 'status': 103, 'headers': [(b'link', b'; rel=preload; as=script'), (b'x-unsafe', b'no')]}) + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'ok', 'more_body': False}) + + server, _tcp_port, udp_port = await _start_server(app, tcp_versions=['3']) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'rfc8297') + core = HTTP3ConnectionCore(role='client') + try: + await _prime_http3(sock, client, core, port=udp_port) + request_stream_id = 0 + payload = core.get_request(request_stream_id).encode_request([ + (b':method', b'GET'), + (b':scheme', b'https'), + (b':path', b'/'), + (b':authority', b'localhost'), + ]) + sock.sendto(client.send_stream_data(request_stream_id, payload, fin=True), ('127.0.0.1', udp_port)) + response_state = await _read_h3_response_state(sock, client, core, stream_id=request_stream_id) + assert len(response_state.informational_headers) == 1 + assert (b':status', b'103') in response_state.informational_headers[0] + assert (b'link', b'; rel=preload; as=script') in response_state.informational_headers[0] + assert (b'x-unsafe', b'no') not in response_state.informational_headers[0] + assert (b':status', b'200') in response_state.headers + assert response_state.body == b'ok' + finally: + sock.close() + await server.close() diff --git a/tests/test_rfc_applicability_and_competitor_status_pytest.py b/tests/test_rfc_applicability_and_competitor_status_pytest.py new file mode 100644 index 00000000..ce51375b --- /dev/null +++ b/tests/test_rfc_applicability_and_competitor_status_pytest.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def test_rfc_applicability_and_competitor_status_document_exists() -> None: + root = Path(__file__).resolve().parents[1] + report = root / 'docs' / 'review' / 'conformance' / 'rfc_applicability_and_competitor_status.current.json' + assert report.exists(), 'expected RFC applicability / competitor status JSON to exist' + + data = json.loads(report.read_text()) + assert data['checkpoint'] == 'rfc_applicability_and_competitor_status' + assert data['reviewed_at'] == '2026-03-26' + + summary = data['summary'] + assert summary['current_core_applicable_rfcs'] == [ + 'RFC 9112', + 'RFC 9113', + 'RFC 9114', + 'RFC 9110 §9.3.6', + 'RFC 9110 §6.5', + 'RFC 9110 §8', + 'RFC 7232', + 'RFC 7233', + 'RFC 8297', + 'RFC 7838 §3', + ] + assert summary['recommended_next_rfcs'] == ['RFC 9530'] + assert summary['transport_adjacent_optional_rfcs'] == ['RFC 9218'] + assert summary['conditional_rfcs_if_boundary_expands'] == ['RFC 9111', 'RFC 9421'] + assert summary['non_core_product_layer_rfcs'] == ['RFC 7515', 'RFC 7516', 'RFC 7519', 'RFC 8152', 'RFC 9052'] + assert summary['competitor_review_scope'] == ['uvicorn', 'hypercorn', 'daphne', 'granian'] + + applicability = data['rfc_applicability'] + assert applicability['rfc9112']['status'] == 'core_current_boundary' + assert applicability['rfc9112']['current_support'] == 'targeted_and_supported' + assert applicability['rfc9113']['status'] == 'core_current_boundary' + assert applicability['rfc9114']['status'] == 'core_current_boundary' + assert applicability['rfc9110']['status'] == 'core_current_boundary_partial' + assert applicability['rfc9110']['covered_sections'] == ['§9.3.6', '§6.5', '§8'] + assert applicability['rfc7232']['status'] == 'core_current_boundary' + assert applicability['rfc7232']['current_support'] == 'targeted_and_supported' + assert applicability['rfc7233']['status'] == 'core_current_boundary' + assert applicability['rfc7233']['current_support'] == 'targeted_and_supported' + assert applicability['rfc8297']['status'] == 'core_current_boundary' + assert applicability['rfc8297']['current_support'] == 'targeted_and_supported' + assert applicability['rfc7838']['status'] == 'core_current_boundary_bounded' + assert applicability['rfc7838']['covered_sections'] == ['§3'] + assert applicability['rfc9218']['status'] == 'transport_adjacent_optional' + assert applicability['rfc9218']['current_support'] == 'not_targeted' + assert applicability['rfc9530']['status'] == 'adjacent_next_recommended' + assert applicability['rfc9111']['status'] == 'conditional_boundary_expansion' + assert applicability['rfc9421']['status'] == 'conditional_boundary_expansion' + for key in ['rfc7515', 'rfc7516', 'rfc7519', 'rfc8152', 'rfc9052']: + assert applicability[key]['status'] == 'non_core_product_layer' + assert applicability[key]['current_support'] == 'not_supported' + + roadmap = data['recommended_roadmap'] + assert [entry['order'] for entry in roadmap] == [1, 2, 3, 4, 5, 6, 7] + assert roadmap[0]['rfcs'] == ['RFC 7232', 'RFC 7233', 'RFC 8297', 'RFC 7838 §3'] + assert roadmap[1]['rfcs'] == ['RFC 7692', 'RFC 9110 §9.3.6', 'RFC 9110 §6.5', 'RFC 9110 §8', 'RFC 6960'] + assert roadmap[2]['rfcs'] == ['RFC 9530'] + assert roadmap[3]['rfcs'] == ['RFC 9218'] + + competitors = data['competitor_matrix']['products'] + assert competitors['tigrcorn']['http3_quic'] == 'yes' + assert competitors['tigrcorn']['connect_policy_surface'] == 'yes' + assert competitors['tigrcorn']['rfc7232'] == 'yes' + assert competitors['tigrcorn']['rfc7233'] == 'yes' + assert competitors['uvicorn']['http1'] == 'documented_yes' + assert competitors['uvicorn']['http2'] == 'no_official_support_claim_found' + assert competitors['uvicorn']['websocket_permessage_deflate_policy'] == 'documented_yes' + assert competitors['hypercorn']['http2'] == 'documented_yes' + assert competitors['hypercorn']['http3_quic'] == 'documented_optional_yes' + assert competitors['hypercorn']['quic_bind'] == 'documented_yes' + assert competitors['daphne']['http2'] == 'documented_yes_with_tls_and_twisted_extras' + assert competitors['daphne']['http3_quic'] == 'no_official_support_claim_found' + assert competitors['granian']['http2'] == 'documented_yes' + assert competitors['granian']['http3_quic'] == 'future_only_not_current' + + +def test_repository_documents_reference_rfc_applicability_report() -> None: + root = Path(__file__).resolve().parents[1] + report_name = 'RFC_APPLICABILITY_AND_COMPETITOR_STATUS.md' + readme = (root / 'README.md').read_text(encoding='utf-8') + current_state = (root / 'docs/review/conformance/state/CURRENT_REPOSITORY_STATE.md').read_text(encoding='utf-8') + conformance_readme = (root / 'docs' / 'review' / 'conformance' / 'README.md').read_text(encoding='utf-8') + + assert report_name in readme + assert report_name in current_state + assert report_name in conformance_readme diff --git a/tests/test_rfc_applicability_and_competitor_support_pytest.py b/tests/test_rfc_applicability_and_competitor_support_pytest.py new file mode 100644 index 00000000..a55ab1b0 --- /dev/null +++ b/tests/test_rfc_applicability_and_competitor_support_pytest.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def test_rfc_applicability_and_competitor_support_document_exists() -> None: + root = Path(__file__).resolve().parents[1] + report = root / 'docs' / 'review' / 'conformance' / 'rfc_applicability_and_competitor_support.current.json' + assert report.exists(), 'expected RFC applicability / competitor support JSON to exist' + + data = json.loads(report.read_text()) + assert data['checkpoint'] == 'phase8_rfc_applicability_competitor_support_update' + + summary = data['summary'] + assert summary['first_priority_is_strict_current_target_closure'] is True + assert summary['core_applicable_rfcs_from_user_table'] == ['rfc9112', 'rfc9113', 'rfc9114', 'rfc9110_bounded', 'rfc7232', 'rfc7233', 'rfc8297', 'rfc7838_section3'] + assert summary['adjacent_optional_expansion_rfcs'] == ['rfc9530'] + assert summary['conditional_boundary_expansion_rfcs'] == ['rfc9111', 'rfc9421'] + assert 'rfc7515' in summary['separate_boundary_optional_rfcs'] + + applicability = data['applicability'] + assert applicability['rfc9112']['applicability'] == 'core_transport' + assert applicability['rfc9110']['applicability'] == 'core_but_bounded' + assert applicability['rfc7232']['applicability'] == 'core_entity_semantics' + assert applicability['rfc7232']['current_state'] == 'targeted_and_release_gated_local_conformance' + assert applicability['rfc7233']['applicability'] == 'core_entity_semantics' + assert applicability['rfc9111']['applicability'] == 'conditional_expansion' + assert applicability['rfc9421']['applicability'] == 'conditional_expansion' + assert applicability['rfc7519']['applicability'] == 'separate_auth_crypto_boundary' + assert applicability['rfc8152']['applicability'] == 'separate_binary_envelope_boundary' + + next_work = data['recommended_next_work'] + assert next_work['strict_current_target_closure'][0] == 'keep the current authoritative / strict / promotion model green' + assert next_work['boundary_expansion_after_strict_closure'][0] == 'formalize rfc7232 conditional request subsystem as current boundary work (completed)' + assert next_work['boundary_expansion_after_strict_closure'][1] == 'formalize rfc7233 range request subsystem as current boundary work (completed)' + assert 'implement rfc9530 content_digest and repr_digest only if integrity fields become a declared product requirement' in next_work['boundary_expansion_after_strict_closure'] + assert 'rfc9218' in summary['transport_adjacent_optional_rfcs'] + assert data['document_role'] == 'scoped_current_audit_not_package_wide_truth_source' + assert data['current_truth_source'] is False + + competitors = data['public_competitor_support'] + assert competitors['tigrcorn']['rfc7232_rfc7233_first_class_server_feature'] == 'yes' + assert competitors['uvicorn']['http2'] == 'no_public_current_claim' + assert competitors['hypercorn']['http3'] == 'optional_draft_via_aioquic' + assert competitors['daphne']['http2'] == 'yes' + assert competitors['granian']['http3'] == 'future_direction_not_current_support' diff --git a/tests/test_rfc_compliance_hardening_pytest.py b/tests/test_rfc_compliance_hardening_pytest.py new file mode 100644 index 00000000..8a71bfd0 --- /dev/null +++ b/tests/test_rfc_compliance_hardening_pytest.py @@ -0,0 +1,281 @@ +import asyncio +import socket + +from cryptography.hazmat.primitives import serialization + +import pytest +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import FRAME_DATA, FRAME_HEADERS, FRAME_SETTINGS, FrameBuffer, FrameWriter, decode_settings, serialize_settings +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.http3.codec import SETTING_ENABLE_CONNECT_PROTOCOL +from tigrcorn.protocols.websocket.frames import OP_TEXT, parse_frame_bytes, serialize_frame +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.security.tls13.extensions import ( + ExtensionType, + GROUP_SECP256R1, + SUPPORTED_CERTIFICATE_SIGNATURE_SCHEMES, + extension_dict, +) +from tigrcorn.security.tls13.handshake import QuicTransportError, _generate_key_share +from tigrcorn.security.tls13.messages import ClientHello, decode_handshake_message +from tigrcorn.transports.quic import QuicConnection +from tigrcorn.transports.quic.handshake import QuicTlsHandshakeDriver, TransportParameters, generate_self_signed_certificate + + +async def _start_http2_server(app): + config = build_config(host='127.0.0.1', port=0, lifespan='off', http_versions=['2']) + server = TigrCornServer(app, config) + await server.start() + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _start_http3_server(app): + config = build_config( + transport='udp', + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=['3'], + protocols=['http3'], + quic_secret=b'shared', + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.transport.get_extra_info('sockname')[1] + return server, port + + +def test_client_hello_omits_legacy_session_id_and_offers_certificate_signature_algorithms(): + client = QuicTlsHandshakeDriver(is_client=True) + client_hello_bytes = client.initiate() + message, consumed = decode_handshake_message(client_hello_bytes, 0) + assert consumed == len(client_hello_bytes) + assert isinstance(message, ClientHello) + assert message.legacy_session_id == b'' + offered = extension_dict(message.extensions) + assert tuple( + int(item) for item in offered[ExtensionType.SIGNATURE_ALGORITHMS_CERT] + ) == SUPPORTED_CERTIFICATE_SIGNATURE_SCHEMES + + +def test_server_rejects_non_empty_legacy_session_id_as_quic_protocol_violation(): + client = QuicTlsHandshakeDriver(is_client=True) + server = QuicTlsHandshakeDriver(is_client=False) + client_hello_bytes = client.initiate() + message, _consumed = decode_handshake_message(client_hello_bytes, 0) + assert isinstance(message, ClientHello) + tampered = ClientHello( + random=message.random, + legacy_session_id=b'legacy-session-id', + cipher_suites=message.cipher_suites, + compression_methods=message.compression_methods, + extensions=message.extensions, + legacy_version=message.legacy_version, + ).encode() + with pytest.raises(QuicTransportError) as ctx: + server.receive(tampered) + assert ctx.value.quic_error_code == 0x0A + + +def test_handshake_supports_secp256r1_key_shares(): + cert_pem, key_pem = generate_self_signed_certificate('server.example') + client = QuicTlsHandshakeDriver( + is_client=True, + server_name='server.example', + trusted_certificates=[cert_pem], + transport_parameters=TransportParameters(max_data=11111), + ) + client._local_key_share_group = GROUP_SECP256R1 + client._local_key_share_private, client._local_key_share_public = _generate_key_share(GROUP_SECP256R1) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name='server.example', + certificate_pem=cert_pem, + private_key_pem=key_pem, + transport_parameters=TransportParameters(max_data=22222), + ) + client_hello = client.initiate() + server_flight = server.receive(client_hello) + client_finished = client.receive(server_flight) + server.receive(client_finished) + assert client.complete + assert server.complete + assert client._local_key_share_group == GROUP_SECP256R1 + assert server._local_key_share_group == GROUP_SECP256R1 + assert client.peer_transport_parameters.max_data == 22222 + + +@pytest.mark.asyncio +async def test_http2_websocket_denial_streaming(): + async def app(scope, receive, send): + assert scope['type'] == 'websocket' + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.http.response.start', 'status': 401, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'websocket.http.response.body', 'body': b'part1-', 'more_body': True}) + await send({'type': 'websocket.http.response.body', 'body': b'part2', 'more_body': False}) + + server, port = await _start_http2_server(app) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + header_block = encode_header_block([ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'http'), + (b':path', b'/chat'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + ]) + writer.write(frame_writer.headers(1, header_block, end_stream=False)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + body = bytearray() + end_stream = False + while not end_stream: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + end_stream = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + end_stream = True + if response_headers and end_stream: + break + + assert (b':status', b'401') in response_headers + assert (b'content-type', b'text/plain') in response_headers + assert bytes(body) == b'part1-part2' + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_websocket_buffers_client_frames_until_accept(): + async def app(scope, receive, send): + assert scope['type'] == 'websocket' + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.accept'}) + event = await receive() + assert event['type'] == 'websocket.receive' + assert event['text'] == 'early-h2-text' + await send({'type': 'websocket.send', 'text': event['text']}) + + server, port = await _start_http2_server(app) + try: + reader, writer = await asyncio.open_connection('127.0.0.1', port) + frame_writer = FrameWriter() + header_block = encode_header_block([ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'http'), + (b':path', b'/chat'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + ]) + early_frame = serialize_frame(OP_TEXT, b'early-h2-text', mask=True, mask_key=b'') + writer.write(H2_PREFACE + serialize_settings({}) + frame_writer.headers(1, header_block, end_stream=False) + frame_writer.data(1, early_frame, end_stream=False)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + response_chunks = [] + while True: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + elif frame.frame_type == FRAME_DATA: + response_chunks.append(frame.payload) + if response_headers and response_chunks: + break + + assert response_headers[0] == (b':status', b'200') + echoed = parse_frame_bytes(response_chunks[0], expect_masked=False) + assert echoed.opcode == OP_TEXT + assert echoed.payload.decode('utf-8') == 'early-h2-text' + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_websocket_denial_streaming(): + async def app(scope, receive, send): + assert scope['type'] == 'websocket' + connect = await receive() + assert connect['type'] == 'websocket.connect' + await send({'type': 'websocket.http.response.start', 'status': 401, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'websocket.http.response.body', 'body': b'part1-', 'more_body': True}) + await send({'type': 'websocket.http.response.body', 'body': b'part2', 'more_body': False}) + + server, port = await _start_http3_server(app) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b'shared', local_cid=b'cli-deny') + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ('127.0.0.1', port)) + for _ in range(4): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if core.state.remote_settings.get(SETTING_ENABLE_CONNECT_PROTOCOL) == 1: + break + + payload = core.get_request(0).encode_request( + [ + (b':method', b'CONNECT'), + (b':protocol', b'websocket'), + (b':scheme', b'https'), + (b':path', b'/chat'), + (b':authority', b'example'), + (b'sec-websocket-version', b'13'), + ], + b'', + ) + sock.sendto(client.send_stream_data(0, payload, fin=False), ('127.0.0.1', port)) + + response_state = None + for _ in range(10): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == 'stream': + response_state = core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + if response_state is not None and response_state.ended: + break + + assert response_state is not None + assert (b':status', b'401') in response_state.headers + assert (b'content-type', b'text/plain') in response_state.headers + assert response_state.body == b'part1-part2' + finally: + sock.close() + await server.close() diff --git a/tests/test_scheduler_runtime_pytest.py b/tests/test_scheduler_runtime_pytest.py new file mode 100644 index 00000000..b1932a91 --- /dev/null +++ b/tests/test_scheduler_runtime_pytest.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from tigrcorn.scheduler import ProductionScheduler, SchedulerPolicy + + +@pytest.mark.asyncio +async def test_connection_leases_and_task_tracking() -> None: + scheduler = ProductionScheduler(SchedulerPolicy(max_connections=1, max_tasks=2)) + lease = scheduler.acquire_connection() + assert lease is not None + assert scheduler.open_connections == 1 + assert scheduler.acquire_connection() is None + + seen: list[int] = [] + + async def job(value: int) -> None: + await asyncio.sleep(0) + seen.append(value) + + first = scheduler.spawn(job(1), owner="alpha") + second = scheduler.spawn(job(2), owner="beta") + await asyncio.gather(first, second) + assert seen == [1, 2] + assert scheduler.active_tasks == 0 + + lease.release() + assert scheduler.open_connections == 0 + await scheduler.close() + assert scheduler.closed + + +@pytest.mark.asyncio +async def test_scheduler_rejects_spawn_after_close() -> None: + scheduler = ProductionScheduler(SchedulerPolicy(max_tasks=1)) + await scheduler.close() + with pytest.raises(RuntimeError): + scheduler.spawn(asyncio.sleep(0)) diff --git a/tests/test_security_compat_utils_pytest.py b/tests/test_security_compat_utils_pytest.py new file mode 100644 index 00000000..081da174 --- /dev/null +++ b/tests/test_security_compat_utils_pytest.py @@ -0,0 +1,36 @@ +from tigrcorn.compat.conformance import compare_sequence, normalize_scope +from tigrcorn.compat.hypercorn import HYPERCORN_COMPAT +from tigrcorn.compat.uvicorn import UVICORN_COMPAT +from tigrcorn.config.model import ListenerConfig +from tigrcorn.security.alpn import normalize_alpn +from tigrcorn.security.certs import PeerCertificate +from tigrcorn.security.policies import TLSPolicy +from tigrcorn.security.tls import build_server_ssl_context +from tigrcorn.utils.ids import next_id, next_session_id, next_stream_id + + +def test_alpn_and_policy() -> None: + assert normalize_alpn("h2") == "h2" + assert normalize_alpn("") is None + cert = PeerCertificate(serial_number="abc") + assert cert.serial_number == "abc" + policy = TLSPolicy(require_client_cert=True) + assert policy.require_client_cert + assert build_server_ssl_context(ListenerConfig()) is None + + +def test_compat_profiles_and_conformance() -> None: + assert UVICORN_COMPAT.http1 + assert HYPERCORN_COMPAT.http2 + left = [{"type": "http.response.start", "headers": [(b"a", b"b")]}] + right = [{"type": "http.response.start", "headers": [(b"a", b"b")]}] + diff = compare_sequence(left, right) + assert diff.ok + assert "state" not in normalize_scope({"type": "http", "state": {}}) + + +def test_ids_monotonic() -> None: + a, b = next_id(), next_id() + assert a < b + assert next_session_id() < next_session_id() + assert next_stream_id() < next_stream_id() diff --git a/tests/test_server_http1_pytest.py b/tests/test_server_http1_pytest.py new file mode 100644 index 00000000..95ba215f --- /dev/null +++ b/tests/test_server_http1_pytest.py @@ -0,0 +1,54 @@ +import asyncio + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.server.runner import TigrCornServer + + +async def _start_server(app): + config = build_config( + host="127.0.0.1", port=0, lifespan="off", http_versions=["1.1"] + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.server.sockets[0].getsockname()[1] + return server, port + + +@pytest.mark.asyncio +async def test_http11_roundtrip(): + async def app(scope, receive, send): + event = await receive() + assert scope["type"] == "http" + assert scope["path"] == "/echo" + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain")], + } + ) + await send( + { + "type": "http.response.body", + "body": event["body"], + "more_body": False, + } + ) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"POST /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello" + ) + await writer.drain() + data = await reader.read(65535) + assert b"200 OK" in data + assert data.endswith(b"hello") + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_server_http2_pytest.py b/tests/test_server_http2_pytest.py new file mode 100644 index 00000000..bbfa4580 --- /dev/null +++ b/tests/test_server_http2_pytest.py @@ -0,0 +1,118 @@ +import asyncio + +import pytest + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import ( + FLAG_END_HEADERS, + FLAG_END_STREAM, + FRAME_DATA, + FRAME_HEADERS, + FRAME_SETTINGS, + FRAME_WINDOW_UPDATE, + FrameBuffer, + FrameWriter, + decode_settings, + serialize_frame, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.server.runner import TigrCornServer + + +async def _start_server(app): + config = build_config(host="127.0.0.1", port=0, lifespan="off", http_versions=["2"]) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.server.sockets[0].getsockname()[1] + return server, port + + +@pytest.mark.asyncio +async def test_http2_prior_knowledge_roundtrip(): + async def app(scope, receive, send): + assert scope["type"] == "http" + assert scope["http_version"] == "2" + event = await receive() + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain")], + } + ) + await send( + { + "type": "http.response.body", + "body": event["body"], + "more_body": False, + } + ) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + request_headers = encode_header_block( + [ + (b":method", b"POST"), + (b":path", b"/h2"), + (b":scheme", b"http"), + (b":authority", b"example"), + (b"content-length", b"5"), + ] + ) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, request_headers, end_stream=False)) + writer.write(frame_writer.data(1, b"hello", end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + body = bytearray() + ended = False + saw_settings = False + while not ended: + data = await reader.read(65535) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + _ = decode_settings(frame.payload) + saw_settings = True + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & FLAG_END_STREAM: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + writer.write( + serialize_frame( + FRAME_WINDOW_UPDATE, + 0, + 0, + len(frame.payload).to_bytes(4, "big"), + ) + ) + writer.write( + serialize_frame( + FRAME_WINDOW_UPDATE, + 0, + frame.stream_id, + len(frame.payload).to_bytes(4, "big"), + ) + ) + await writer.drain() + if frame.flags & FLAG_END_STREAM: + ended = True + assert saw_settings + assert (b":status", b"200") in response_headers + assert bytes(body) == b"hello" + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_server_unix_pytest.py b/tests/test_server_unix_pytest.py new file mode 100644 index 00000000..d4b96fe7 --- /dev/null +++ b/tests/test_server_unix_pytest.py @@ -0,0 +1,48 @@ +import asyncio +import os +import tempfile + +from tigrcorn.config.load import build_config +from tigrcorn.server.runner import TigrCornServer + + +import pytest + +async def test_unix_http_roundtrip(): + async def app(scope, receive, send): + event = await receive() + assert scope["type"] == "http" + assert scope["path"] == "/unix" + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain")], + } + ) + await send( + { + "type": "http.response.body", + "body": b"ok:" + event["body"], + "more_body": False, + } + ) + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "tigrcorn.sock") + config = build_config(uds=path, lifespan="off", http_versions=["1.1"]) + server = TigrCornServer(app, config) + await server.start() + try: + reader, writer = await asyncio.open_unix_connection(path) + writer.write( + b"POST /unix HTTP/1.1\r\nHost: localhost\r\nContent-Length: 3\r\n\r\nhey" + ) + await writer.drain() + data = await reader.read(65535) + assert b"200 OK" in data + assert data.endswith(b"ok:hey") + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_server_websocket_pytest.py b/tests/test_server_websocket_pytest.py new file mode 100644 index 00000000..647599f8 --- /dev/null +++ b/tests/test_server_websocket_pytest.py @@ -0,0 +1,64 @@ +import asyncio +import base64 +import os + +from tigrcorn.config.load import build_config +from tigrcorn.protocols.websocket.frames import encode_frame, read_frame +from tigrcorn.server.runner import TigrCornServer + + +import pytest +async def _start_server(app): + config = build_config( + host="127.0.0.1", port=0, lifespan="off", http_versions=["1.1"] + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.server.sockets[0].getsockname()[1] + return server, port + + + +async def test_websocket_echo(): + async def app(scope, receive, send): + assert scope["type"] == "websocket" + connect = await receive() + assert connect["type"] == "websocket.connect" + await send({"type": "websocket.accept", "headers": []}) + message = await receive() + await send({"type": "websocket.send", "text": message.get("text")}) + await send({"type": "websocket.close", "code": 1000}) + + server, port = await _start_server(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + key = base64.b64encode(os.urandom(16)) + request = ( + b"GET /ws HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: " + key + b"\r\n\r\n" + ) + writer.write(request) + await writer.drain() + response = await reader.readuntil(b"\r\n\r\n") + assert b"101 Switching Protocols" in response + writer.write( + encode_frame(opcode=1, payload=b"hello", fin=True, masked=True) + ) + await writer.drain() + frame = await read_frame(reader, max_payload_size=1024, expect_masked=False) + assert frame.payload == b"hello" + close_frame = await read_frame( + reader, max_payload_size=1024, expect_masked=False + ) + assert close_frame.opcode == 8 + writer.close() + await writer.wait_closed() + finally: + await server.close() + + diff --git a/tests/test_sessions_streams_pytest.py b/tests/test_sessions_streams_pytest.py new file mode 100644 index 00000000..8045cbc1 --- /dev/null +++ b/tests/test_sessions_streams_pytest.py @@ -0,0 +1,35 @@ +from tigrcorn.sessions.connection import ConnectionSession +from tigrcorn.sessions.manager import SessionManager +from tigrcorn.sessions.quic import QuicSession +from tigrcorn.streams.base import LogicalStream +from tigrcorn.streams.multiplex import MultiplexStream +from tigrcorn.streams.registry import StreamRegistry +from tigrcorn.streams.singleplex import SingleplexStream + + +def test_session_manager() -> None: + manager = SessionManager() + session = manager.open(ConnectionSession(session_id=1, peer=("127.0.0.1", 1))) + assert manager.snapshot()["tcp"] == 1 + manager.close(session.session_id) + assert manager.snapshot()["tcp"] == 0 + + +def test_quic_session() -> None: + session = QuicSession(session_id=5) + session.opened_stream() + session.opened_stream() + assert session.stream_count == 2 + + +def test_stream_registry() -> None: + registry = StreamRegistry() + stream = registry.add(LogicalStream(stream_id=1)) + assert registry.get(1) is stream + registry.close(1) + assert registry.get(1) is None + + +def test_stream_types() -> None: + assert not SingleplexStream().multiplexed + assert MultiplexStream(3).multiplexed diff --git a/tests/test_static_delivery_productionization_checkpoint.py b/tests/test_static_delivery_productionization_checkpoint.py index 81a67ad2..c27ab977 100644 --- a/tests/test_static_delivery_productionization_checkpoint.py +++ b/tests/test_static_delivery_productionization_checkpoint.py @@ -76,7 +76,10 @@ async def send(message: dict) -> None: self.assertEqual(sent[0]['status'], 200) self.assertEqual(sent[1]['type'], 'tigrcorn.http.response.file') self.assertEqual(sent[1]['segments'][0]['type'], 'file') - self.assertEqual(sent[1]['segments'][0]['path'], str(root / 'blob.bin')) + self.assertEqual( + Path(sent[1]['segments'][0]['path']).resolve(strict=False), + (root / 'blob.bin').resolve(strict=False), + ) self.assertEqual(sent[1]['segments'][0]['offset'], 0) self.assertEqual(sent[1]['segments'][0]['count'], len(payload)) diff --git a/tests/test_static_delivery_productionization_checkpoint_pytest.py b/tests/test_static_delivery_productionization_checkpoint_pytest.py new file mode 100644 index 00000000..5d8c3300 --- /dev/null +++ b/tests/test_static_delivery_productionization_checkpoint_pytest.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import asyncio +import socket +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import FrameWriter, serialize_settings +from tigrcorn.protocols.http2.hpack import encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.static import StaticFilesApp +from tigrcorn.transports.quic import QuicConnection + +from tests.test_phase2_entity_semantics_checkpoint import ( + _read_h2_response, + _read_http1_response, + _start_server, +) + + +async def _read_h3_response_with_client_progress( + sock: socket.socket, + core: HTTP3ConnectionCore, + client: QuicConnection, + addr: tuple[str, int], +) -> tuple[list[tuple[bytes, bytes]], bytes]: + """Read a streamed HTTP/3 response while driving client ACK/timer progress. + + Large HTTP/3 responses are subject to QUIC anti-amplification and recovery pacing. + The checkpoint server path relies on normal client progress signals, so the test + pumps scheduled client ACK/timer datagrams back to the server while the response + body is streaming. + """ + loop = asyncio.get_running_loop() + response_state = None + for _ in range(128): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 2.0) + for event in client.receive_datagram(data): + if event.kind == "stream" and event.stream_id == 0: + response_state = core.receive_stream_data( + event.stream_id, event.data, fin=event.fin + ) + for raw in ( + client.take_handshake_datagrams() + client.drain_scheduled_datagrams() + ): + sock.sendto(raw, addr) + if response_state is not None and response_state.ended: + return response_state.headers, response_state.body + raise AssertionError("timed out waiting for full HTTP/3 response body") + + +@pytest.mark.asyncio +async def test_static_files_app_uses_streaming_file_extension_when_available(): + async def receive() -> dict: + return {"type": "http.request", "body": b"", "more_body": False} + + sent: list[dict] = [] + + async def send(message: dict) -> None: + sent.append(message) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload = b"0123456789abcdef" * 65536 + (root / "blob.bin").write_bytes(payload) + app = StaticFilesApp(root) + scope = { + "type": "http", + "method": "GET", + "path": "/blob.bin", + "headers": [], + "extensions": { + "tigrcorn.http.response.file": { + "protocol": "http/1.1", + "streaming": True, + "sendfile": True, + } + }, + } + with patch( + "pathlib.Path.read_bytes", + side_effect=AssertionError( + "read_bytes must not be used for streaming static delivery" + ), + ): + await app(scope, receive, send) + assert sent[0]["type"] == "http.response.start" + assert sent[0]["status"] == 200 + assert sent[1]["type"] == "tigrcorn.http.response.file" + assert sent[1]["segments"][0]["type"] == "file" + assert Path(sent[1]["segments"][0]["path"]).resolve(strict=False) == ( + root / "blob.bin" + ).resolve(strict=False) + assert sent[1]["segments"][0]["offset"] == 0 + assert sent[1]["segments"][0]["count"] == len(payload) + + +@pytest.mark.asyncio +async def test_http11_large_static_file_serves_without_read_bytes(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload = b"http11-static-" * 131072 + (root / "blob.bin").write_bytes(payload) + app = StaticFilesApp(root) + server, port = await _start_server(app, http_versions=["1.1"]) + try: + with patch( + "pathlib.Path.read_bytes", + side_effect=AssertionError( + "read_bytes must not be used for HTTP/1.1 static delivery" + ), + ): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(b"GET /blob.bin HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + _head, headers, body = await _read_http1_response(reader) + assert headers[b"content-length"] == str(len(payload)).encode("ascii") + assert body == payload + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http2_large_static_range_serves_without_read_bytes(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload = b"http2-static-range-" * 131072 + (root / "blob.bin").write_bytes(payload) + app = StaticFilesApp(root) + server, port = await _start_server(app, http_versions=["2"]) + try: + with patch( + "pathlib.Path.read_bytes", + side_effect=AssertionError( + "read_bytes must not be used for HTTP/2 static delivery" + ), + ): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + headers = encode_header_block( + [ + (b":method", b"GET"), + (b":scheme", b"http"), + (b":path", b"/blob.bin"), + (b":authority", b"localhost"), + (b"range", b"bytes=4096-8191"), + ] + ) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, headers, end_stream=True)) + await writer.drain() + response_headers, body = await _read_h2_response(reader) + header_map = dict(response_headers) + assert header_map[b":status"] == b"206" + assert header_map[b"content-range"] == f"bytes 4096-8191/{len(payload)}".encode( + "ascii" + ) + assert body == payload[4096:8192] + writer.close() + await writer.wait_closed() + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_http3_large_static_range_serves_without_read_bytes(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload = b"http3-static-range-" * 131072 + (root / "blob.bin").write_bytes(payload) + app = StaticFilesApp(root) + server, port = await _start_server(app, http_versions=["3"], transport="udp") + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b"shared", local_cid=b"cli-static") + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + with patch( + "pathlib.Path.read_bytes", + side_effect=AssertionError( + "read_bytes must not be used for HTTP/3 static delivery" + ), + ): + sock.sendto(client.build_initial(), ("127.0.0.1", port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + request_payload = core.get_request(0).encode_request( + [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":path", b"/blob.bin"), + (b":authority", b"localhost"), + (b"range", b"bytes=16384-32767"), + ], + body=b"x" * 6000, + ) + target = ("127.0.0.1", port) + sock.sendto(client.send_stream_data(0, request_payload, fin=True), target) + response_headers, body = await _read_h3_response_with_client_progress( + sock, core, client, target + ) + header_map = dict(response_headers) + assert header_map[b":status"] == b"206" + assert header_map[b"content-range"] == f"bytes 16384-32767/{len(payload)}".encode( + "ascii" + ) + assert body == payload[16384:32768] + finally: + sock.close() + await server.close() diff --git a/tests/test_tcp_tls_package_owned_pytest.py b/tests/test_tcp_tls_package_owned_pytest.py new file mode 100644 index 00000000..d413bbac --- /dev/null +++ b/tests/test_tcp_tls_package_owned_pytest.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import asyncio +import contextlib +import ssl +import tempfile +from pathlib import Path + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +import pytest +from tigrcorn.protocols.http2.codec import ( + FRAME_DATA, + FRAME_HEADERS, + FRAME_SETTINGS, + FRAME_WINDOW_UPDATE, + FrameBuffer, + FrameWriter, + decode_settings, + serialize_frame, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.security.tls13.handshake import generate_self_signed_certificate + +ROOT = Path(__file__).resolve().parent +CERTS = ROOT / 'fixtures_certs' +SERVER_CERT = CERTS / 'interop-localhost-cert.pem' +SERVER_KEY = CERTS / 'interop-localhost-key.pem' +CLIENT_CERT = CERTS / 'interop-client-cert.pem' +CLIENT_KEY = CERTS / 'interop-client-key.pem' + + +async def _start_tls_server(app, *, http_versions: list[str] | None = None, ssl_ca_certs: str | None = None, require_client_cert: bool = False): + config = build_config( + host='127.0.0.1', + port=0, + lifespan='off', + http_versions=http_versions or ['1.1'], + ssl_certfile=str(SERVER_CERT), + ssl_keyfile=str(SERVER_KEY), + ssl_ca_certs=ssl_ca_certs, + ssl_require_client_cert=require_client_cert, + ) + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.server.sockets[0].getsockname()[1] + return server, port + + +def _client_context(*, alpn: list[str], with_client_cert: bool = False, client_cert: str | None = None, client_key: str | None = None) -> ssl.SSLContext: + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=str(SERVER_CERT)) + context.minimum_version = ssl.TLSVersion.TLSv1_3 + context.set_alpn_protocols(alpn) + if with_client_cert: + context.load_cert_chain(str(client_cert or CLIENT_CERT), str(client_key or CLIENT_KEY)) + return context + + + +async def test_http11_over_package_owned_tls_exposes_tls_extension(): + seen = {} + + async def app(scope, receive, send): + seen['scope'] = scope + event = await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': event['body'], 'more_body': False}) + + server, port = await _start_tls_server(app, http_versions=['1.1']) + try: + reader, writer = await asyncio.open_connection( + '127.0.0.1', + port, + ssl=_client_context(alpn=['http/1.1']), + server_hostname='localhost', + ) + writer.write(b'POST /tls HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello') + await writer.drain() + data = await reader.read(65535) + assert b'200 OK' in data + assert data.endswith(b'hello') + tls_ext = seen['scope']['extensions']['tls'] + assert tls_ext['selected_alpn_protocol'] == 'http/1.1' + assert 'peer_cert' not in tls_ext + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + finally: + await server.close() + +async def test_mtls_over_package_owned_tls_exposes_client_peer_certificate(): + seen = {} + + async def app(scope, receive, send): + seen['scope'] = scope + await receive() + await send({'type': 'http.response.start', 'status': 204, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + + with tempfile.TemporaryDirectory() as tmpdir: + client_cert_pem, client_key_pem = generate_self_signed_certificate('interop-client', purpose='client') + client_cert_path = Path(tmpdir) / 'client-cert.pem' + client_key_path = Path(tmpdir) / 'client-key.pem' + client_cert_path.write_bytes(client_cert_pem) + client_key_path.write_bytes(client_key_pem) + + server, port = await _start_tls_server( + app, + http_versions=['1.1'], + ssl_ca_certs=str(client_cert_path), + require_client_cert=True, + ) + try: + reader, writer = await asyncio.open_connection( + '127.0.0.1', + port, + ssl=_client_context( + alpn=['http/1.1'], + with_client_cert=True, + client_cert=str(client_cert_path), + client_key=str(client_key_path), + ), + server_hostname='localhost', + ) + writer.write(b'POST /mtls HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n') + await writer.drain() + data = await reader.read(65535) + assert b'204 No Content' in data + tls_ext = seen['scope']['extensions']['tls'] + assert tls_ext['selected_alpn_protocol'] == 'http/1.1' + assert 'peer_cert' in tls_ext + assert 'interop-client' in tls_ext['peer_cert']['subject'] + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + finally: + await server.close() + +async def test_http2_over_package_owned_tls_negotiates_h2(): + seen = {} + + async def app(scope, receive, send): + seen['scope'] = scope + event = await receive() + await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': event['body'], 'more_body': False}) + + server, port = await _start_tls_server(app, http_versions=['2']) + try: + reader, writer = await asyncio.open_connection( + '127.0.0.1', + port, + ssl=_client_context(alpn=['h2']), + server_hostname='localhost', + ) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + request_headers = encode_header_block([ + (b':method', b'POST'), + (b':path', b'/h2-tls'), + (b':scheme', b'https'), + (b':authority', b'localhost'), + (b'content-length', b'5'), + ]) + frame_writer = FrameWriter() + writer.write(frame_writer.headers(1, request_headers, end_stream=False)) + writer.write(frame_writer.data(1, b'hello', end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + body = bytearray() + ended = False + saw_settings = False + while not ended: + data = await reader.read(65535) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + _ = decode_settings(frame.payload) + saw_settings = True + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + writer.write(serialize_frame(FRAME_WINDOW_UPDATE, 0, 0, len(frame.payload).to_bytes(4, 'big'))) + writer.write(serialize_frame(FRAME_WINDOW_UPDATE, 0, frame.stream_id, len(frame.payload).to_bytes(4, 'big'))) + await writer.drain() + if frame.flags & 0x1: + ended = True + assert saw_settings + assert (b':status' in b'200'), response_headers + assert bytes(body) == b'hello' + tls_ext = seen['scope']['extensions']['tls'] + assert tls_ext['selected_alpn_protocol'] == 'h2' + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + finally: + await server.close() + + diff --git a/tests/test_tls13_engine_upgrade_pytest.py b/tests/test_tls13_engine_upgrade_pytest.py new file mode 100644 index 00000000..946db266 --- /dev/null +++ b/tests/test_tls13_engine_upgrade_pytest.py @@ -0,0 +1,200 @@ +from tigrcorn.security.tls13.extensions import ( + ExtensionType, + OfferedPsks, + TransportParameters, + extension_dict, +) +import pytest +from tigrcorn.security.tls13.handshake import TlsAlertError +from tigrcorn.security.tls13.messages import ( + ClientHello, + KeyUpdate, + ServerHello, + decode_handshake_messages, +) +from tigrcorn.transports.quic.handshake import ( + QuicTlsHandshakeDriver, + generate_self_signed_certificate, +) + + +def _complete_handshake( + *, + cert_pem: bytes, + key_pem: bytes, + server_name: str = "server.example", + enable_early_data: bool = False, + session_ticket=None, +): + client = QuicTlsHandshakeDriver( + is_client=True, + server_name=server_name, + trusted_certificates=[cert_pem], + session_ticket=session_ticket, + enable_early_data=enable_early_data, + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name=server_name, + certificate_pem=cert_pem, + private_key_pem=key_pem, + enable_early_data=enable_early_data, + ) + client_hello = client.initiate() + server_flight = server.receive(client_hello) + client_finished = client.receive(server_flight) + server.receive(client_finished) + return client, server + + +def test_client_hello_is_real_tls_binary_and_carries_quic_transport_parameters(): + cert_pem, _key_pem = generate_self_signed_certificate("server.example") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + transport_parameters=TransportParameters(max_data=33333, active_connection_id_limit=6), + ) + payload = client.initiate() + assert payload[0] == 1 + messages = decode_handshake_messages(payload) + assert len(messages) == 1 + assert isinstance(messages[0], ClientHello) + offered = extension_dict(messages[0].extensions) + assert offered[ExtensionType.ALPN] == ("h3",) + assert isinstance(offered[ExtensionType.QUIC_TRANSPORT_PARAMETERS], TransportParameters) + assert offered[ExtensionType.QUIC_TRANSPORT_PARAMETERS].max_data == 33333 + assert offered[ExtensionType.QUIC_TRANSPORT_PARAMETERS].active_connection_id_limit == 6 + + +def test_server_emits_hello_retry_request_when_client_key_share_is_missing(): + cert_pem, key_pem = generate_self_signed_certificate("server.example") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=cert_pem, + private_key_pem=key_pem, + ) + original = decode_handshake_messages(client.initiate())[0] + retry_input = original.with_extensions( + tuple( + extension + for extension in original.extensions + if int(extension.extension_type) != ExtensionType.KEY_SHARE + ) + ) + response = server.receive(retry_input.encode()) + messages = decode_handshake_messages(response) + assert len(messages) == 1 + assert isinstance(messages[0], ServerHello) + assert messages[0].is_hello_retry_request + + +def test_session_ticket_resumption_accepts_quic_0rtt_when_policy_allows(): + cert_pem, key_pem = generate_self_signed_certificate("server.example") + first_client, first_server = _complete_handshake( + cert_pem=cert_pem, key_pem=key_pem, enable_early_data=True + ) + ticket_bytes = first_server.issue_session_ticket(max_early_data_size=1) + first_client.receive(ticket_bytes) + ticket = first_client.received_session_ticket + assert ticket is not None + resumed_client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + session_ticket=ticket, + enable_early_data=True, + ) + resumed_server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=cert_pem, + private_key_pem=key_pem, + enable_early_data=True, + ) + client_hello = resumed_client.initiate() + hello = decode_handshake_messages(client_hello)[0] + assert isinstance(hello, ClientHello) + client_extensions = extension_dict(hello.extensions) + assert client_extensions[ExtensionType.EARLY_DATA] + assert isinstance(client_extensions[ExtensionType.PRE_SHARED_KEY], OfferedPsks) + + server_flight = resumed_server.receive(client_hello) + server_messages = decode_handshake_messages(server_flight) + assert len(server_messages) >= 3 + server_extensions = extension_dict(server_messages[1].extensions) + assert server_extensions[ExtensionType.EARLY_DATA] + client_finished = resumed_client.receive(server_flight) + resumed_server.receive(client_finished) + assert resumed_client.early_data_accepted + assert resumed_server.early_data_accepted + assert resumed_client.complete + assert resumed_server.complete + + +def test_0rtt_ticket_replay_is_not_accepted_twice(): + cert_pem, key_pem = generate_self_signed_certificate("server.example") + first_client, first_server = _complete_handshake( + cert_pem=cert_pem, key_pem=key_pem, enable_early_data=True + ) + ticket_bytes = first_server.issue_session_ticket(max_early_data_size=1) + first_client.receive(ticket_bytes) + ticket = first_client.received_session_ticket + assert ticket is not None + accepted_client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + session_ticket=ticket, + enable_early_data=True, + ) + accepted_server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=cert_pem, + private_key_pem=key_pem, + enable_early_data=True, + ) + accepted_finished = accepted_client.receive( + accepted_server.receive(accepted_client.initiate()) + ) + accepted_server.receive(accepted_finished) + assert accepted_server.early_data_accepted + replay_client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + session_ticket=ticket, + enable_early_data=True, + ) + replay_server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=cert_pem, + private_key_pem=key_pem, + enable_early_data=True, + ) + replay_flight = replay_server.receive(replay_client.initiate()) + replay_finished = replay_client.receive(replay_flight) + replay_server.receive(replay_finished) + assert not replay_server.early_data_accepted + assert not replay_client.early_data_accepted + + +def test_tls_key_update_handshake_message_is_rejected_for_quic(): + cert_pem, _key_pem = generate_self_signed_certificate("server.example") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + ) + with pytest.raises(TlsAlertError) as ctx: + client.receive(KeyUpdate(request_update=1).encode()) + assert ctx.value.description == 10 + assert ctx.value.quic_error_code == 0x0100 + 10 diff --git a/tests/test_tls_alpn_rfc7301_pytest.py b/tests/test_tls_alpn_rfc7301_pytest.py new file mode 100644 index 00000000..1e080517 --- /dev/null +++ b/tests/test_tls_alpn_rfc7301_pytest.py @@ -0,0 +1,54 @@ + +from tigrcorn.security.tls13.handshake import QuicTlsHandshakeDriver +import pytest +from tigrcorn.transports.quic.handshake import ( + TransportParameters, + generate_self_signed_certificate, +) + + + +def test_tls13_handshake_selects_first_mutual_alpn(): + cert_pem, key_pem = generate_self_signed_certificate("server.example") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + alpn=("h3", "h2"), + transport_parameters=TransportParameters(max_data=11111), + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=cert_pem, + private_key_pem=key_pem, + alpn=("h2", "h3"), + transport_parameters=TransportParameters(max_data=22222), + ) + client_hello = client.initiate() + server_flight = server.receive(client_hello) + client_finished = client.receive(server_flight) + server.receive(client_finished) + assert client.complete + assert server.complete + assert client.selected_alpn == "h3" + assert server.selected_alpn == "h3" +def test_tls13_handshake_rejects_when_no_mutual_alpn_exists(): + cert_pem, key_pem = generate_self_signed_certificate("server.example") + client = QuicTlsHandshakeDriver( + is_client=True, + server_name="server.example", + trusted_certificates=[cert_pem], + alpn=("h3",), + ) + server = QuicTlsHandshakeDriver( + is_client=False, + server_name="server.example", + certificate_pem=cert_pem, + private_key_pem=key_pem, + alpn=("h2",), + ) + with pytest.raises(Exception, match="ALPN negotiation failed"): + server.receive(client.initiate()) + + diff --git a/tests/test_trailer_policy_strict_local_pytest.py b/tests/test_trailer_policy_strict_local_pytest.py new file mode 100644 index 00000000..444799f7 --- /dev/null +++ b/tests/test_trailer_policy_strict_local_pytest.py @@ -0,0 +1,180 @@ +import asyncio +import socket + +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +import pytest +from tigrcorn.protocols.http2.codec import ( + FRAME_HEADERS, + FRAME_SETTINGS, + FrameBuffer, + FrameWriter, + decode_settings, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server( + *, http_versions: list[str], transport: str = "tcp", seen: dict +): + async def app(scope, receive, send): + seen["dispatched"] = True + while True: + message = await receive() + if message["type"] == "http.disconnect": + break + if message["type"] == "http.request" and not message.get( + "more_body", False + ): + break + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + kwargs = { + "host": "127.0.0.1", + "port": 0, + "lifespan": "off", + "http_versions": http_versions, + "config": {"http": {"trailer_policy": "strict"}}, + } + if transport == "udp": + kwargs.update( + {"transport": "udp", "protocols": ["http3"], "quic_secret": b"shared"} + ) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == "udp": + port = server._listeners[0].transport.get_extra_info("sockname")[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + + +async def test_http11_invalid_request_trailer_returns_400(): + seen = {"dispatched": False} + server, port = await _start_server(http_versions=["1.1"], seen=seen) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"POST /trailers HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" + b"5\r\nhello\r\n" + b"0\r\ncontent-length: 7\r\n\r\n" + ) + await writer.drain() + head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 2.0) + assert b"400" in head + # HTTP/1.1 streaming dispatch begins before invalid trailers are discovered; the strict-path guarantee here is the 400 rejection. + assert seen["dispatched"] + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http2_invalid_request_trailer_returns_400(): + seen = {"dispatched": False} + server, port = await _start_server(http_versions=["2"], seen=seen) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + frame_writer = FrameWriter() + request_headers = encode_header_block( + [ + (b":method", b"POST"), + (b":scheme", b"http"), + (b":path", b"/trailers"), + (b":authority", b"localhost"), + (b"te", b"trailers"), + ] + ) + writer.write(frame_writer.headers(1, request_headers, end_stream=False)) + writer.write(frame_writer.data(1, b"hello", end_stream=False)) + trailer_headers = encode_header_block([(b"content-length", b"7")]) + writer.write(frame_writer.headers(1, trailer_headers, end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + response_headers = [] + while not response_headers: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + break + assert (b":status" in b"400"), response_headers + assert not (seen["dispatched"]) + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_http3_invalid_request_trailer_returns_400(): + seen = {"dispatched": False} + server, port = await _start_server( + http_versions=["3"], transport="udp", seen=seen + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection( + is_client=True, secret=b"shared", local_cid=b"cli-strict-trailer" + ) + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ("127.0.0.1", port)) + for _ in range(2): + data, _addr = await asyncio.wait_for( + loop.sock_recvfrom(sock, 65535), 1.0 + ) + for event in client.receive_datagram(data): + if event.kind == "stream": + core.receive_stream_data( + event.stream_id, event.data, fin=event.fin + ) + headers_payload = core.get_request(0).encode_request( + [ + (b":method", b"POST"), + (b":scheme", b"https"), + (b":path", b"/trailers"), + (b":authority", b"localhost"), + (b"te", b"trailers"), + ], + body=b"hello", + ) + trailer_block = core.encode_headers(0, [(b"content-length", b"7")]) + from tigrcorn.protocols.http3.codec import FRAME_HEADERS, encode_frame + + payload = headers_payload + encode_frame(FRAME_HEADERS, trailer_block) + sock.sendto( + client.send_stream_data(0, payload, fin=True), ("127.0.0.1", port) + ) + + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for( + loop.sock_recvfrom(sock, 65535), 1.0 + ) + for event in client.receive_datagram(data): + if event.kind == "stream" and event.stream_id == 0: + response_state = core.receive_stream_data( + event.stream_id, event.data, fin=event.fin + ) + assert response_state is not None + assert (b":status" in b"400"), response_state.headers + assert not (seen["dispatched"]) + finally: + sock.close() + await server.close() + + diff --git a/tests/test_trailers_rfc9110_pytest.py b/tests/test_trailers_rfc9110_pytest.py new file mode 100644 index 00000000..fa46920d --- /dev/null +++ b/tests/test_trailers_rfc9110_pytest.py @@ -0,0 +1,238 @@ +import asyncio +import socket + +import pytest +from tigrcorn.config.load import build_config +from tigrcorn.constants import H2_PREFACE +from tigrcorn.protocols.http2.codec import ( + FRAME_DATA, + FRAME_HEADERS, + FRAME_SETTINGS, + FrameBuffer, + FrameWriter, + decode_settings, + serialize_settings, +) +from tigrcorn.protocols.http2.hpack import decode_header_block, encode_header_block +from tigrcorn.protocols.http3 import HTTP3ConnectionCore +from tigrcorn.protocols.http3.codec import ( + FRAME_DATA as H3_FRAME_DATA, + FRAME_HEADERS as H3_FRAME_HEADERS, + encode_frame as encode_h3_frame, +) +from tigrcorn.server.runner import TigrCornServer +from tigrcorn.transports.quic import QuicConnection + + +async def _start_server( + *, http_versions: list[str], transport: str = "tcp", seen: dict +): + async def app(scope, receive, send): + seen["extensions"] = dict(scope["extensions"]) + events = [] + while True: + event = await receive() + events.append(event) + if event["type"] == "http.disconnect": + break + seen["events"] = events + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain")], + } + ) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + kwargs = { + "host": "127.0.0.1", + "port": 0, + "lifespan": "off", + "http_versions": http_versions, + } + if transport == "udp": + kwargs.update( + {"transport": "udp", "protocols": ["http3"], "quic_secret": b"shared"} + ) + config = build_config(**kwargs) + server = TigrCornServer(app, config) + await server.start() + if transport == "udp": + port = server._listeners[0].transport.get_extra_info("sockname")[1] + else: + port = server._listeners[0].server.sockets[0].getsockname()[1] + return server, port + + +async def _read_http1_response(reader: asyncio.StreamReader) -> tuple[bytes, bytes]: + head = await reader.readuntil(b"\r\n\r\n") + length = 0 + for line in head.split(b"\r\n")[1:]: + if line.lower().startswith(b"content-length:"): + length = int(line.split(b":", 1)[1].strip()) + break + body = await reader.readexactly(length) if length else b"" + return head, body + + +@pytest.mark.asyncio +async def test_http11_request_trailers_are_exposed(): + seen: dict = {} + server, port = await _start_server(http_versions=["1.1"], seen=seen) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"POST /trailers HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"5\r\nhello\r\n" + b"0\r\nX-Trailer-One: yes\r\nX-Trailer-Two: done\r\n\r\n" + ) + await writer.drain() + head, body = await _read_http1_response(reader) + assert b"200 OK" in head + assert body == b"ok" + writer.close() + await writer.wait_closed() + finally: + await server.close() + assert "tigrcorn.http.request_trailers" in seen["extensions"] + assert seen["events"][0]["type"] == "http.request" + assert seen["events"][1]["type"] == "http.request" + assert seen["events"][2]["type"] == "http.request.trailers" + assert seen["events"][2]["trailers"] == [ + (b"x-trailer-one", b"yes"), + (b"x-trailer-two", b"done"), + ] + assert seen["events"][3]["type"] == "http.disconnect" + + +@pytest.mark.asyncio +async def test_http2_request_trailers_are_exposed(): + seen: dict = {} + server, port = await _start_server(http_versions=["2"], seen=seen) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(H2_PREFACE) + writer.write(serialize_settings({})) + frame_writer = FrameWriter() + request_headers = encode_header_block( + [ + (b":method", b"POST"), + (b":scheme", b"http"), + (b":path", b"/trailers"), + (b":authority", b"localhost"), + (b"te", b"trailers"), + ] + ) + writer.write(frame_writer.headers(1, request_headers, end_stream=False)) + writer.write(frame_writer.data(1, b"hello", end_stream=False)) + trailer_headers = encode_header_block( + [ + (b"x-trailer-one", b"yes"), + (b"x-trailer-two", b"done"), + ] + ) + writer.write(frame_writer.headers(1, trailer_headers, end_stream=True)) + await writer.drain() + + buf = FrameBuffer() + response_headers: list[tuple[bytes, bytes]] = [] + body = bytearray() + ended = False + while not ended: + data = await asyncio.wait_for(reader.read(65535), 2.0) + assert data + buf.feed(data) + for frame in buf.pop_all(): + if frame.frame_type == FRAME_SETTINGS: + if frame.payload: + decode_settings(frame.payload) + elif frame.frame_type == FRAME_HEADERS: + response_headers.extend(decode_header_block(frame.payload)) + if frame.flags & 0x1: + ended = True + elif frame.frame_type == FRAME_DATA: + body.extend(frame.payload) + if frame.flags & 0x1: + ended = True + if response_headers and ended: + break + assert (b":status", b"200") in response_headers + assert bytes(body) == b"ok" + writer.close() + await writer.wait_closed() + finally: + await server.close() + + assert "tigrcorn.http.request_trailers" in seen["extensions"] + assert seen["events"][0]["type"] == "http.request" + assert seen["events"][0]["body"] == b"hello" + assert not seen["events"][0]["more_body"] + assert seen["events"][1]["type"] == "http.request.trailers" + assert seen["events"][1]["trailers"] == [ + (b"x-trailer-one", b"yes"), + (b"x-trailer-two", b"done"), + ] + assert seen["events"][2]["type"] == "http.disconnect" + + +@pytest.mark.asyncio +async def test_http3_request_trailers_are_exposed(): + seen: dict = {} + server, port = await _start_server(http_versions=["3"], transport="udp", seen=seen) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + client = QuicConnection(is_client=True, secret=b"shared", local_cid=b"cli-trailer") + core = HTTP3ConnectionCore() + loop = asyncio.get_running_loop() + try: + sock.sendto(client.build_initial(), ("127.0.0.1", port)) + for _ in range(2): + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream": + core.receive_stream_data(event.stream_id, event.data, fin=event.fin) + + headers_payload = core.get_request(0).encode_request( + [ + (b":method", b"POST"), + (b":scheme", b"https"), + (b":path", b"/trailers"), + (b":authority", b"localhost"), + (b"te", b"trailers"), + ], + body=b"hello", + ) + trailer_block = core.encode_headers( + 0, [(b"x-trailer-one", b"yes"), (b"x-trailer-two", b"done")] + ) + payload = headers_payload + encode_h3_frame(H3_FRAME_HEADERS, trailer_block) + sock.sendto(client.send_stream_data(0, payload, fin=True), ("127.0.0.1", port)) + + response_state = None + while response_state is None or not response_state.ended: + data, _addr = await asyncio.wait_for(loop.sock_recvfrom(sock, 65535), 1.0) + for event in client.receive_datagram(data): + if event.kind == "stream" and event.stream_id == 0: + response_state = core.receive_stream_data( + event.stream_id, event.data, fin=event.fin + ) + assert response_state is not None + assert (b":status", b"200") in response_state.headers + assert response_state.body == b"ok" + finally: + sock.close() + await server.close() + + assert "tigrcorn.http.request_trailers" in seen["extensions"] + assert seen["events"][0]["type"] == "http.request" + assert seen["events"][0]["body"] == b"hello" + assert not seen["events"][0]["more_body"] + assert seen["events"][1]["type"] == "http.request.trailers" + assert seen["events"][1]["trailers"] == [ + (b"x-trailer-one", b"yes"), + (b"x-trailer-two", b"done"), + ] + assert seen["events"][2]["type"] == "http.disconnect" diff --git a/tests/test_trio_runtime_surface_reconciliation_checkpoint_pytest.py b/tests/test_trio_runtime_surface_reconciliation_checkpoint_pytest.py new file mode 100644 index 00000000..e4f86d75 --- /dev/null +++ b/tests/test_trio_runtime_surface_reconciliation_checkpoint_pytest.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from tigrcorn.cli import build_parser +from tigrcorn.compat.release_gates import evaluate_promotion_target +from tigrcorn.config.load import config_from_mapping +from tigrcorn.errors import ConfigError +import pytest +from tigrcorn.server.bootstrap import ( + run_coro_with_runtime, + runtime_compatibility_matrix, +) + +ROOT = Path(__file__).resolve().parents[1] + + + +def test_parser_runtime_choices_no_longer_include_trio() -> None: + parser = build_parser() + runtime_action = next( + action for action in parser._actions if "--runtime" in action.option_strings + ) + assert tuple(runtime_action.choices) == ("auto", "asyncio", "uvloop") + with pytest.raises(SystemExit): + parser.parse_args(["tests.fixtures_pkg.appmod:app", "--runtime", "trio"]) + +def test_config_validation_rejects_trio_runtime_and_worker_class() -> None: + with pytest.raises(ConfigError): + config_from_mapping( + { + "app": {"target": "tests.fixtures_pkg.appmod:app"}, + "process": {"runtime": "trio"}, + } + ) + with pytest.raises(ConfigError): + config_from_mapping( + { + "app": {"target": "tests.fixtures_pkg.appmod:app"}, + "process": {"worker_class": "trio"}, + } + ) + +def test_runtime_matrix_and_docs_descoped_trio() -> None: + expected = runtime_compatibility_matrix() + assert set(expected) == {"auto", "asyncio", "uvloop"} + for rel in [ + "docs/review/conformance/phase4_advanced_delivery/runtime_compatibility_matrix.json", + "docs/review/conformance/phase4_advanced_protocol_delivery/runtime_compatibility_matrix.json", + ]: + payload = json.loads((ROOT / rel).read_text(encoding="utf-8")) + assert payload == expected +def test_direct_runtime_invocation_rejects_trio() -> None: + with pytest.raises(RuntimeError): + run_coro_with_runtime(lambda: None, runtime="trio") + +def test_promotion_target_remains_green() -> None: + report = evaluate_promotion_target(ROOT) + assert report.passed, report.failures \ No newline at end of file diff --git a/tests/test_websocket_additional_rfc6455_pytest.py b/tests/test_websocket_additional_rfc6455_pytest.py new file mode 100644 index 00000000..c6b5366b --- /dev/null +++ b/tests/test_websocket_additional_rfc6455_pytest.py @@ -0,0 +1,128 @@ +import asyncio +import base64 +import os + +from tigrcorn.config.load import build_config +import pytest +from tigrcorn.protocols.websocket.frames import ( + decode_close_payload, + encode_frame, + read_frame, +) +from tigrcorn.protocols.websocket.handler import _WSAppSend +from tigrcorn.server.runner import TigrCornServer + + +async def _start_server(app, *, websocket_max_message_size: int | None = None): + config = build_config( + host="127.0.0.1", port=0, lifespan="off", http_versions=["1.1"] + ) + if websocket_max_message_size is not None: + config.websocket_max_message_size = websocket_max_message_size + server = TigrCornServer(app, config) + await server.start() + listener = server._listeners[0] + port = listener.server.sockets[0].getsockname()[1] + return server, port + + +async def _open_websocket(port: int): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + key = base64.b64encode(os.urandom(16)) + request = ( + b"GET /ws HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: " + key + b"\r\n\r\n" + ) + writer.write(request) + await writer.drain() + response = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 1.0) + return reader, writer, response + + + +async def test_accept_rejects_extension_negotiation(): + sender = _WSAppSend( + writer=type( + "W", + (), + { + "write": lambda self, data: None, + "drain": staticmethod(asyncio.sleep), + }, + )(), + server_header=None, + state={ + "accepted": False, + "closed": False, + "http_denied": False, + "http_denial_status": 403, + "http_denial_headers": [], + "http_denial_started": False, + "sec_websocket_key": b"dGhlIHNhbXBsZSBub25jZQ==", + }, + accepted=asyncio.Event(), + allowed_subprotocols=[], + ) + with pytest.raises(RuntimeError): + await sender( + { + "type": "websocket.accept", + "headers": [(b"sec-websocket-extensions", b"permessage-deflate")], + } + ) + +async def test_invalid_utf8_text_message_closes_with_1007(): + disconnects: list[dict] = [] + + async def app(scope, receive, send): + await receive() + await send({"type": "websocket.accept", "headers": []}) + disconnects.append(await receive()) + + server, port = await _start_server(app) + try: + reader, writer, response = await _open_websocket(port) + assert b"101 Switching Protocols" in response + writer.write(encode_frame(opcode=1, payload=b"\xff", fin=True, masked=True)) + await writer.drain() + frame = await asyncio.wait_for( + read_frame(reader, max_payload_size=1024, expect_masked=False), 1.0 + ) + code, reason = decode_close_payload(frame.payload) + assert code == 1007 + assert reason == "invalid frame payload data" + assert disconnects[0]["code"] == 1007 + writer.close() + await writer.wait_closed() + finally: + await server.close() + +async def test_fragmented_message_limit_closes_with_1009(): + disconnects: list[dict] = [] + + async def app(scope, receive, send): + await receive() + await send({"type": "websocket.accept", "headers": []}) + disconnects.append(await receive()) + + server, port = await _start_server(app, websocket_max_message_size=4) + try: + reader, writer, response = await _open_websocket(port) + assert b"101 Switching Protocols" in response + writer.write(encode_frame(opcode=1, payload=b"abc", fin=False, masked=True)) + writer.write(encode_frame(opcode=0, payload=b"de", fin=True, masked=True)) + await writer.drain() + frame = await asyncio.wait_for( + read_frame(reader, max_payload_size=1024, expect_masked=False), 1.0 + ) + code, _reason = decode_close_payload(frame.payload) + assert code == 1009 + assert disconnects[0]["code"] == 1009 + writer.close() + await writer.wait_closed() + finally: + await server.close() diff --git a/tests/test_websocket_frames_pytest.py b/tests/test_websocket_frames_pytest.py new file mode 100644 index 00000000..b3b124c8 --- /dev/null +++ b/tests/test_websocket_frames_pytest.py @@ -0,0 +1,8 @@ +from tigrcorn.protocols.websocket.frames import decode_frame, encode_frame + + +def test_encode_decode_text() -> None: + raw = encode_frame(opcode=1, payload=b"hello", fin=True, masked=False) + frame = decode_frame(raw) + assert frame.opcode == 1 + assert frame.payload == b"hello" diff --git a/tests/test_websocket_rfc6455_pytest.py b/tests/test_websocket_rfc6455_pytest.py new file mode 100644 index 00000000..a30bc83e --- /dev/null +++ b/tests/test_websocket_rfc6455_pytest.py @@ -0,0 +1,80 @@ +import asyncio + +from tigrcorn.errors import ProtocolError +from tigrcorn.protocols.websocket.codec import close_frame +import pytest +from tigrcorn.protocols.websocket.frames import ( + OP_CLOSE, + OP_PING, + OP_TEXT, + decode_close_payload, + encode_close_payload, + encode_frame, + parse_frame_bytes, +) +from tigrcorn.protocols.websocket.handler import _WSAppSend + + +class _FakeWriter: + def __init__(self) -> None: + self.data = bytearray() + + def write(self, data: bytes) -> None: + self.data.extend(data) + + async def drain(self) -> None: + return None + + + +def test_control_frames_must_not_fragment(): + with pytest.raises(ProtocolError): + parse_frame_bytes(encode_frame(OP_PING, b"a", fin=False)) + +def test_control_frames_must_be_small(): + with pytest.raises(ProtocolError): + parse_frame_bytes(encode_frame(OP_PING, b"a" * 126, fin=True)) + +def test_invalid_reserved_opcode_rejected(): + with pytest.raises(ProtocolError): + parse_frame_bytes(encode_frame(0x3, b"data", fin=True)) + +def test_invalid_close_code_rejected(): + with pytest.raises(ProtocolError): + encode_close_payload(1005, "") + with pytest.raises(ProtocolError): + decode_close_payload((1005).to_bytes(2, "big")) + +def test_invalid_close_reason_utf8_rejected(): + payload = (1000).to_bytes(2, "big") + b"\xff" + with pytest.raises(ProtocolError): + decode_close_payload(payload) + +async def test_accept_subprotocol_must_be_offered(): + writer = _FakeWriter() + sender = _WSAppSend( + writer=writer, + server_header=None, + state={ + "accepted": False, + "closed": False, + "http_denied": False, + "http_denial_status": 403, + "http_denial_headers": [], + "http_denial_started": False, + "sec_websocket_key": b"dGhlIHNhbXBsZSBub25jZQ==", + }, + accepted=asyncio.Event(), + allowed_subprotocols=["chat"], + ) + with pytest.raises(RuntimeError): + await sender( + {"type": "websocket.accept", "subprotocol": "superchat", "headers": []} + ) + +def test_close_frame_valid(): + raw = close_frame(1000, "ok") + frame = parse_frame_bytes(raw) + assert frame.opcode == OP_CLOSE + code, reason = decode_close_payload(frame.payload) + assert (code == reason), (1000, "ok") \ No newline at end of file diff --git a/tests/test_websocket_rfc7692_pytest.py b/tests/test_websocket_rfc7692_pytest.py new file mode 100644 index 00000000..2babcfc1 --- /dev/null +++ b/tests/test_websocket_rfc7692_pytest.py @@ -0,0 +1,104 @@ +import pytest +from tigrcorn.protocols.websocket.extensions import ( + PerMessageDeflateAgreement, + PerMessageDeflateRuntime, + default_permessage_deflate_agreement, + negotiate_permessage_deflate, + parse_permessage_deflate_offers, +) + + +def test_server_may_request_client_no_context_takeover_without_offer_hint(): + agreement = negotiate_permessage_deflate( + request_headers=[(b"sec-websocket-extensions", b"permessage-deflate")], + response_headers=[ + ( + b"sec-websocket-extensions", + b"permessage-deflate; client_no_context_takeover; server_max_window_bits=10", + ) + ], + ) + assert agreement == PerMessageDeflateAgreement( + client_no_context_takeover=True, + server_max_window_bits=10, + ) + + +def test_server_must_not_set_client_max_window_bits_without_client_offer(): + with pytest.raises(RuntimeError): + negotiate_permessage_deflate( + request_headers=[(b"sec-websocket-extensions", b"permessage-deflate")], + response_headers=[ + ( + b"sec-websocket-extensions", + b"permessage-deflate; client_max_window_bits=10", + ) + ], + ) + + +def test_context_takeover_reduces_second_message_size(): + payload = b"HelloHelloHelloHelloHello" + sender = PerMessageDeflateRuntime(PerMessageDeflateAgreement()) + receiver = PerMessageDeflateRuntime(PerMessageDeflateAgreement()) + + first = sender.compress_message(payload) + second = sender.compress_message(payload) + assert len(second) < len(first) + assert receiver.decompress_message(first) == payload + assert receiver.decompress_message(second) == payload + + +def test_no_context_takeover_restarts_compression_and_decompression_state(): + payload = b"HelloHelloHelloHelloHello" + agreement = PerMessageDeflateAgreement( + server_no_context_takeover=True, + client_no_context_takeover=True, + server_max_window_bits=10, + client_max_window_bits=10, + ) + sender = PerMessageDeflateRuntime(agreement) + receiver = PerMessageDeflateRuntime(agreement) + + first = sender.compress_message(payload) + second = sender.compress_message(payload) + assert first == second + assert receiver.decompress_message(first) == payload + assert receiver.decompress_message(second) == payload + + +def test_default_agreement_mirrors_explicit_window_bits(): + offers = parse_permessage_deflate_offers( + [ + ( + b"sec-websocket-extensions", + b"permessage-deflate; client_max_window_bits=15; server_max_window_bits=15", + ), + ] + ) + agreement = default_permessage_deflate_agreement(offers) + assert agreement == PerMessageDeflateAgreement( + server_max_window_bits=15, + client_max_window_bits=15, + ) + + +def test_negotiate_permessage_deflate_with_explicit_window_bits(): + agreement = negotiate_permessage_deflate( + request_headers=[ + ( + b"sec-websocket-extensions", + b"permessage-deflate; client_max_window_bits=15; server_max_window_bits=15", + ) + ], + response_headers=[ + ( + b"sec-websocket-extensions", + b"permessage-deflate; client_max_window_bits=15; server_max_window_bits=15", + ) + ], + ) + assert agreement == PerMessageDeflateAgreement( + server_max_window_bits=15, + client_max_window_bits=15, + ) diff --git a/tests/test_x509_webpki_validation_pytest.py b/tests/test_x509_webpki_validation_pytest.py new file mode 100644 index 00000000..9febc104 --- /dev/null +++ b/tests/test_x509_webpki_validation_pytest.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import threading +from collections import Counter +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509 import ocsp +from cryptography.x509.oid import ( + AuthorityInformationAccessOID, + ExtendedKeyUsageOID, + NameOID, +) + +from tigrcorn.errors import ProtocolError +from tigrcorn.security.tls import ( + CertificateValidationPolicy, + RevocationFetchPolicy, + RevocationMaterial, + RevocationMode, + verify_certificate_chain, +) +from tigrcorn.transports.quic.handshake import generate_self_signed_certificate + + +_NOW = datetime.now(timezone.utc) + + +@dataclass(slots=True) +class _ResponseSpec: + body: bytes + status: int = 200 + headers: dict[str, str] = field(default_factory=dict) + + +class _RevocationRequestHandler(BaseHTTPRequestHandler): + server_version = "tigrcorn-test-revocation" + sys_version = "" + + def do_GET(self) -> None: # noqa: N802 + self._dispatch() + + def do_POST(self) -> None: # noqa: N802 + self._dispatch() + + def log_message(self, fmt: str, *args) -> None: # noqa: A003 + return + + def _dispatch(self) -> None: + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) if length else b"" + self.server.request_counts[(self.command, self.path)] += 1 + self.server.requests.append( + (self.command, self.path, body, dict(self.headers.items())) + ) + spec = self.server.responses.get((self.command, self.path)) + if spec is None: + self.send_response(404) + self.end_headers() + self.wfile.write(b"not found") + return + self.send_response(spec.status) + for key, value in spec.headers.items(): + self.send_header(key, value) + self.send_header("Content-Length", str(len(spec.body))) + self.end_headers() + self.wfile.write(spec.body) + + +class _RevocationHTTPServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, responses: dict[tuple[str, str], _ResponseSpec]) -> None: + super().__init__(("127.0.0.1", 0), _RevocationRequestHandler) + self.responses = responses + self.request_counts: Counter[tuple[str, str]] = Counter() + self.requests: list[tuple[str, str, bytes, dict[str, str]]] = [] + + def url(self, path: str) -> str: + return f"http://127.0.0.1:{self.server_port}{path}" + + def count(self, method: str, path: str) -> int: + return self.request_counts[(method, path)] + + +@contextmanager +def revocation_http_server(responses: dict[tuple[str, str], _ResponseSpec]): + server = _RevocationHTTPServer(responses) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +class CertificateFactory: + def __init__(self) -> None: + self._now = _NOW + + def make_ca( + self, + common_name: str, + *, + issuer_cert: x509.Certificate | None = None, + issuer_key=None, + path_length: int | None = 1, + name_constraints: x509.NameConstraints | None = None, + ) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + issuer = issuer_cert.subject if issuer_cert is not None else subject + signer = issuer_key or key + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(self._now - timedelta(days=1)) + .not_valid_after(self._now + timedelta(days=365)) + .add_extension( + x509.BasicConstraints(ca=True, path_length=path_length), critical=True + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + (issuer_key or key).public_key() + ), + critical=False, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + ) + if name_constraints is not None: + builder = builder.add_extension(name_constraints, critical=True) + return builder.sign(signer, hashes.SHA256()), key + + def make_server_leaf( + self, + common_name: str, + *, + issuer_cert: x509.Certificate, + issuer_key, + san_dns: tuple[str, ...] = (), + san_ips: tuple[str, ...] = (), + ocsp_uris: tuple[str, ...] = (), + crl_uris: tuple[str, ...] = (), + ) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + ) + .issuer_name(issuer_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(self._now - timedelta(days=1)) + .not_valid_after(self._now + timedelta(days=90)) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=True + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + issuer_key.public_key() + ), + critical=False, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False + ) + ) + general_names: list[x509.GeneralName] = [x509.DNSName(name) for name in san_dns] + general_names.extend( + x509.IPAddress(__import__("ipaddress").ip_address(value)) + for value in san_ips + ) + if general_names: + builder = builder.add_extension( + x509.SubjectAlternativeName(general_names), critical=False + ) + if ocsp_uris: + builder = builder.add_extension( + x509.AuthorityInformationAccess( + [ + x509.AccessDescription( + AuthorityInformationAccessOID.OCSP, + x509.UniformResourceIdentifier(uri), + ) + for uri in ocsp_uris + ] + ), + critical=False, + ) + if crl_uris: + builder = builder.add_extension( + x509.CRLDistributionPoints( + [ + x509.DistributionPoint( + full_name=[x509.UniformResourceIdentifier(uri)], + relative_name=None, + reasons=None, + crl_issuer=None, + ) + for uri in crl_uris + ] + ), + critical=False, + ) + return builder.sign(issuer_key, hashes.SHA256()), key + + def make_crl( + self, + issuer_cert: x509.Certificate, + issuer_key, + *, + revoked_serials: tuple[int, ...], + next_update: datetime | None = None, + ) -> x509.CertificateRevocationList: + builder = ( + x509.CertificateRevocationListBuilder() + .issuer_name(issuer_cert.subject) + .last_update(self._now - timedelta(minutes=5)) + .next_update(next_update or (self._now + timedelta(days=1))) + ) + for serial in revoked_serials: + revoked = ( + x509.RevokedCertificateBuilder() + .serial_number(serial) + .revocation_date(self._now - timedelta(minutes=1)) + .build() + ) + builder = builder.add_revoked_certificate(revoked) + return builder.sign(private_key=issuer_key, algorithm=hashes.SHA256()) + + def make_ocsp_response( + self, + certificate: x509.Certificate, + issuer_cert: x509.Certificate, + issuer_key, + *, + cert_status: ocsp.OCSPCertStatus = ocsp.OCSPCertStatus.GOOD, + next_update: datetime | None = None, + this_update: datetime | None = None, + ) -> ocsp.OCSPResponse: + status_kwargs = { + "revocation_time": None, + "revocation_reason": None, + } + if cert_status is ocsp.OCSPCertStatus.REVOKED: + status_kwargs["revocation_time"] = self._now - timedelta(minutes=1) + status_kwargs["revocation_reason"] = x509.ReasonFlags.key_compromise + builder = ( + ocsp.OCSPResponseBuilder() + .add_response( + cert=certificate, + issuer=issuer_cert, + algorithm=hashes.SHA1(), + cert_status=cert_status, + this_update=this_update or (self._now - timedelta(minutes=1)), + next_update=next_update, + **status_kwargs, + ) + .responder_id(ocsp.OCSPResponderEncoding.HASH, issuer_cert) + ) + return builder.sign(issuer_key, hashes.SHA256()) + + +def _pem(cert: x509.Certificate) -> bytes: + return cert.public_bytes(serialization.Encoding.PEM) + + +def _der_ocsp(response: ocsp.OCSPResponse) -> bytes: + return response.public_bytes(serialization.Encoding.DER) + + +def _der_crl(crl: x509.CertificateRevocationList) -> bytes: + return crl.public_bytes(serialization.Encoding.DER) + + +def test_accepts_directly_trusted_self_signed_leaf_with_san_and_key_identifiers() -> None: + cert_pem, _key_pem = generate_self_signed_certificate("server.example") + leaf = verify_certificate_chain([cert_pem], [cert_pem], server_name="server.example") + assert leaf.subject.rfc4514_string() == "CN=server.example" + + +def test_rejects_server_certificate_without_subject_alt_name() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + leaf, _leaf_key = factory.make_server_leaf( + "server.example", issuer_cert=root, issuer_key=root_key + ) + with pytest.raises(ProtocolError, match="subjectAltName"): + verify_certificate_chain([_pem(leaf)], [_pem(root)], server_name="server.example") + + +def test_rejects_path_length_violation() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA", path_length=0) + intermediate, intermediate_key = factory.make_ca( + "Intermediate CA", issuer_cert=root, issuer_key=root_key, path_length=0 + ) + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=intermediate, + issuer_key=intermediate_key, + san_dns=("service.example",), + ) + with pytest.raises(ProtocolError, match="chain verification failed"): + verify_certificate_chain( + [_pem(leaf), _pem(intermediate)], + [_pem(root)], + server_name="service.example", + ) + + +def test_rejects_name_constraints_violation() -> None: + factory = CertificateFactory() + constraints = x509.NameConstraints( + permitted_subtrees=[x509.DNSName("allowed.example")], excluded_subtrees=None + ) + root, root_key = factory.make_ca("Root CA", name_constraints=constraints) + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + ) + with pytest.raises(ProtocolError, match="chain verification failed"): + verify_certificate_chain([_pem(leaf)], [_pem(root)], server_name="service.example") + + +def test_rejects_revoked_leaf_when_crl_is_present() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + ) + crl = factory.make_crl(root, root_key, revoked_serials=(leaf.serial_number,)) + policy = CertificateValidationPolicy( + revocation_mode=RevocationMode.REQUIRE, + revocation_material=RevocationMaterial(crls=(crl,)), + ) + with pytest.raises(ProtocolError, match="revoked"): + verify_certificate_chain( + [_pem(leaf)], [_pem(root)], server_name="service.example", policy=policy + ) + + +def test_requires_revocation_evidence_when_policy_requires_it() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + ) + policy = CertificateValidationPolicy(revocation_mode=RevocationMode.REQUIRE) + with pytest.raises(ProtocolError, match="revocation"): + verify_certificate_chain( + [_pem(leaf)], [_pem(root)], server_name="service.example", policy=policy + ) + + +def test_fetches_ocsp_from_aia_and_reuses_cache() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + policy = CertificateValidationPolicy(revocation_mode=RevocationMode.REQUIRE) + assert policy.revocation_fetch_policy is not None + with revocation_http_server({}) as server: + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + ocsp_uris=(server.url("/ocsp"),), + ) + response = factory.make_ocsp_response( + leaf, + root, + root_key, + next_update=_NOW + timedelta(minutes=30), + ) + server.responses[("POST", "/ocsp")] = _ResponseSpec( + body=_der_ocsp(response), + headers={ + "Content-Type": "application/ocsp-response", + "Cache-Control": "max-age=600", + }, + ) + verified = verify_certificate_chain( + [_pem(leaf)], [_pem(root)], server_name="service.example", policy=policy + ) + assert verified.serial_number == leaf.serial_number + assert server.count("POST", "/ocsp") == 1 + verified = verify_certificate_chain( + [_pem(leaf)], [_pem(root)], server_name="service.example", policy=policy + ) + assert verified.serial_number == leaf.serial_number + assert len(policy.revocation_fetch_policy.cache) == 1 + + +def test_fetches_crl_from_distribution_point() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + with revocation_http_server({}) as server: + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + crl_uris=(server.url("/root.crl"),), + ) + crl = factory.make_crl(root, root_key, revoked_serials=()) + server.responses[("GET", "/root.crl")] = _ResponseSpec( + body=_der_crl(crl), + headers={"Content-Type": "application/pkix-crl"}, + ) + policy = CertificateValidationPolicy(revocation_mode=RevocationMode.REQUIRE) + verified = verify_certificate_chain( + [_pem(leaf)], [_pem(root)], server_name="service.example", policy=policy + ) + assert verified.serial_number == leaf.serial_number + assert server.count("GET", "/root.crl") == 1 + + +def test_soft_fail_allows_unreachable_online_revocation_source() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + ocsp_uris=("http://127.0.0.1:9/unreachable",), + ) + policy = CertificateValidationPolicy( + revocation_mode=RevocationMode.SOFT_FAIL, + revocation_fetch_policy=RevocationFetchPolicy(timeout_seconds=0.25), + ) + verified = verify_certificate_chain( + [_pem(leaf)], [_pem(root)], server_name="service.example", policy=policy + ) + assert verified.serial_number == leaf.serial_number + + +def test_require_mode_rejects_stale_ocsp_response() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + with revocation_http_server({}) as server: + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + ocsp_uris=(server.url("/stale-ocsp"),), + ) + stale_response = factory.make_ocsp_response( + leaf, + root, + root_key, + next_update=_NOW - timedelta(hours=1), + this_update=_NOW - timedelta(days=1), + ) + server.responses[("POST", "/stale-ocsp")] = _ResponseSpec( + body=_der_ocsp(stale_response), + headers={"Content-Type": "application/ocsp-response"}, + ) + policy = CertificateValidationPolicy(revocation_mode=RevocationMode.REQUIRE) + with pytest.raises( + ProtocolError, match="revocation status could not be established" + ): + verify_certificate_chain( + [_pem(leaf)], + [_pem(root)], + server_name="service.example", + policy=policy, + ) + + +def test_require_mode_surfaces_fetch_failure_context() -> None: + factory = CertificateFactory() + root, root_key = factory.make_ca("Root CA") + leaf, _leaf_key = factory.make_server_leaf( + "service.example", + issuer_cert=root, + issuer_key=root_key, + san_dns=("service.example",), + crl_uris=("http://127.0.0.1:9/missing.crl",), + ) + policy = CertificateValidationPolicy( + revocation_mode=RevocationMode.REQUIRE, + revocation_fetch_policy=RevocationFetchPolicy(timeout_seconds=0.25), + ) + with pytest.raises(ProtocolError, match="CRL http://127.0.0.1:9/missing.crl"): + verify_certificate_chain( + [_pem(leaf)], [_pem(root)], server_name="service.example", policy=policy + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..578ae4f5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,714 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "aioquic" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cryptography" }, + { name = "pylsqpack" }, + { name = "pyopenssl" }, + { name = "service-identity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/0c/858bb02e0ff96b40735b09ed7be25690197851e4c1bcde51af3348c851fc/aioquic-1.3.0.tar.gz", hash = "sha256:28d070b2183e3e79afa9d4e7bd558960d0d53aeb98bc0cf0a358b279ba797c92", size = 181923, upload-time = "2025-10-11T09:16:30.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/41/9a6cf092f2d21768091969dccd4723270f4cd8138d00097160d9c8eabeb8/aioquic-1.3.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59da070ff0f55a54f5623c9190dbc86638daa0bcf84bbdb11ebe507abc641435", size = 1922701, upload-time = "2025-10-11T09:16:10.971Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/ac91850a3e6c915802d8c0ee782f966ddfaeed9f870696c1cdb98b25c9a1/aioquic-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:48590fa38ec13f01a3d4e44fb3cfd373661094c9c7248f3c54d2d9512b6c3469", size = 2240281, upload-time = "2025-10-11T09:16:12.895Z" }, + { url = "https://files.pythonhosted.org/packages/a8/65/383f3b3921e1d6b9b757bff3c805c24f7180eda690aecb5e8df50eb7b028/aioquic-1.3.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:019b16580d53541b5d77b4a44a61966921156554fad2536d74895713c800caa5", size = 2752433, upload-time = "2025-10-11T09:16:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/b9/00/66f9a2f95db35ccbe1d9384d44beae28072fceec6ca0ffa29f6c640516c2/aioquic-1.3.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:396e5f53f6ddb27713d9b5bb11d8f0f842e42857b7e671c5ae203bf618528550", size = 2445180, upload-time = "2025-10-11T09:16:17.136Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/f020815b9fa6ea9b83354deb213b90a25fd01466f5a8e517e1c0e672be8c/aioquic-1.3.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:4098afc6337adf19bdb54474f6c37983988e7bfa407892a277259c32eb664b00", size = 2361800, upload-time = "2025-10-11T09:16:18.685Z" }, + { url = "https://files.pythonhosted.org/packages/87/be/a141aafe8984ed380e610397d606a9d9818ef30ce352aa9ede048a966d81/aioquic-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:48292279a248422b6289fffd82159eba8d8b35ff4b1f660b9f74ff85e10ca265", size = 2797515, upload-time = "2025-10-11T09:16:20.451Z" }, + { url = "https://files.pythonhosted.org/packages/52/50/b421e7aedff4a96840bf8734c2c11c18a8434c780c0cb59dff7f0906cee8/aioquic-1.3.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0538acdfbf839d87b175676664737c248cd51f1a2295c5fef8e131ddde478a86", size = 2388628, upload-time = "2025-10-11T09:16:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f4/3c674f4608883e7fc7212f067c599d1321b0c5dd45bda5c77ab5a1e73924/aioquic-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a8881239801279188e33ced6f9849cedf033325a48a6f44d7e55e583abc555a3", size = 2465059, upload-time = "2025-10-11T09:16:23.474Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/7b1908feffb29b89d2f6d4adc583e83543cd559676354f85c5b4b77a6428/aioquic-1.3.0-cp310-abi3-win32.whl", hash = "sha256:ba30016244e45d9222fdd1fbd4e8b0e5f6811e81a5d0643475ad7024a537274a", size = 1326532, upload-time = "2025-10-11T09:16:25.971Z" }, + { url = "https://files.pythonhosted.org/packages/82/45/4e47404984d65ee31cc9e1370f1fbc4e8c92b25da71f61429dbdba437246/aioquic-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:2d7957ba14a6c5efcc14fdc685ccda7ecf0ad048c410a2bdcad1b63bf9527e8e", size = 1675068, upload-time = "2025-10-11T09:16:27.258Z" }, + { url = "https://files.pythonhosted.org/packages/43/60/a8cb5f85c5a6a3cc630124a45644ca5a0ab3eecae2df558b6e0ab7847e1c/aioquic-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:9d15a89213d38cbc4679990fa5151af8ea02655a1d6ce5ec972b0a6af74d5f1c", size = 1234825, upload-time = "2025-10-11T09:16:28.994Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pylsqpack" +version = "0.3.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/a0/20b34e654b911a9abb736b242cc0a11912bc79ea3e911f139ea756e39ea2/pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890", size = 677187, upload-time = "2026-03-29T15:42:40.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/88/71b79d334f67dd595fbed5f3a337e2aa997a96e452bb1b64120bccf5679d/pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d", size = 162525, upload-time = "2026-03-29T15:42:25.436Z" }, + { url = "https://files.pythonhosted.org/packages/4e/96/f0a7625075394e93db42bd476abb7240ff1a474acd1ad404158baf68dc6a/pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc", size = 168643, upload-time = "2026-03-29T15:42:26.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/de/49ec59856ea41468ed879ec143fc429729e37e4860b2119959a2a66fb652/pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a", size = 246930, upload-time = "2026-03-29T15:42:28.136Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d3/3e748fa5317782bfe68a7eaf890524aee48281c59f07e9bdfd7774f158db/pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c", size = 249234, upload-time = "2026-03-29T15:42:29.583Z" }, + { url = "https://files.pythonhosted.org/packages/22/5b/06f5e354ed882ce036ed65f2a393c98d0f6c71a23fa64b53251ddeb40a7b/pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c", size = 250274, upload-time = "2026-03-29T15:42:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/61/0e/c95cae2817a5c272b7a3132376165aa16875efcccbbd3e6608f5082770cc/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9", size = 246966, upload-time = "2026-03-29T15:42:32.035Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/d5e84c3b4b2fa716df9e95aeb40d3bfb4de50c21cccccd66e194cfc084ac/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b", size = 246546, upload-time = "2026-03-29T15:42:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/88e442ced83c0305f50f45bf521bbce3344ef0c29c3442f010086ff0c124/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0", size = 248517, upload-time = "2026-03-29T15:42:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c2/886348974bba20db2a80cf37e97203d7334223b3c1c1babe4159dd12626d/pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767", size = 153483, upload-time = "2026-03-29T15:42:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/0d/22/adbce7adfb41b8f5f222195f7f4f5e58655aa3e83f525bc5f3882b07d6e8/pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff", size = 156145, upload-time = "2026-03-29T15:42:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2e/6fb6d797ce88741a0e18984bbab69160abc0971a41f4478cab6c8255a8dc/pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9", size = 153424, upload-time = "2026-03-29T15:42:38.73Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "service-identity" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tigrcorn" +version = "0.3.9" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, +] + +[package.optional-dependencies] +certification = [ + { name = "aioquic" }, + { name = "h2" }, + { name = "websockets" }, + { name = "wsproto" }, +] +compression = [ + { name = "brotli" }, +] +config-yaml = [ + { name = "pyyaml" }, +] +dev = [ + { name = "aioquic" }, + { name = "brotli" }, + { name = "h2" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, + { name = "websockets" }, + { name = "wsproto" }, +] +full-featured = [ + { name = "brotli" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] +runtime-trio = [ + { name = "trio" }, +] +runtime-uvloop = [ + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "aioquic", marker = "extra == 'certification'", specifier = ">=1.3.0" }, + { name = "aioquic", marker = "extra == 'dev'", specifier = ">=1.3.0" }, + { name = "brotli", marker = "extra == 'compression'", specifier = ">=1.1.0" }, + { name = "brotli", marker = "extra == 'dev'", specifier = ">=1.1.0" }, + { name = "brotli", marker = "extra == 'full-featured'", specifier = ">=1.1.0" }, + { name = "cryptography", specifier = ">=46.0.0" }, + { name = "h2", marker = "extra == 'certification'", specifier = ">=4.1.0" }, + { name = "h2", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pyyaml", marker = "extra == 'config-yaml'", specifier = ">=6.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "pyyaml", marker = "extra == 'full-featured'", specifier = ">=6.0" }, + { name = "trio", marker = "extra == 'runtime-trio'", specifier = ">=0.25.0" }, + { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'dev'", specifier = ">=0.19.0" }, + { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'full-featured'", specifier = ">=0.19.0" }, + { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'runtime-uvloop'", specifier = ">=0.19.0" }, + { name = "websockets", marker = "extra == 'certification'", specifier = ">=12.0" }, + { name = "websockets", marker = "extra == 'dev'", specifier = ">=12.0" }, + { name = "wsproto", marker = "extra == 'certification'", specifier = ">=1.3.0" }, + { name = "wsproto", marker = "extra == 'dev'", specifier = ">=1.3.0" }, +] +provides-extras = ["certification", "config-yaml", "compression", "runtime-uvloop", "runtime-trio", "full-featured", "dev"] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +]