diff --git a/docs/source/contributing/conventions/protocol-family-grouping.rst b/docs/source/contributing/conventions/protocol-family-grouping.rst index 79b1bbff5d..81ae964559 100644 --- a/docs/source/contributing/conventions/protocol-family-grouping.rst +++ b/docs/source/contributing/conventions/protocol-family-grouping.rst @@ -122,6 +122,11 @@ version under that name to the layer it carries. - ``version`` - Octets without ``version`` - ``from_data`` + * - :class:`~pcapkit.protocols.application.http.HTTP` + - ``1``, ``2`` + - :meth:`~pcapkit.protocols.application.http.HTTP._dispatch_version`: the + connection preface, a start line or a frame header + - the data model; a :obj:`dict` is refused * - :class:`~pcapkit.protocols.internet.igmp.IGMP` - ``1``, ``2``, ``3`` - :meth:`~pcapkit.protocols.internet.igmp.IGMP._dispatch`: type, max response @@ -146,13 +151,11 @@ version under that name to the layer it carries. - refused, below - the data model; a :obj:`dict` is refused -:class:`~pcapkit.protocols.application.http.HTTP` dispatches otherwise. Its octets -go to :meth:`~pcapkit.protocols.application.http.HTTP._dispatch_version`, which reads -the connection preface, a start line or a frame header, for a payload a transport -port binds to HTTP as well. Without ``version``, its -:meth:`~pcapkit.protocols.application.http.HTTP._dispatch_fields` picks by each -member's own fields and builds ``HTTPv1`` from none, and its ``from_data`` reads the -data model, or a :obj:`dict` by its ``receipt`` or ``sid``. +HTTP octets carry their version, but the HTTP data models have no top-level +``version`` field, only HTTP/1's ``receipt.version`` string, so +:meth:`HTTP.from_data ` reads the +data model's class. It refuses a :obj:`dict` even though the ``receipt`` and ``sid`` +keys would tell the members apart, since no key picks a member (:issue:`2628`). **Octets that carry no version are refused through the base without** ``version`` (:issue:`2574`). An ICMP message and a Linux cooked-capture header have no version diff --git a/docs/source/pcapkit/protocols/application/http.rst b/docs/source/pcapkit/protocols/application/http.rst index 7950d40cb5..49fe6182dc 100644 --- a/docs/source/pcapkit/protocols/application/http.rst +++ b/docs/source/pcapkit/protocols/application/http.rst @@ -20,7 +20,10 @@ which tells whether a payload opens with an HTTP/1.* start line. :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or :class:`~pcapkit.protocols.application.httpv2.HTTPv2` rather than an instance of it, so the data sits under ``httpv1`` or ``httpv2`` rather than ``http`` - (:issue:`2524`). + (:issue:`2524`). Building it from fields takes ``version`` and is refused + without it, where it built HTTP/1.*, and its + :meth:`~pcapkit.protocols.application.http.HTTP.from_data` refuses a + :obj:`dict`, where it read the ``receipt`` or ``sid`` key (:issue:`2628`). .. autoclass:: pcapkit.protocols.application.http.HTTP :no-members: @@ -38,11 +41,11 @@ which tells whether a payload opens with an HTTP/1.* start line. .. automethod:: _make_data + .. automethod:: __init__ .. automethod:: __new__ .. automethod:: _guess_version .. automethod:: _dispatch_version - .. automethod:: _dispatch_fields .. automethod:: _member_of_version .. autoattribute:: _preface_length diff --git a/pcapkit/protocols/application/http.py b/pcapkit/protocols/application/http.py index 08d13e6e03..ed56d69b64 100644 --- a/pcapkit/protocols/application/http.py +++ b/pcapkit/protocols/application/http.py @@ -23,18 +23,19 @@ :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or :class:`~pcapkit.protocols.application.httpv2.HTTPv2` rather than an instance of it, so the data sits under ``httpv1`` or ``httpv2`` rather than ``http`` - (:issue:`2524`). + (:issue:`2524`). Building it from fields takes ``version`` and is refused + without it, where it built HTTP/1.*, and its + :meth:`~pcapkit.protocols.application.http.HTTP.from_data` refuses a + :obj:`dict`, where it read the ``receipt`` or ``sid`` key (:issue:`2628`). """ import contextlib -import inspect import re import struct from typing import TYPE_CHECKING, Generic, cast from pcapkit.corekit.protochain import ProtoChain from pcapkit.protocols.application.application import Application -from pcapkit.protocols.data.data import Data from pcapkit.protocols.misc.no_payload import NoPayload from pcapkit.protocols.protocol import _PT, _ST from pcapkit.utilities.exceptions import ProtocolError @@ -146,21 +147,6 @@ def _test_http2_frame(data: 'bytes') -> 'bool': _HTTP2_PREFACE_HEADER = _HTTP2_PREFACE.split(b'\r\n\r\n', maxsplit=1)[0] -def _make_fields(klass: 'Type[HTTP]') -> 'frozenset[str]': - """Names of the fields ``klass.make`` takes, as keywords. - - Args: - klass: An HTTP member class. - - Returns: - Every named parameter of its ``make`` but ``self``. - - """ - kinds = (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) - return frozenset(name for name, param in inspect.signature(klass.make).parameters.items() - if name != 'self' and param.kind in kinds) - - def test_start_line(data: 'bytes') -> 'bool': """Whether ``data`` opens with an HTTP/1.* start line. @@ -237,7 +223,11 @@ class HTTP(Application[_PT, _ST], Generic[_PT, _ST]): and :meth:`from_data` give :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or :class:`~pcapkit.protocols.application.httpv2.HTTPv2`, never an instance of - this class (:issue:`2524`); see :meth:`__new__`. + this class (:issue:`2524`). ``version`` names the member; without it, octets + give the one :meth:`_dispatch_version` identifies, and fields are refused, + since no field picks a member (:issue:`2574`). :meth:`from_data` gives the + member whose data model it is handed, and refuses a :obj:`dict`. See + :meth:`__new__`. """ @@ -383,13 +373,14 @@ def read(self, length: 'Optional[int]' = None, *, return info def make(self, - version: 'Literal[1, 2]' = 1, *, + version: 'Optional[Literal[1, 2]]' = None, *, preface: 'bool' = False, **kwargs: 'Any') -> '_ST': """Make (construct) packet data. Args: - version: Version of HTTP. + version: Version of HTTP, ``1`` or ``2``. There is no default: no + field picks a member (:issue:`2574`). preface: Whether the HTTP/2 connection preface goes ahead of the frame; :meth:`pack` writes it. **kwargs: Arbitrary keyword arguments. @@ -398,20 +389,16 @@ def make(self, The header schema the versioned ``make`` constructs. Raises: - ProtocolError: If ``version`` is unknown, or if ``preface`` is - requested for a version other than HTTP/2. + ProtocolError: If ``version`` is missing or names neither member, or + if ``preface`` is requested for a version other than HTTP/2. """ + if version is None: + raise ProtocolError('HTTP: no version to build with; pass version=1 or version=2') + protocol = self._member_of_version(version) if preface and version != 2: raise ProtocolError(f'HTTP/{version}: connection preface is HTTP/2 only') - if version == 1: - from pcapkit.protocols.application.httpv1 import HTTPv1 as protocol # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - elif version == 2: - from pcapkit.protocols.application.httpv2 import HTTPv2 as protocol # type: ignore[assignment] # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - else: - raise ProtocolError(f'HTTP: invalid version: {version!r}') - # NOTE: ``protocol.make`` is an ordinary instance method (the abstract # declaration at ``ProtocolBase.make`` takes ``self``, and the # versioned overrides use instance-bound helpers such as @@ -447,56 +434,87 @@ def from_data(cls, data: '_PT | dict[str, Any]', **kwargs: 'Any') -> 'Self': """Create protocol instance from data. Args: - data: Protocol data. A :obj:`dict`, as :meth:`Info.to_dict - ` returns it, is rebuilt - into the versioned data model its keys belong to -- ``receipt`` - is HTTP/1.*'s and ``sid`` HTTP/2's -- since this class's own data - model carries no version. + data: Protocol data. **kwargs: As :meth:`Protocol.from_data `. Returns: - Protocol instance; on this class, an instance of the member the data - model belongs to (:issue:`2524`). + Protocol instance; on this class, an instance of the member whose data + model ``data`` is (:issue:`2524`). Raises: - ProtocolError: If ``data`` is neither HTTP/1.* nor HTTP/2 data, on - this class. + ProtocolError: If ``data`` is neither member's data model, on this + class. A :obj:`dict` is refused: it carries no version, and no + key picks a member (:issue:`2574`). """ + if cls is not HTTP: + return super().from_data(data, **kwargs) + from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel from pcapkit.protocols.data.application.httpv1 import HTTPv1 as Data_HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel from pcapkit.protocols.data.application.httpv2 import HTTPv2 as Data_HTTPv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - if not isinstance(data, Data) and not issubclass(cls.__data__, (Data_HTTPv1, Data_HTTPv2)): - if 'receipt' in data: - data = cast('_PT', Data_HTTPv1.from_dict(data)) - elif 'sid' in data: - data = cast('_PT', Data_HTTPv2.from_dict(data)) - if cls is not HTTP: - return super().from_data(data, **kwargs) if isinstance(data, Data_HTTPv1): return cast('Self', HTTPv1.from_data(data, **kwargs)) if isinstance(data, Data_HTTPv2): return cast('Self', HTTPv2.from_data(data, **kwargs)) - raise ProtocolError(f'HTTP: invalid data: must be HTTP/1.* or HTTP/2 data, ' - f'not {type(data).__name__}') + raise ProtocolError('HTTP: invalid data: must be an HTTPv1 or HTTPv2 data model') ########################################################################## # Data models. ########################################################################## + def __init__(self, file: 'Optional[IO[bytes] | bytes]' = None, + length: 'Optional[int]' = None, *, + version: 'Optional[Literal[1, 2]]' = None, **kwargs: 'Any') -> 'None': + """Initialisation. + + Args: + file: Source packet stream. + length: Length of packet data. + version: Version of HTTP, ``1`` or ``2``: the member :meth:`__new__` + picks. Building from fields, it must be this class's own. Parsing, + it is handed on unread. + **kwargs: As :meth:`Protocol.__init__ + `. + + Raises: + ProtocolError: If, building from fields, ``version`` names neither + member, or is not this class's own version: that of the member + it is or derives from, which a subclass outside the members + does not have; or if ``preface`` is asked of HTTP/1. + + """ + if file is not None: + if version is not None: + kwargs['version'] = version + else: + from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=import-outside-toplevel + from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=import-outside-toplevel + + if version is not None and not isinstance(self, self._member_of_version(version)): + if not isinstance(self, (HTTPv1, HTTPv2)): + raise ProtocolError(f'{type(self).__name__}: no version of its own to match ' + f'version={version!r}') + raise ProtocolError(f'{type(self).__name__}: version={version!r} ' + 'names another member') + # NOTE: ``pack`` writes the preface for any member, so HTTP/1 refuses it here. + if kwargs.get('preface') and isinstance(self, HTTPv1): + raise ProtocolError('HTTP/1: connection preface is HTTP/2 only') + super().__init__(file, length, **kwargs) # type: ignore[arg-type] + def __new__(cls, file: 'Optional[IO[bytes] | bytes]' = None, # type: ignore[misc] length: 'Optional[int]' = None, **kwargs: 'Any') -> 'HTTP': """Create the member a construction of this class describes. - Parsing octets gives the member an explicit ``version`` names, or else - the one :meth:`_dispatch_version` identifies; building from fields gives - the one :meth:`_dispatch_fields` picks. That member's ``__init__`` then - runs with the same arguments, which it accepts, since a member inherits - this class's ``version`` and ``preface`` keywords. A subclass of this - class is created as itself. + The ``version`` keyword names the member, on either path. Without it, + parsing octets gives the member :meth:`_dispatch_version` identifies, + since HTTP octets carry their own version, and building from fields is + refused, since no field picks a member (:issue:`2574`). That member's + ``__init__`` then runs with the same arguments. A subclass of this class + is created as itself. Args: file: Source packet stream, read here without being consumed. @@ -507,17 +525,17 @@ class is created as itself. An uninitialised instance of the member. Raises: - ProtocolError: If the octets are neither version's, including an - HTTP/2 connection preface with no frame after it, or if - :meth:`_dispatch_fields` refuses the fields. + ProtocolError: If ``version`` names neither member; or, without it, + if building from fields, or if the octets are neither version's, + including an HTTP/2 connection preface with no frame after it. """ if cls is not HTTP: return super().__new__(cls, file, length, **kwargs) # type: ignore[call-arg] - if file is None: - member = cls._dispatch_fields(kwargs) - elif kwargs.get('version') is not None: + if kwargs.get('version') is not None: member = cls._member_of_version(kwargs['version']) + elif file is None: + raise ProtocolError('HTTP: no version to build with; pass version=1 or version=2') else: if isinstance(file, bytes): data = file @@ -815,53 +833,6 @@ def _dispatch_version(cls, data: 'bytes', length: 'int') -> 'Type[ProtocolBase]' return HTTPv2 return Raw - @classmethod - def _dispatch_fields(cls, fields: 'dict[str, Any]') -> 'Type[HTTP]': - """Pick the member that builds a packet from the keyword ``fields``. - - An explicit ``version`` names the member. Otherwise a field that only - :meth:`HTTPv2.make ` - takes picks :class:`~pcapkit.protocols.application.httpv2.HTTPv2`, and a - field that only :meth:`HTTPv1.make - ` takes, or no field - of either, picks :class:`~pcapkit.protocols.application.httpv1.HTTPv1`, - the version :meth:`make` defaults to. ``preface`` counts only when true, - as in the ``version`` check, since its default is :data:`False`. - - Args: - fields: Construction keywords. - - Returns: - :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or - :class:`~pcapkit.protocols.application.httpv2.HTTPv2`. - - Raises: - ProtocolError: If ``version`` is neither ``1`` nor ``2``, if - ``preface`` is asked of HTTP/1, or if ``fields`` holds fields - only HTTP/1 takes and fields only HTTP/2 takes. - - """ - from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=import-outside-toplevel - from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=import-outside-toplevel - - version = fields.get('version') - if version is not None: - member = cls._member_of_version(version) - if fields.get('preface') and member is not HTTPv2: - raise ProtocolError(f'HTTP/{version}: connection preface is HTTP/2 only') - return member - - # NOTE: ``preface=False`` is the default, not a request for HTTP/2. - names = {name for name, value in fields.items() if name != 'preface' or value} - own1, own2 = _make_fields(HTTPv1), _make_fields(HTTPv2) - only1, only2 = names & (own1 - own2), names & (own2 - own1) - if only1 and only2: - if only2 == {'preface'}: - raise ProtocolError('HTTP/1: connection preface is HTTP/2 only') - raise ProtocolError(f'HTTP: fields of both HTTP/1 ({", ".join(sorted(only1))}) ' - f'and HTTP/2 ({", ".join(sorted(only2))})') - return HTTPv2 if only2 else HTTPv1 - @staticmethod def _member_of_version(version: 'Any') -> 'Type[HTTP]': """The member a ``version`` of ``1`` or ``2`` names. @@ -874,14 +845,16 @@ def _member_of_version(version: 'Any') -> 'Type[HTTP]': :class:`~pcapkit.protocols.application.httpv2.HTTPv2`. Raises: - ProtocolError: If ``version`` is neither. + ProtocolError: If ``version`` is neither, or is no :obj:`int`: a + :obj:`bool` or a :obj:`float` is refused even where it equals one. """ from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=import-outside-toplevel from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=import-outside-toplevel - if version == 1: - return HTTPv1 - if version == 2: - return HTTPv2 + if isinstance(version, int) and not isinstance(version, bool): + if version == 1: + return HTTPv1 + if version == 2: + return HTTPv2 raise ProtocolError(f'HTTP: invalid version: {version!r}') diff --git a/tests/protocols/application/test_http2_roundtrip_unit.py b/tests/protocols/application/test_http2_roundtrip_unit.py index 4376d3ff70..d7f4c91097 100644 --- a/tests/protocols/application/test_http2_roundtrip_unit.py +++ b/tests/protocols/application/test_http2_roundtrip_unit.py @@ -254,7 +254,7 @@ def test_parse_then_rebuild_is_byte_exact(self) -> None: with self.subTest(segment=name): parsed = HTTP(wire, len(wire)) for rebuilt in (HTTP.from_data(parsed.info), HTTPv2.from_data(parsed.info), - HTTP.from_data(parsed.info.to_dict())): + HTTPv2.from_data(parsed.info.to_dict())): self.assertEqual(bytes(rebuilt).hex(), wire.hex()) self.assertEqual(len(_layers(rebuilt)[0]), len(_split_frames(wire))) diff --git a/tests/protocols/application/test_http_base_dispatch_2544_unit.py b/tests/protocols/application/test_http_base_dispatch_2544_unit.py index 091444959d..3c59abed90 100644 --- a/tests/protocols/application/test_http_base_dispatch_2544_unit.py +++ b/tests/protocols/application/test_http_base_dispatch_2544_unit.py @@ -5,8 +5,9 @@ through :class:`~pcapkit.protocols.application.http.HTTP`, constructing it from fields, and its ``from_data`` all give ``HTTPv1`` or ``HTTPv2``. On the read path the member is the one an explicit ``version`` names or ``_dispatch_version`` -identifies; on the make path the one ``version`` names or the fields describe. -Octets neither version takes raise, as they do through either member. +identifies; on the make path the one ``version`` names, which #2628's module +pins as required there. Octets neither version takes raise, as they do through +either member. This module is unit-tier: every packet is built in memory. @@ -99,15 +100,14 @@ def test_octets_neither_version_takes_raise(self) -> None: with self.assertRaisesRegex(ProtocolError, message): HTTP(octets) - def test_building_through_the_base_gives_the_member_the_fields_describe(self) -> None: + def test_building_through_the_base_gives_the_member_version_names(self) -> None: from pcapkit.const.http.frame import Frame HTTP, HTTPv1, HTTPv2 = self.classes() cases = { 'version 1': ({'version': 1, 'method': 'GET', 'uri': '/'}, HTTPv1), - 'HTTP/1 fields': ({'method': 'GET', 'uri': '/'}, HTTPv1), 'version 2': ({'version': 2, 'type': Frame.DATA, 'sid': 1}, HTTPv2), - 'HTTP/2 fields': ({'type': Frame.DATA, 'sid': 1, 'frame': {'data': b'x'}}, HTTPv2), + 'frame': ({'version': 2, 'type': Frame.DATA, 'sid': 1, 'frame': {'data': b'x'}}, HTTPv2), 'preface': ({'version': 2, 'preface': True, 'type': Frame.SETTINGS}, HTTPv2), } for label, (fields, klass) in cases.items(): @@ -120,49 +120,36 @@ def test_building_through_the_base_gives_the_member_the_fields_describe(self) -> self.assertEqual(layer.data, direct.data) self.assertTrue(HTTP(version=2, preface=True, type=Frame.SETTINGS).data.startswith(_PREFACE)) - def test_a_false_preface_names_no_version(self) -> None: - """``preface=False`` is the default, so it names no version. - - HTTP/1 fields beside it still build HTTP/1, as without it, and on its own it - picks HTTPv1, the version ``make`` defaults to; only a true ``preface`` means - HTTP/2. - """ + def test_a_false_preface_is_the_default(self) -> None: + """``preface=False`` builds what leaving it out builds, for either version.""" from pcapkit.const.http.frame import Frame - from pcapkit.utilities.exceptions import ProtocolError HTTP, HTTPv1, HTTPv2 = self.classes() - self.assertIs(HTTP._dispatch_fields({'preface': False}), HTTPv1) - self.assertIs(HTTP._dispatch_fields({'preface': True}), HTTPv2) with warnings.catch_warnings(): warnings.simplefilter('ignore') - with_false = HTTP(method='GET', uri='/', preface=False) - without = HTTP(method='GET', uri='/') - frame = HTTP(type=Frame.DATA, sid=1, preface=False) + with_false = HTTP(version=1, method='GET', uri='/', preface=False) + without = HTTP(version=1, method='GET', uri='/') + frame = HTTP(version=2, type=Frame.DATA, sid=1, preface=False) self.assertIs(type(with_false), HTTPv1) self.assertEqual(with_false.data, without.data) self.assertIs(type(frame), HTTPv2) self.assertFalse(frame.data.startswith(_PREFACE)) - # Alone it builds what ``HTTP()`` builds: an HTTP/1 message with no start - # line, which HTTPv1 refuses. - with self.assertRaisesRegex(ProtocolError, 'invalid start line'): - HTTP(preface=False) - def test_fields_no_one_version_builds_raise(self) -> None: + def test_fields_the_named_version_cannot_build_raise(self) -> None: from pcapkit.utilities.exceptions import ProtocolError HTTP, _, _ = self.classes() - for fields, message in (({'method': 'GET', 'sid': 1}, 'fields of both'), - ({'version': 1, 'preface': True}, 'HTTP/2 only'), - ({'method': 'GET', 'uri': '/', 'preface': True}, + for fields, message in (({'version': 1, 'preface': True}, + r'^HTTP/1: connection preface is HTTP/2 only$'), + ({'version': 1, 'method': 'GET', 'uri': '/', 'preface': True}, r'^HTTP/1: connection preface is HTTP/2 only$'), - ({'method': 'GET', 'preface': True, 'sid': 1}, - r'^HTTP: fields of both HTTP/1 \(method\) and HTTP/2 \(preface, sid\)$'), ({'version': 3}, 'invalid version')): with self.subTest(fields=fields): with self.assertRaisesRegex(ProtocolError, message): HTTP(**fields) def test_from_data_on_the_base_gives_the_member(self) -> None: + """A data model rebuilds through the base, a :obj:`dict` through its member.""" from pcapkit.utilities.exceptions import ProtocolError HTTP, _, _ = self.classes() @@ -173,9 +160,10 @@ def test_from_data_on_the_base_gives_the_member(self) -> None: for form in ('info', 'dict'): with self.subTest(octets=label, form=form): data = parsed.info if form == 'info' else parsed.info.to_dict() + klass = HTTP if form == 'info' else self.member(name) with warnings.catch_warnings(): warnings.simplefilter('ignore') - rebuilt = HTTP.from_data(data) + rebuilt = klass.from_data(data) self.assertIs(type(rebuilt), self.member(name)) self.assertEqual(rebuilt.data, octets) with self.assertRaisesRegex(ProtocolError, 'invalid data'): @@ -186,7 +174,8 @@ def test_built_packets_parse_back_through_the_base(self) -> None: from pcapkit.const.http.frame import Frame HTTP, _, _ = self.classes() - for fields in ({'method': 'GET', 'uri': '/'}, {'type': Frame.DATA, 'sid': 1}, + for fields in ({'version': 1, 'method': 'GET', 'uri': '/'}, + {'version': 2, 'type': Frame.DATA, 'sid': 1}, {'version': 2, 'preface': True, 'type': Frame.SETTINGS}): with self.subTest(fields=fields): with warnings.catch_warnings(): diff --git a/tests/protocols/application/test_http_base_dispatch_2628_unit.py b/tests/protocols/application/test_http_base_dispatch_2628_unit.py new file mode 100644 index 0000000000..3ea061eaac --- /dev/null +++ b/tests/protocols/application/test_http_base_dispatch_2628_unit.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +"""The HTTP family base follows the versioned-family rules. C.f. #2628. + +The rules are #2574's: ``version`` names the member, no field picks one and no +member is a default. So building :class:`~pcapkit.protocols.application.http.HTTP` +from fields, or calling its ``make``, raises without ``version``; v1.5.1 built +HTTP/1.* by default, and #2544's ``_dispatch_fields`` picked HTTP/2 for a field +only HTTP/2 takes. Its ``from_data`` gives the member whose data model it is +handed and refuses a ``dict``, which v1.5.1 rebuilt by its ``receipt`` or ``sid`` +key. Octets dispatch without ``version``, since HTTP octets carry their own; +#2544's module covers that. A member takes ``version`` only as its own, as ICMP's +do. + +Every case builds its own octets in memory and reads no capture. Classes are +imported inside each test, after :func:`~tests._support.reimport_once_per_class`. + +""" + +import unittest + +from tests._support import reimport_once_per_class + +#: An HTTP/1.1 request and an HTTP/2 ``DATA`` frame on stream 1. +OCTETS = { + 1: b'GET / HTTP/1.1\r\nHost: example.invalid\r\n\r\n', + 2: bytes.fromhex('000000000000000001'), +} + +#: The refusal of fields given no ``version``. +NO_VERSION_FIELDS = '^HTTP: no version to build with; pass version=1 or version=2$' + + +def fields(version: 'int') -> 'dict[str, object]': + """Fields only the member of ``version`` takes.""" + from pcapkit.const.http.frame import Frame + + return {'method': 'GET', 'uri': '/'} if version == 1 else {'type': Frame.DATA, 'sid': 1} + + +class HTTPBaseVersionRuleTests(unittest.TestCase): + """``version`` alone picks the member; no field, key or default does.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def member(version: 'int') -> 'type': + from pcapkit.protocols.application.httpv1 import HTTPv1 + from pcapkit.protocols.application.httpv2 import HTTPv2 + + return {1: HTTPv1, 2: HTTPv2}[version] + + def test_fields_without_version_raise(self) -> None: + """Even a field only one member takes picks nothing, ``preface`` included.""" + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.exceptions import ProtocolError + + for label, kwargs in (('no fields', {}), ('HTTP/1 fields', fields(1)), + ('HTTP/2 fields', fields(2)), ('preface', {'preface': True}), + ('false preface', {'preface': False})): + with self.subTest(fields=label): + with self.assertRaisesRegex(ProtocolError, NO_VERSION_FIELDS): + HTTP(**kwargs) + + def test_make_has_no_default_version(self) -> None: + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.exceptions import ProtocolError + + for version in (1, 2): + with self.subTest(fields=version): + with self.assertRaisesRegex(ProtocolError, NO_VERSION_FIELDS): + HTTP.make(object.__new__(HTTP), **fields(version)) # type: ignore[arg-type] + schema = HTTP.make(object.__new__(HTTP), version=version, **fields(version)) # type: ignore[arg-type] + self.assertEqual(schema.pack(), self.member(version)(**fields(version)).data) + + def test_version_is_the_only_check(self) -> None: + """A bad ``version`` raises on either path; a field the member lacks fails its check.""" + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall + + for version in (0, 3, True, '1', 1.0, 2.0): + with self.subTest(version=version): + with self.assertRaisesRegex(ProtocolError, f'^HTTP: invalid version: {version!r}$'): + HTTP(version=version, **fields(1)) + with self.assertRaisesRegex(ProtocolError, f'^HTTP: invalid version: {version!r}$'): + HTTP(OCTETS[1], version=version) + with self.assertRaisesRegex(UnsupportedCall, "^HTTPv1: unexpected keyword\\(s\\): 'sid'"): + HTTP(version=1, sid=1, **fields(1)) + + def test_a_member_takes_version_only_as_its_own(self) -> None: + """Building, ``version`` must name the member; parsing, it is handed on unread.""" + from pcapkit.protocols.application.httpv1 import HTTPv1 + from pcapkit.protocols.data.application.httpv1 import \ + HTTPv1 as Data_HTTPv1 + from pcapkit.protocols.schema.application.httpv1 import \ + HTTPv1 as Schema_HTTPv1 + from pcapkit.utilities.exceptions import ProtocolError + + class Custom(HTTPv1, schema=Schema_HTTPv1, data=Data_HTTPv1): + """A subclass of a member, which is that member's version.""" + + for version, other in ((1, 2), (2, 1)): + klass = self.member(version) + with self.subTest(member=klass.__name__): + self.assertEqual(klass(version=version, **fields(version)).data, + klass(**fields(version)).data) + with self.assertRaisesRegex(ProtocolError, + f'^{klass.__name__}: version={other} names another member$'): + klass(version=other, **fields(version)) + self.assertEqual(klass(OCTETS[version], version=other).data, OCTETS[version]) + self.assertIs(type(Custom(version=1, **fields(1))), Custom) + with self.assertRaisesRegex(ProtocolError, '^Custom: version=2 names another member$'): + Custom(version=2, **fields(1)) + + def test_a_subclass_outside_the_members_has_no_version_of_its_own(self) -> None: + """So it takes no ``version``, as a member takes no other's.""" + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.exceptions import ProtocolError + + class Outside(HTTP): # type: ignore[type-arg] + """A concrete subclass of the base that is no member.""" + + @classmethod + def __index__(cls) -> 'int': # type: ignore[override] + raise NotImplementedError + + def read(self, length: 'object' = None, **kwargs: 'object') -> 'object': + raise NotImplementedError + + def make(self, **kwargs: 'object') -> 'object': + raise NotImplementedError + + for version in (1, 2): + with self.subTest(version=version): + with self.assertRaisesRegex(ProtocolError, + f'^Outside: no version of its own to match version={version}$'): + Outside(version=version) + + def test_http1_refuses_a_preface(self) -> None: + """``pack`` writes the preface for any member, so HTTP/1 refuses it, built directly too.""" + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.exceptions import ProtocolError + + for label, build in (('base', lambda: HTTP(version=1, preface=True, **fields(1))), + ('member', lambda: self.member(1)(preface=True, **fields(1))), + ('make', lambda: HTTP.make(object.__new__(HTTP), version=1, # type: ignore[arg-type] + preface=True, **fields(1)))): + with self.subTest(built=label): + with self.assertRaisesRegex(ProtocolError, '^HTTP/1: connection preface is HTTP/2 only$'): + build() + self.assertEqual(self.member(1)(preface=False, **fields(1)).data, self.member(1)(**fields(1)).data) + + def test_from_data_dispatches_by_class_and_refuses_a_dict(self) -> None: + """No key picks a member, though ``receipt`` or ``sid`` would tell them apart.""" + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.exceptions import ProtocolError + + for version, octets in OCTETS.items(): + with self.subTest(octets=version): + parsed = self.member(version)(octets) + rebuilt = HTTP.from_data(parsed.info) + self.assertIs(type(rebuilt), self.member(version)) + self.assertEqual(rebuilt.data, octets) + self.assertEqual(self.member(version).from_data(parsed.info.to_dict()).data, octets) + with self.assertRaisesRegex(ProtocolError, + '^HTTP: invalid data: must be an HTTPv1 or HTTPv2 data model$'): + HTTP.from_data(parsed.info.to_dict()) + + def test_from_data_takes_version_only_as_the_data_models_own(self) -> None: + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.exceptions import ProtocolError + + for version, other in ((1, 2), (2, 1)): + klass = self.member(version) + with self.subTest(member=klass.__name__): + info = klass(OCTETS[version]).info + self.assertEqual(HTTP.from_data(info, version=version).data, OCTETS[version]) + with self.assertRaisesRegex(ProtocolError, + f'^{klass.__name__}: version={other} names another member$'): + HTTP.from_data(info, version=other) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_http_message_prefix_2298_unit.py b/tests/protocols/application/test_http_message_prefix_2298_unit.py index 00d657529c..93f4e0e2be 100644 --- a/tests/protocols/application/test_http_message_prefix_2298_unit.py +++ b/tests/protocols/application/test_http_message_prefix_2298_unit.py @@ -39,7 +39,7 @@ def test_make_names_the_rejected_version(self) -> None: from pcapkit.utilities.exceptions import ProtocolError with self.assertRaises(ProtocolError) as ctx: - HTTP.make(HTTP.__new__(HTTP), version=3) # type: ignore[arg-type] + HTTP.make(object.__new__(HTTP), version=3) # type: ignore[arg-type] self.assertEqual(str(ctx.exception), 'HTTP: invalid version: 3') def test_read_names_the_rejected_version(self) -> None: diff --git a/tests/protocols/application/test_httpv2_frame_default_2274_unit.py b/tests/protocols/application/test_httpv2_frame_default_2274_unit.py index 06115417c6..ea9ddbfd32 100644 --- a/tests/protocols/application/test_httpv2_frame_default_2274_unit.py +++ b/tests/protocols/application/test_httpv2_frame_default_2274_unit.py @@ -28,7 +28,7 @@ from tests._support import reimport_once_per_class -#: Octets ``HTTP(type=, sid=)`` builds, by frame type name and Stream +#: Octets ``HTTPv2(type=, sid=)`` builds, by frame type name and Stream #: Identifier. Every one of them is also what ``frame={}`` builds. DEFAULT_FRAMES = { 'DATA': (1, bytes.fromhex('000000000000000001')), diff --git a/tests/protocols/test_to_dict_payload_roundtrip_1447_unit.py b/tests/protocols/test_to_dict_payload_roundtrip_1447_unit.py index 6d1120d16e..241a4f1738 100644 --- a/tests/protocols/test_to_dict_payload_roundtrip_1447_unit.py +++ b/tests/protocols/test_to_dict_payload_roundtrip_1447_unit.py @@ -167,7 +167,7 @@ def test_http_dict_keeps_its_version(self) -> None: with self.subTest(frame=name): http = self.frame(name)[HTTP] self.assertEqual(http.alias, alias) - self.assertEqual(HTTP.from_data(http.info.to_dict()).alias, alias) + self.assertEqual(type(http).from_data(http.info.to_dict()).alias, alias) def test_dict_without_a_payload_key_rebuilds_the_header_alone(self) -> None: from pcapkit.protocols.link.ethernet import Ethernet