diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a978fcd6..0c5376ac 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,12 @@ +1.2.0 (2024-09-28) +==================== + +**Added** +- Support for informational response 1XX in HTTP/3. The event ``InformationalHeadersReceived`` has been added to reflect that. + +**Changed** +- Update rustls v0.23.12 to v0.23.13 along with dependents. + 1.1.0 (2024-09-20) ==================== diff --git a/Cargo.lock b/Cargo.lock index 32755b7e..e73e3d34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,9 +73,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "aws-lc-fips-sys" @@ -218,12 +218,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.10" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" dependencies = [ "jobserver", "libc", + "shlex", ] [[package]] @@ -289,9 +290,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" dependencies = [ "cc", ] @@ -304,9 +305,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "cpufeatures" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] @@ -613,9 +614,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libloading" @@ -776,9 +777,9 @@ dependencies = [ [[package]] name = "oid-registry" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c958dd45046245b9c3c2547369bb634eb461670b2e7e0de552905801a648d1d" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" dependencies = [ "asn1-rs", ] @@ -900,9 +901,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "powerfmt" @@ -921,9 +922,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.20" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" +checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" dependencies = [ "proc-macro2", "syn", @@ -1013,7 +1014,7 @@ dependencies = [ [[package]] name = "qh3" -version = "1.1.0" +version = "1.2.0" dependencies = [ "aws-lc-rs", "chacha20poly1305", @@ -1036,9 +1037,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -1075,9 +1076,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags", ] @@ -1165,9 +1166,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc_version" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] @@ -1183,9 +1184,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags", "errno", @@ -1196,9 +1197,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.12" +version = "0.23.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" +checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" dependencies = [ "aws-lc-rs", "log", @@ -1221,15 +1222,15 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "rustls-webpki" -version = "0.102.6" +version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "aws-lc-rs", "ring", @@ -1271,18 +1272,18 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -1357,9 +1358,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.74" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -1385,18 +1386,18 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", @@ -1463,9 +1464,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unindent" diff --git a/Cargo.toml b/Cargo.toml index 4cc3b476..c1ba3d4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qh3" -version = "1.1.0" +version = "1.2.0" edition = "2021" rust-version = "1.75" license = "BSD-3" diff --git a/pyproject.toml b/pyproject.toml index 4b8eba1b..4eea8641 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ strict_optional = false warn_redundant_casts = true warn_unused_ignores = true -[tool.ruff] +[tool.ruff.lint] select = [ "E", # pycodestyle "F", # Pyflakes @@ -64,5 +64,5 @@ select = [ "I", # isort ] -[tool.ruff.isort] +[tool.ruff.lint.isort] required-imports = ["from __future__ import annotations"] diff --git a/qh3/__init__.py b/qh3/__init__.py index f7af6746..6ec4147a 100644 --- a/qh3/__init__.py +++ b/qh3/__init__.py @@ -13,7 +13,7 @@ from .quic.packet import QuicProtocolVersion from .tls import CipherSuite, SessionTicket -__version__ = "1.1.0" +__version__ = "1.2.0" __all__ = ( "connect", diff --git a/qh3/h3/connection.py b/qh3/h3/connection.py index c128131d..4a115643 100644 --- a/qh3/h3/connection.py +++ b/qh3/h3/connection.py @@ -22,6 +22,7 @@ H3Event, Headers, HeadersReceived, + InformationalHeadersReceived, PushPromiseReceived, WebTransportStreamDataReceived, ) @@ -193,16 +194,23 @@ def validate_headers( headers: Headers, allowed_pseudo_headers: frozenset[bytes], required_pseudo_headers: frozenset[bytes], -) -> None: + extract_header: bytes | None = None, +) -> bytes | None: after_pseudo_headers = False authority: bytes | None = None path: bytes | None = None scheme: bytes | None = None seen_pseudo_headers: set[bytes] = set() + + extracted_header_value: bytes | None = None + for key, value in headers: if UPPERCASE.search(key): raise MessageError("Header %r contains uppercase letters" % key) + if extract_header is not None and extracted_header_value is None: + extracted_header_value = value + if key.startswith(b":"): # pseudo-headers if after_pseudo_headers: @@ -237,6 +245,8 @@ def validate_headers( if not path: raise MessageError("Pseudo-header b':path' cannot be empty") + return extracted_header_value + def validate_push_promise_headers(headers: Headers) -> None: validate_headers( @@ -262,13 +272,22 @@ def validate_request_headers(headers: Headers) -> None: ) -def validate_response_headers(headers: Headers) -> None: - validate_headers( +def validate_response_headers(headers: Headers) -> int | None: + status_code: bytes | None = validate_headers( headers, allowed_pseudo_headers=frozenset((b":status",)), required_pseudo_headers=frozenset((b":status",)), + extract_header=b":status", ) + if status_code is None: + return None + + try: + return int(status_code) + except ValueError: + return None + def validate_trailers(headers: Headers) -> None: validate_headers( @@ -494,7 +513,20 @@ def send_headers( # update state and send headers if stream.headers_send_state == HeadersState.INITIAL: - stream.headers_send_state = HeadersState.AFTER_HEADERS + is_informational_headers = False + + if not self._is_client and not end_stream: + for k, v in headers: + if k == b":status": + if int(v) < 200: + is_informational_headers = True + break + elif not k.startswith(b":"): + break + + # only the server is allowed to do so! + if not is_informational_headers: + stream.headers_send_state = HeadersState.AFTER_HEADERS else: stream.headers_send_state = HeadersState.AFTER_TRAILERS self._quic.send_stream_data( @@ -648,10 +680,12 @@ def _handle_request_or_push_frame( # try to decode HEADERS, may raise pylsqpack.StreamBlocked headers = self._decode_headers(stream.stream_id, frame_data) + status_code: int | None = None + # validate headers if stream.headers_recv_state == HeadersState.INITIAL: if self._is_client: - validate_response_headers(headers) + status_code = validate_response_headers(headers) else: validate_request_headers(headers) else: @@ -675,17 +709,33 @@ def _handle_request_or_push_frame( # update state and emit headers if stream.headers_recv_state == HeadersState.INITIAL: - stream.headers_recv_state = HeadersState.AFTER_HEADERS + # Informational Response MUST be taken as-is without + # skipping the main response. + if status_code is None or status_code >= 200 or stream_ended: + stream.headers_recv_state = HeadersState.AFTER_HEADERS else: stream.headers_recv_state = HeadersState.AFTER_TRAILERS - http_events.append( - HeadersReceived( - headers=headers, - push_id=stream.push_id, - stream_id=stream.stream_id, - stream_ended=stream_ended, + + if ( + stream.headers_recv_state == HeadersState.INITIAL + and status_code is not None + and status_code < 200 + ): + http_events.append( + InformationalHeadersReceived( + headers=headers, + stream_id=stream.stream_id, + ) + ) + else: + http_events.append( + HeadersReceived( + headers=headers, + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) ) - ) elif frame_type == FrameType.PUSH_PROMISE and stream.push_id is None: if not self._is_client: raise FrameUnexpected("Clients must not send PUSH_PROMISE") diff --git a/qh3/h3/events.py b/qh3/h3/events.py index 451a40f6..b7da1339 100644 --- a/qh3/h3/events.py +++ b/qh3/h3/events.py @@ -46,6 +46,20 @@ class DatagramReceived(H3Event): "The ID of the flow the data was received for." +@dataclass +class InformationalHeadersReceived(H3Event): + """ + This event is fired whenever an informational response has been caught inflight! + The stream cannot be ended there. + """ + + headers: Headers + "The headers." + + stream_id: int + "The ID of the stream the headers were received for." + + @dataclass class HeadersReceived(H3Event): """ diff --git a/tests/test_h3.py b/tests/test_h3.py index 53558b42..898a215a 100644 --- a/tests/test_h3.py +++ b/tests/test_h3.py @@ -24,7 +24,7 @@ validate_response_headers, validate_trailers, ) -from qh3.h3.events import DataReceived, HeadersReceived, PushPromiseReceived +from qh3.h3.events import DataReceived, HeadersReceived, PushPromiseReceived, InformationalHeadersReceived from qh3.h3.exceptions import NoAvailablePushIDError from qh3.quic.configuration import QuicConfiguration from qh3.quic.events import StreamDataReceived @@ -1414,6 +1414,94 @@ def test_request_with_trailers(self): ], ) + + def test_request_with_informational(self): + with h3_client_and_server() as (quic_client, quic_server): + h3_client = H3Connection(quic_client) + h3_server = H3Connection(quic_server) + + # send request with trailers + stream_id = quic_client.get_next_available_stream_id() + h3_client.send_headers( + stream_id=stream_id, + headers=[ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", b"localhost"), + (b":path", b"/"), + ], + end_stream=True, + ) + + # receive request + events = h3_transfer(quic_client, h3_server) + self.assertEqual( + events, + [ + HeadersReceived( + headers=[ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", b"localhost"), + (b":path", b"/"), + ], + stream_id=stream_id, + stream_ended=True, + ), + ], + ) + + # send response + h3_server.send_headers( + stream_id=stream_id, + headers=[ + (b":status", b"103"), + (b"link", b"; rel=preload; as=style"), + ], + end_stream=False, + ) + h3_server.send_headers( + stream_id=stream_id, + headers=[ + (b":status", b"200"), + (b"content-type", b"text/html; charset=utf-8"), + ], + end_stream=False, + ) + h3_server.send_data( + stream_id=stream_id, + data=b"hello", + end_stream=True, + ) + + # receive response + events = h3_transfer(quic_server, h3_client) + self.assertEqual( + events, + [ + InformationalHeadersReceived( + headers=[ + (b":status", b"103"), + (b"link", b"; rel=preload; as=style"), + ], + stream_id=stream_id + ), + HeadersReceived( + headers=[ + (b":status", b"200"), + (b"content-type", b"text/html; charset=utf-8"), + ], + stream_id=stream_id, + stream_ended=False, + ), + DataReceived( + data=b"hello", + stream_id=stream_id, + stream_ended=True, + ), + ], + ) + def test_uni_stream_type(self): with h3_client_and_server() as (quic_client, quic_server): h3_server = H3Connection(quic_server)